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
#0E0E0D
#6E6E6B
#A3A3A0
#FF4A1C
#E6E5E1
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.