Layr Components

Components / Hero

Commits

A pinned hero where one sentence is edited commit by commit as you scroll: cut words strike through, new ones fade in, a cover ships last.

Scroll insideFull screen
Props
60%
#0E0E0D
#6E6E6B
#A3A3A0
#FF4A1C
#E6E5E1
76 px
48 px

Set in code: Edits, Image, Link. The preview fills these in with demo content.

Scroll inside the preview, or click an edit in the list.

In Framer, open Assets, add a new code file, paste this over everything in it and save. Commits shows up in the Insert menu, and its controls appear in the right panel.
// Commits · Layr Components
//
// A pinned hero where one sentence is edited in front of the visitor. Each
// scroll step applies the next edit, listed like a commit history: the words
// it removes are struck out, then the new wording arrives. The last edit adds a
// result line in the quiet tone and brings in a cover picture. Click an edit in
// the list to jump to it.
// Paste into a new code file in Framer. Place it at the top of the page at full width; it is taller than the window and pins itself while the edits play. Without a cover picture the last edit shows a plain tone.

import { addPropertyControls, ControlType, useIsStaticRenderer } from "framer"
import { useEffect, useRef, type CSSProperties } from "react"

type Picture = { src: string; srcSet?: string; alt?: string }
type Edit = { message?: string; sentence?: string; day?: string }

interface CommitsProps {
    label: string
    edits: Edit[]
    resultLine: string
    image?: Picture
    altText: string
    link?: string
    linkLabel: string
    waitLabel: string
    scrollLength: number
    fontFamily: string
    ink: string
    mute: string
    quietTone: string
    accent: string
    hairline: string
    topSpace: number
    sideSpace: number
    style?: CSSProperties
}

const EDITS: Edit[] = [
    { message: "First draft", sentence: "I am a passionate, results-driven full-stack developer who loves crafting scalable solutions.", day: "Monday" },
    { message: "Remove the adjectives", sentence: "I am a full-stack developer who loves crafting solutions.", day: "Tuesday" },
    { message: "Say what I build", sentence: "I am a software engineer who builds fast web apps.", day: "Wednesday" },
    { message: "Shorter", sentence: "I build fast web apps.", day: "Thursday" },
    { message: "Add the number", sentence: "I build fast web apps.", day: "Today" },
]

const CSS = `
.lyr-cmt{position:relative;width:100%;color:var(--ink);-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility;height:calc(100vh + var(--n) * var(--step) * 1vh);height:calc(100svh + var(--n) * var(--step) * 1svh)}
.lyr-cmt *{box-sizing:border-box}
.lyr-cmt-pin{position:sticky;top:0;width:100%;height:100vh;height:100svh;container-type:size;overflow:clip}
.lyr-cmt.lyr-cmt-static{height:820px}
.lyr-cmt.lyr-cmt-static .lyr-cmt-pin{position:relative;height:820px}
.lyr-cmt-box{position:absolute;inset:0;padding:calc(var(--top) + clamp(12px,4.4cqh,44px)) var(--side) clamp(18px,4.4cqh,44px);display:grid;grid-template-columns:clamp(210px,22cqw,330px) minmax(0,1fr);grid-template-rows:minmax(0,1fr) auto;column-gap:clamp(28px,5cqw,84px);row-gap:clamp(14px,3cqh,30px)}
.lyr-cmt-log{grid-column:1;grid-row:1 / 3;min-width:0;font-size:15px;letter-spacing:-0.01em;line-height:1.3}
.lyr-cmt-label{color:var(--mute);margin:0 0 clamp(12px,3cqh,28px)}
.lyr-cmt-item{display:flex;justify-content:space-between;gap:12px;width:100%;padding:clamp(9px,1.8cqh,15px) 0;margin:0;border:0;border-top:1px solid var(--hair);background:none;font:inherit;color:var(--quiet);text-align:left;cursor:pointer;transition:color .25s}
.lyr-cmt-item:last-of-type{border-bottom:1px solid var(--hair)}
.lyr-cmt-item b{font-weight:400;display:flex;align-items:center;gap:11px;min-width:0}
.lyr-cmt-item b::before{content:"";width:9px;height:9px;flex:none;background:var(--accent);opacity:0;transition:opacity .25s}
.lyr-cmt-item span{flex:none;color:var(--quiet)}
.lyr-cmt-item.lyr-cmt-done{color:var(--mute)}
.lyr-cmt-item.lyr-cmt-cur{color:var(--ink)}
.lyr-cmt-item.lyr-cmt-cur b::before{opacity:1}
.lyr-cmt-item:hover{color:var(--ink)}
.lyr-cmt-item:focus-visible{outline:1px solid var(--ink);outline-offset:2px}
.lyr-cmt-stage{grid-column:2;grid-row:1;min-width:0;display:grid;align-content:start;--fs:clamp(28px,min(5.9cqw,(100cqh - var(--top) - var(--strip) - 110px) / 5.5),96px);--strip:clamp(110px,27cqh,270px)}
.lyr-cmt-v{grid-area:1 / 1;margin:0;font-weight:400;font-size:var(--fs);line-height:1.03;letter-spacing:-0.042em;visibility:hidden;text-wrap:pretty}
.lyr-cmt-v.lyr-cmt-cur{visibility:visible}
.lyr-cmt-w{background:linear-gradient(currentColor,currentColor) no-repeat 0 56% / 0% .055em;transition:color .35s ease,background-size .4s ease}
.lyr-cmt-v.lyr-cmt-strike .lyr-cmt-w.lyr-cmt-out{color:var(--quiet);background-size:100% .055em}
.lyr-cmt-v.lyr-cmt-cur .lyr-cmt-w.lyr-cmt-in{animation:lyr-cmt-fade .5s ease both;animation-delay:calc(var(--k) * 45ms + 80ms)}
.lyr-cmt-w.lyr-cmt-q{color:var(--quiet)}
@keyframes lyr-cmt-fade{from{opacity:0}to{opacity:1}}
.lyr-cmt-strip{grid-column:2;grid-row:2;position:relative;height:clamp(110px,27cqh,270px);border-radius:6px;overflow:hidden;box-shadow:inset 0 0 0 1px var(--hair);display:block;color:inherit;text-decoration:none}
.lyr-cmt-strip img,.lyr-cmt-strip .lyr-cmt-ph{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;object-position:left top;display:block;opacity:0;transition:opacity .6s ease}
.lyr-cmt.lyr-cmt-shipped .lyr-cmt-strip img,.lyr-cmt.lyr-cmt-shipped .lyr-cmt-ph{opacity:1}
.lyr-cmt.lyr-cmt-shipped a.lyr-cmt-strip:hover img{opacity:.86}
.lyr-cmt-wait{position:absolute;left:16px;top:13px;font-size:14px;letter-spacing:-0.005em;color:var(--quiet);transition:opacity .3s}
.lyr-cmt.lyr-cmt-shipped .lyr-cmt-wait{opacity:0}
.lyr-cmt-now{display:none}
.lyr-cmt-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap;margin:0}
@container (max-aspect-ratio: 1/1) or (max-width: 700px){
.lyr-cmt-box{grid-template-columns:minmax(0,1fr);grid-template-rows:auto minmax(0,1fr) auto;padding-top:calc(var(--top) + clamp(8px,2.4cqh,26px))}
.lyr-cmt-log{grid-column:1;grid-row:1}
.lyr-cmt-label,.lyr-cmt-item{display:none}
.lyr-cmt-now{display:flex;justify-content:space-between;gap:12px;padding:0 0 12px;border-bottom:1px solid var(--hair);color:var(--mute)}
.lyr-cmt-now b{font-weight:400;color:var(--ink);display:flex;align-items:center;gap:10px}
.lyr-cmt-now b::before{content:"";width:9px;height:9px;background:var(--accent)}
.lyr-cmt-stage{grid-column:1;grid-row:2;--fs:clamp(26px,min(9.6cqw,(100cqh - var(--top) - var(--strip) - 120px) / 6.4),64px);--strip:clamp(96px,22cqh,240px);padding-top:6px}
.lyr-cmt-strip{grid-column:1;grid-row:3;height:clamp(96px,22cqh,240px)}
}
@media (prefers-reduced-motion: reduce){.lyr-cmt-w,.lyr-cmt-strip img,.lyr-cmt-ph,.lyr-cmt-item{transition:none}.lyr-cmt-v.lyr-cmt-cur .lyr-cmt-w.lyr-cmt-in{animation:none}}
`

// Shown in the cover strip with the last edit when no picture is set.
const EMPTY = "linear-gradient(160deg, #2c2925, #171512)"

const clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v))
const words = (s: string) => String(s || "").trim().split(/\s+/).filter(Boolean)
const key = (w: string) => w.toLowerCase().replace(/[.,;:!?]+$/g, "")

/** Which words of `a` also appear, in order, in `b` (longest common subsequence). */
function kept(a: string[], b: string[]) {
    const n = a.length
    const m = b.length
    const t = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0))
    for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) t[i][j] = key(a[i]) === key(b[j]) ? t[i + 1][j + 1] + 1 : Math.max(t[i + 1][j], t[i][j + 1])
    const inA = new Array(n).fill(false)
    const inB = new Array(m).fill(false)
    let i = 0
    let j = 0
    while (i < n && j < m) {
        if (key(a[i]) === key(b[j])) {
            inA[i] = inB[j] = true
            i++
            j++
        } else if (t[i + 1][j] >= t[i][j + 1]) i++
        else j++
    }
    return { inA, inB }
}

/**
 * @framerIntrinsicWidth 1200
 * @framerIntrinsicHeight 820
 *
 * @framerSupportedLayoutWidth fixed
 * @framerSupportedLayoutHeight any
 */
export default function Commits(props: CommitsProps) {
    const {
        label = "History of this headline",
        edits = EDITS,
        resultLine: resultProp = "Pages load in 96 ms, was 840.",
        image,
        altText = "",
        link,
        linkLabel = "Read the case study",
        waitLabel = "Ships with the last edit",
        scrollLength = 60,
        fontFamily = "Inter",
        ink = "#F0ECE4",
        mute = "#918D84",
        quietTone = "#625F58",
        accent = "#FFB02E",
        hairline = "rgba(240,236,228,0.14)",
        topSpace = 76,
        sideSpace = 48,
    } = props
    const resultLine = (() => {
        const r = String(resultProp || "").trim()
        return r && !/[.!?]$/.test(r) ? r + "." : r
    })()
    const isStatic = useIsStaticRenderer()
    const root = useRef<HTMLDivElement>(null)
    const commits = (edits || [])
        .map((c) => ({ message: String((c && c.message) || "").trim(), draft: String((c && c.sentence) || "").trim(), when: String((c && c.day) || "").trim() }))
        .filter((c) => c.draft)
    const N = Math.max(1, commits.length)
    const last = N - 1
    const lists = commits.map((c, n) => (n === last && resultLine ? [...words(c.draft), ...words(resultLine)] : words(c.draft)))
    const quietFrom = commits.map((c, n) => (n === last && resultLine ? words(c.draft).length : Infinity))
    const marks = lists.map((w, n) => ({
        out: n < last ? kept(w, lists[n + 1]).inA.map((x) => !x) : w.map(() => false),
        added: n > 0 ? kept(lists[n - 1], w).inB.map((x) => !x) : w.map(() => false),
    }))
    const messagesKey = commits.map((c) => c.message).join("\n")

    useEffect(() => {
        if (isStatic) return
        const el = root.current
        if (!el) return
        const messages = messagesKey.split("\n")
        const pin = el.querySelector(".lyr-cmt-pin") as HTMLElement
        const layers = Array.from(el.querySelectorAll(".lyr-cmt-v")) as HTMLElement[]
        const items = Array.from(el.querySelectorAll(".lyr-cmt-item")) as HTMLElement[]
        const nowMessage = el.querySelector(".lyr-cmt-now b") as HTMLElement | null
        const nowCount = el.querySelector(".lyr-cmt-now span") as HTMLElement | null
        let state = ""
        const apply = (p: number) => {
            const base = clamp(Math.floor(p + 0.18), 0, last)
            const striking = base < last && p - base > 0.3
            const next = `${base}${striking ? "s" : ""}`
            if (next === state) return
            state = next
            layers.forEach((l, n) => {
                l.classList.toggle("lyr-cmt-cur", n === base)
                l.classList.toggle("lyr-cmt-strike", n === base && striking)
            })
            items.forEach((it, n) => {
                it.classList.toggle("lyr-cmt-cur", n === base)
                it.classList.toggle("lyr-cmt-done", n < base)
            })
            el.classList.toggle("lyr-cmt-shipped", base === last)
            if (nowMessage) nowMessage.textContent = messages[base] || ""
            if (nowCount) nowCount.textContent = `${base + 1} of ${last + 1}`
        }
        const onScroll = () => {
            const r = el.getBoundingClientRect()
            const range = r.height - pin.offsetHeight
            apply(r.width === 0 || range <= 0 ? 0 : clamp(-r.top / range, 0, 1) * last)
        }
        onScroll()
        window.addEventListener("scroll", onScroll, { passive: true })
        window.addEventListener("resize", onScroll)
        return () => {
            window.removeEventListener("scroll", onScroll)
            window.removeEventListener("resize", onScroll)
        }
    }, [isStatic, last, messagesKey])

    const jump = (n: number) => {
        const el = root.current
        const pin = el && (el.querySelector(".lyr-cmt-pin") as HTMLElement | null)
        if (!el || !pin || last < 1) return
        const top = el.getBoundingClientRect().top + window.scrollY
        const range = el.offsetHeight - pin.offsetHeight
        window.scrollTo({ top: top + (range * n) / last + 2, behavior: typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth" })
    }

    const vars = {
        "--n": last,
        "--step": clamp(scrollLength || 60, 20, 300),
        "--top": `${topSpace}px`,
        "--side": `${sideSpace}px`,
        "--ink": ink,
        "--mute": mute,
        "--quiet": quietTone,
        "--accent": accent,
        "--hair": hairline,
        fontFamily: `"${fontFamily}", "Inter", system-ui, sans-serif`,
    } as CSSProperties
    const shown = isStatic ? last : 0

    const cover = (
        <>
            <span className="lyr-cmt-wait" aria-hidden="true">
                {waitLabel}
            </span>
            {image && image.src ? (
                <img srcSet={image.srcSet} sizes={image.srcSet ? "(max-width: 809px) 92vw, 70vw" : undefined} src={image.src} alt={altText || image.alt || ""} loading="eager" decoding="async" draggable={false} />
            ) : (
                <span className="lyr-cmt-ph" aria-hidden="true" style={{ background: EMPTY }} />
            )}
        </>
    )

    return (
        <div ref={root} className={`lyr-cmt${isStatic ? " lyr-cmt-static lyr-cmt-shipped" : ""}`} style={vars}>
            <style dangerouslySetInnerHTML={{ __html: CSS }} />
            <div className="lyr-cmt-pin">
                <div className="lyr-cmt-box">
                    <h1 className="lyr-cmt-sr">
                        {commits[last] ? commits[last].draft : ""} {resultLine}
                    </h1>
                    <div className="lyr-cmt-log">
                        <p className="lyr-cmt-label">{label}</p>
                        {commits.map((c, n) => (
                            <button
                                type="button"
                                key={n}
                                className={`lyr-cmt-item${n === shown ? " lyr-cmt-cur" : n < shown ? " lyr-cmt-done" : ""}`}
                                onClick={() => jump(n)}
                                aria-label={`Show edit ${n + 1} of ${N}: ${c.message}`}
                            >
                                <b>{c.message}</b>
                                <span>{c.when}</span>
                            </button>
                        ))}
                        <div className="lyr-cmt-now" aria-hidden="true">
                            <b>{commits[shown] ? commits[shown].message : ""}</b>
                            <span>
                                {shown + 1} of {N}
                            </span>
                        </div>
                    </div>
                    <div className="lyr-cmt-stage" aria-hidden="true">
                        {lists.map((w, n) => {
                            let k = 0
                            return (
                                <p key={n} className={`lyr-cmt-v${n === shown ? " lyr-cmt-cur" : ""}`}>
                                    {w.map((word, x) => {
                                        const cls = `lyr-cmt-w${marks[n].out[x] ? " lyr-cmt-out" : ""}${marks[n].added[x] ? " lyr-cmt-in" : ""}${x >= quietFrom[n] ? " lyr-cmt-q" : ""}`
                                        const delay = marks[n].added[x] ? k++ : 0
                                        return (
                                            <span key={x}>
                                                <span className={cls} style={{ "--k": delay } as CSSProperties}>
                                                    {word}
                                                </span>
                                                {x < w.length - 1 ? " " : ""}
                                            </span>
                                        )
                                    })}
                                </p>
                            )
                        })}
                    </div>
                    {link ? (
                        <a className="lyr-cmt-strip" href={link} aria-label={linkLabel}>
                            {cover}
                        </a>
                    ) : (
                        <div className="lyr-cmt-strip">{cover}</div>
                    )}
                </div>
            </div>
        </div>
    )
}

addPropertyControls(Commits, {
    label: { type: ControlType.String, title: "List label", defaultValue: "History of this headline" },
    edits: {
        type: ControlType.Array,
        title: "Edits",
        maxCount: 8,
        description: "One per scroll step, oldest first. The component works out which words each edit removed and added.",
        control: {
            type: ControlType.Object,
            controls: {
                message: { type: ControlType.String, title: "Message", defaultValue: "Edit", description: "What this edit did, as you would write a commit message." },
                sentence: { type: ControlType.String, title: "Sentence", defaultValue: "", displayTextArea: true, description: "The headline after this edit. Leave it empty to drop the edit." },
                day: { type: ControlType.String, title: "Day", defaultValue: "Today" },
            },
        },
        defaultValue: [
            { message: "First draft", sentence: "I am a passionate, results-driven full-stack developer who loves crafting scalable solutions.", day: "Monday" },
            { message: "Remove the adjectives", sentence: "I am a full-stack developer who loves crafting solutions.", day: "Tuesday" },
            { message: "Say what I build", sentence: "I am a software engineer who builds fast web apps.", day: "Wednesday" },
            { message: "Shorter", sentence: "I build fast web apps.", day: "Thursday" },
            { message: "Add the number", sentence: "I build fast web apps.", day: "Today" },
        ],
    },
    resultLine: { type: ControlType.String, title: "Result line", defaultValue: "Pages load in 96 ms, was 840.", description: "Added in the quiet tone by the last edit. Bind it to a CMS field if you like." },
    image: { type: ControlType.ResponsiveImage, title: "Image", description: "A cover picture. It appears with the last edit." },
    altText: { type: ControlType.String, title: "Alt text", defaultValue: "", description: "Only needed when the image is set by hand." },
    link: { type: ControlType.Link, title: "Link" },
    linkLabel: { type: ControlType.String, title: "Link label", defaultValue: "Read the case study" },
    waitLabel: { type: ControlType.String, title: "Waiting label", defaultValue: "Ships with the last edit", description: "Shown in the empty cover strip until the last edit." },
    scrollLength: { type: ControlType.Number, title: "Scroll per edit", defaultValue: 60, min: 20, max: 300, step: 5, unit: "%", description: "How much scrolling applies the next edit, as a share of the window height." },
    fontFamily: { type: ControlType.String, title: "Font", defaultValue: "Inter", description: "The family name used by the site's text styles." },
    ink: { type: ControlType.Color, title: "Ink", defaultValue: "#F0ECE4" },
    mute: { type: ControlType.Color, title: "Muted", defaultValue: "#918D84" },
    quietTone: { type: ControlType.Color, title: "Quiet tone", defaultValue: "#625F58", description: "Struck words and the result line." },
    accent: { type: ControlType.Color, title: "Accent", defaultValue: "#FFB02E" },
    hairline: { type: ControlType.Color, title: "Hairline", defaultValue: "rgba(240,236,228,0.14)" },
    topSpace: { type: ControlType.Number, title: "Top space", defaultValue: 76, min: 0, max: 200, step: 1, unit: "px", description: "Height of the navigation above." },
    sideSpace: { type: ControlType.Number, title: "Side space", defaultValue: 48, min: 0, max: 120, step: 1, unit: "px" },
})

In this preview

The photos are demo pictures shown on this site only; they are not in the files you copy. Set your own through the image props.