Components / Scroll scene
Hyphen Chain
A pinned chain of steps, Brief - Sketch - Prototype - Product: each hyphen opens onto a picture, closes, and the next word slides in.
Scroll insideFull screen
Props
#0E0E0D
#6E6E6B
Set in code: Steps, Font file. The preview fills these in with demo content.
Scroll inside the preview.
In Framer, open Assets, add a new code file, paste this over everything in it and save. Hyphen Chain shows up in the Insert menu, and its controls appear in the right panel.
// Hyphen Chain · Layr Components
//
// A chain of steps written as "From - To": Brief - Sketch - Prototype - Product.
// The screen holds while it is scrolled: each hyphen opens onto its picture and
// closes again, then the right word slides left to start the next step.
// Paste into a new code file in Framer. Full width, auto height: it is taller than
// the screen and pins itself, so keep its parents' overflow visible. Without a
// picture a step shows a plain tone. The width axis needs a variable font: without
// Font family or Font file it loads Mona Sans from Google Fonts.
import { addPropertyControls, ControlType, useIsStaticRenderer } from "framer"
import { useEffect, useMemo, useRef, useState, type CSSProperties } from "react"
// The words narrow on the font's width axis ("wdth"). Without a font of your own, Mona Sans loads from
// Google Fonts: free (SIL Open Font License), with a width axis from 75 to 125.
const GOOGLE_FONT = "https://fonts.googleapis.com/css2?family=Mona+Sans:wdth,wght@75..125,200..900&display=swap"
const hashOf = (s: string) => {
let h = 5381
for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0
return (h >>> 0).toString(36)
}
function widthFont(fontFamily: string, fontFile?: string) {
const family = String(fontFamily || "").trim()
if (family) return { name: family, css: "" }
if (fontFile) {
const name = `Lyr Width ${hashOf(fontFile)}`
return { name, css: `@font-face{font-family:"${name}";src:url("${fontFile}") format("woff2");font-weight:100 900;font-stretch:25% 200%;font-display:swap}` }
}
return { name: "Mona Sans", css: `@import url("${GOOGLE_FONT}");` }
}
const TRACK = -0.03,
WIDE = 125,
NARROW = 62
const clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v))
const ease = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2)
const lerp = (a: number, b: number, t: number) => a + (b - a) * t
// Shown when a step has no picture yet: a quiet tone, so the hyphen still opens onto something.
const EMPTY = "linear-gradient(160deg, #cfcac0, #8f897e)"
type Pic = { src: string; srcSet?: string; alt?: string }
type Step = { from: string; to: string; image?: Pic; note?: string }
interface HyphenChainProps {
steps: Step[]
fontFile?: string
fontFamily: string
ink: string
mute: string
topGap: number
edge: number
corner: number
step: number
imageSizes: string
style?: CSSProperties
}
const STEPS: Step[] = [
{ from: "Brief", to: "Sketch", note: "A page of goals and limits, agreed before anything is drawn." },
{ from: "Sketch", to: "Prototype", note: "Rough screens on paper, then in code, tried on real devices." },
{ from: "Prototype", to: "Product", note: "The tested version, built to last and handed over with notes." },
]
const CSS = `
.lyr-hch{position:relative;width:100%;color:var(--hch-ink);font-family:var(--hch-font);font-weight:400;-webkit-font-smoothing:antialiased;font-synthesis:none}
.lyr-hch *{box-sizing:border-box}
.lyr-hch-tall{position:relative;width:100%;height:calc(100vh + var(--hch-travel) * 1vh);height:calc(100svh + var(--hch-travel) * 1svh)}
.lyr-hch-pin{position:sticky;top:0;width:100%;height:100vh;height:100svh;overflow:hidden;overflow:clip}
.lyr-hch-w{position:absolute;left:0;top:0;margin:0;white-space:nowrap;font-size:var(--hch-fs,10vw);line-height:1;letter-spacing:${TRACK}em;font-variation-settings:"wdth" var(--w,${WIDE});will-change:transform;visibility:hidden}
.lyr-hch-bar{position:absolute;left:0;top:0;background:var(--hch-ink);border-radius:2px;will-change:transform;visibility:hidden}
.lyr-hch-win{position:absolute;left:0;top:0;overflow:hidden;border-radius:3px;will-change:transform;visibility:hidden}
.lyr-hch-win>img,.lyr-hch-win>.lyr-hch-ph{position:absolute;left:50%;top:50%;display:block;object-fit:cover;max-width:none;will-change:transform}
.lyr-hch-ph{background:${EMPTY}}
.lyr-hch-note{position:absolute;left:0;top:0;margin:0;max-width:min(30em,80vw);font-size:15px;line-height:1.45;letter-spacing:-.01em;color:var(--hch-mute);opacity:0;transition:opacity .45s ease;will-change:transform}
.lyr-hch-note[data-on="1"]{opacity:1}
.lyr-hch-sr{position:absolute;width:1px;height:1px;margin:-1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
.lyr-hch-probe{position:absolute;left:0;top:0;width:0;height:0;overflow:hidden;visibility:hidden;pointer-events:none;white-space:nowrap;font-size:100px;line-height:1;letter-spacing:${TRACK}em}
.lyr-hch-probe span{display:inline-block}
.lyr-hch-rest{container-type:inline-size;padding:0 var(--hch-edge) 28px}
.lyr-hch-rest:not(.lyr-hch-rest + .lyr-hch-rest){padding-top:48px}
.lyr-hch-rest-row{display:flex;align-items:center;gap:clamp(12px,2.4cqw,36px);font-size:clamp(34px,9.4cqw,150px);line-height:1;letter-spacing:${TRACK}em;white-space:nowrap;font-variation-settings:"wdth" ${NARROW}}
.lyr-hch-rest-pic{position:relative;flex:1 1 0;min-width:0;aspect-ratio:1.6;max-height:420px;border-radius:var(--hch-r);overflow:hidden;background:${EMPTY}}
.lyr-hch-rest-pic img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block}
.lyr-hch-rest-note{margin:12px 0 0;font-size:15px;line-height:1.45;letter-spacing:-.01em;color:var(--hch-mute);max-width:30em}
@container (max-width:719px){.lyr-hch-rest-row{flex-direction:column;align-items:flex-start;gap:.14em;font-size:clamp(34px,17cqw,120px)}.lyr-hch-rest-pic{flex:none;width:100%}}
`
/**
* @framerIntrinsicWidth 1200
* @framerIntrinsicHeight 800
*
* @framerSupportedLayoutWidth fixed
* @framerSupportedLayoutHeight auto
* @framerDisableUnlink
*/
export default function HyphenChain(props: HyphenChainProps) {
const { steps = STEPS, fontFile, fontFamily = "", ink = "#121211", mute = "#6E6D68", topGap = 72, edge = 32, corner = 10, step = 100, imageSizes = "60vw" } = props
const isStatic = useIsStaticRenderer()
const [reduced, setReduced] = useState(false)
const items = useMemo(() => (Array.isArray(steps) ? steps : []).filter((s) => s && String(s.from || "").trim() && String(s.to || "").trim()), [steps])
const root = useRef<HTMLDivElement>(null)
const tall = useRef<HTMLDivElement>(null)
const pin = useRef<HTMLDivElement>(null)
const probe = useRef<HTMLDivElement>(null)
const geo = useRef<any>(null)
const paintRef = useRef<(p: number) => void>(() => {})
const measureRef = useRef<() => void>(() => {})
const still = isStatic || reduced
const font = widthFont(fontFamily, fontFile)
const stack = `"${font.name}", "Helvetica Neue", Arial, sans-serif`
useEffect(() => {
if (typeof window === "undefined") return
const mq = window.matchMedia("(prefers-reduced-motion: reduce)")
const on = () => setReduced(mq.matches)
on()
mq.addEventListener?.("change", on)
return () => mq.removeEventListener?.("change", on)
}, [])
const n = items.length
const words = n ? [...items.map((it) => it.from), items[n - 1].to] : []
// p runs 0 to 1 over the pinned scroll
paintRef.current = (p: number) => {
const g = geo.current,
pn = pin.current
if (!g || !pn) return
const F = p * (g.n - 0.48)
const i = Math.min(g.n - 1, Math.floor(F)),
t = F - i
const A = ease(clamp(t / 0.34, 0, 1)) - (i < g.n - 1 ? ease(clamp((t - 0.58) / 0.18, 0, 1)) : 0)
const s = i < g.n - 1 ? ease(clamp((t - 0.76) / 0.24, 0, 1)) : 0
const wd = lerp(WIDE, NARROW, A)
const wEls = pn.querySelectorAll<HTMLElement>(".lyr-hch-w"),
bEls = pn.querySelectorAll<HTMLElement>(".lyr-hch-bar"),
winEls = pn.querySelectorAll<HTMLElement>(".lyr-hch-win"),
nEls = pn.querySelectorAll<HTMLElement>(".lyr-hch-note")
const width = (k: number, active: boolean) => g.fs * (active ? lerp(g.wide[k], g.thin[k], A) : g.wide[k])
const xs: number[] = [],
ys: number[] = [],
ws: number[] = [],
on: boolean[] = []
const M = g.edge,
W = g.W,
far = 80
for (let k = 0; k <= g.n; k++) {
const active = (k === i || k === i + 1) && s === 0
const w = width(k, active)
ws[k] = w
let x = 0,
y = g.y1,
vis = true
if (g.stacked) {
const push = A * (g.bh + g.noteH + g.gap * 0.8)
if (k < i) {
vis = false
} else if (k === i) {
x = M - s * (w + M + far)
y = g.y1
} else if (k === i + 1) {
x = M
y = lerp(g.y2 + push, g.y1, s)
} else if (k === i + 2) {
x = lerp(W + far, M, s)
y = g.y2
} else vis = false
} else {
if (k < i) vis = false
else if (k === i) x = M - s * (w + M + far)
else if (k === i + 1) x = lerp(W - M - w, M, s)
else if (k === i + 2) x = lerp(W + far, W - M - w, s)
else vis = false
if (k === i + 2 && s === 0) vis = false
}
xs[k] = x
ys[k] = y
on[k] = vis
const el = wEls[k]
if (!el) continue
el.style.visibility = vis ? "visible" : "hidden"
if (vis) {
el.style.setProperty("--w", (active ? wd : WIDE).toFixed(1))
el.style.transform = `translate3d(${x.toFixed(2)}px,${y.toFixed(2)}px,0)`
}
}
// one hyphen between each pair of neighbouring words that are on screen
for (let j = 0; j < g.n; j++) {
const bar = bEls[j],
win = winEls[j],
nt = nEls[j]
let show = on[j] && on[j + 1],
x0 = 0,
x1 = 0,
cy = 0
if (g.stacked) {
// narrow screens: the hyphen ends the first line; it leaves with the old word and grows out of the new one
if (j === i) {
show = on[j]
x0 = xs[j] + ws[j] + g.gap
x1 = lerp(W - M, x0, s)
cy = ys[j] + g.fs * 0.57
} else if (j === i + 1 && s > 0) {
show = true
x0 = xs[j] + ws[j] + g.gap
x1 = lerp(x0, W - M, s)
cy = ys[j] + g.fs * 0.57
} else show = false
} else if (show) {
x0 = xs[j] + ws[j] + g.gap
x1 = xs[j + 1] - g.gap
cy = g.y1 + g.fs * 0.57
}
if (show && x1 - x0 < 2) show = false
const a = j === i ? A : 0
let rx0 = x0,
rx1 = x1,
ry0 = cy - g.h0 / 2,
ry1 = cy + g.h0 / 2
if (show && a > 0) {
if (g.stacked) {
const by0 = g.y1 + g.fs + g.gap * 0.6
rx0 = lerp(x0, M, a)
rx1 = lerp(x1, W - M, a)
ry0 = lerp(ry0, by0, a)
ry1 = ry0 + lerp(g.h0, g.bh, a)
} else {
const h = lerp(g.h0, Math.min((x1 - x0) / 1.6, g.hmax), a)
ry0 = cy - h / 2
ry1 = cy + h / 2
}
}
if (bar) {
bar.style.visibility = show && !g.loaded[j] ? "visible" : "hidden"
if (show) {
bar.style.transform = `translate3d(${x0.toFixed(2)}px,${(cy - g.h0 / 2).toFixed(2)}px,0)`
bar.style.width = (x1 - x0).toFixed(2) + "px"
bar.style.height = g.h0.toFixed(2) + "px"
}
}
if (win) {
win.style.visibility = show ? "visible" : "hidden"
if (show) {
const w = rx1 - rx0,
h = ry1 - ry0
win.style.transform = `translate3d(${rx0.toFixed(2)}px,${ry0.toFixed(2)}px,0)`
win.style.width = w.toFixed(2) + "px"
win.style.height = h.toFixed(2) + "px"
win.style.borderRadius = lerp(Math.max(2, g.fs * 0.03), g.corner, clamp(a * 2, 0, 1)).toFixed(2) + "px"
const im = win.firstElementChild as HTMLElement | null
if (im) {
// closed, the hyphen shows the lower edge of the picture (clear of anything in its middle); opening, the picture slides home
const iw = Math.max(g.ow[j], w / 1.04),
ih = g.oh[j] * (iw / g.ow[j])
im.style.width = iw.toFixed(1) + "px"
im.style.height = ih.toFixed(1) + "px"
const my = Math.max(0, (ih * 1.08 - h) / 2)
im.style.transform = `translate(-50%,-50%) translate3d(0,${clamp(-ih * 0.37 * (1 - a), -my, my).toFixed(2)}px,0) scale(${(1.08 - 0.08 * a).toFixed(4)})`
}
}
}
if (nt) {
const open = j === i && A > 0.9
if (nt.dataset.on !== (open ? "1" : "0")) nt.dataset.on = open ? "1" : "0"
if (j === i) nt.style.transform = `translate3d(${rx0.toFixed(1)}px,${(ry1 + 12).toFixed(1)}px,0)`
}
}
}
measureRef.current = () => {
const r = root.current,
pn = pin.current,
pb = probe.current
if (!r || !pn || !pb) return
const W = pn.clientWidth,
H = pn.clientHeight
if (!W || !H) return
const kids = pb.children,
count = words.length
if (kids.length < count * 2) return
const wide: number[] = [],
thin: number[] = []
for (let k = 0; k < count; k++) {
wide[k] = kids[k * 2].getBoundingClientRect().width / 100
thin[k] = kids[k * 2 + 1].getBoundingClientRect().width / 100
}
if (thin[0] > wide[0] * 0.96) for (let k = 0; k < count; k++) thin[k] = wide[k] * 0.62
const stacked = W < 720 || (W < 1024 && W / Math.max(1, window.innerHeight) < 5 / 6)
const gap = stacked ? Math.max(8, W * 0.03) : clamp(W * 0.024, 14, 40)
const hw = clamp(W * 0.122, 64, 190)
let pair = 0,
longest = 0
for (let k = 0; k < count - 1; k++) pair = Math.max(pair, wide[k] + wide[k + 1])
for (let k = 0; k < count; k++) longest = Math.max(longest, wide[k])
const notes = Array.from(pn.querySelectorAll<HTMLElement>(".lyr-hch-note"))
const noteH = notes.length ? Math.max(...notes.map((e) => e.offsetHeight)) + 16 : 0
let fs: number,
y1: number,
y2 = 0,
bh = 0,
hmax = 0
if (stacked) {
fs = Math.min((W - 2 * edge) / (longest + 1.1), (H - topGap) * 0.16, 200)
bh = Math.max(60, Math.min((W - 2 * edge) / 1.5, H - topGap - 24 - fs * 2.3 - noteH - 40))
y1 = topGap + Math.max(12, (H - topGap - (fs * 2.2 + bh + noteH + gap)) * 0.4)
y2 = y1 + fs * 1.06
} else {
fs = Math.min((W - 2 * edge - hw - 2 * gap) / pair, (H - topGap) * 0.26, 260)
hmax = (H - topGap) * 0.62 - noteH
y1 = topGap + (H - topGap) * 0.47 - fs * 0.5
}
const ow: number[] = [],
oh: number[] = [],
loaded: boolean[] = []
const wins = pn.querySelectorAll<HTMLElement>(".lyr-hch-win")
for (let j = 0; j < count - 1; j++) {
if (stacked) {
ow[j] = W - 2 * edge
oh[j] = bh
} else {
const sw = W - 2 * edge - 2 * gap - fs * (thin[j] + thin[j + 1])
oh[j] = Math.min(sw / 1.6, hmax)
ow[j] = sw
}
const im = wins[j] && (wins[j].firstElementChild as HTMLElement | null)
if (im) {
im.style.width = ow[j].toFixed(1) + "px"
im.style.height = oh[j].toFixed(1) + "px"
loaded[j] = im instanceof HTMLImageElement ? !!(im.complete && im.naturalWidth) : true
} else loaded[j] = false
}
r.style.setProperty("--hch-fs", fs.toFixed(2) + "px")
geo.current = { W, H, n: count - 1, wide, thin, stacked, gap, fs, edge, y1, y2, bh, hmax, noteH, h0: fs * 0.36, corner, ow, oh, loaded }
}
useEffect(() => {
if (still || !n) return
const tl = tall.current,
pn = pin.current
if (!tl || !pn) return
let raf = 0,
shown = -1,
goal = 0,
last = 0,
live = true
const read = () => {
const tr = tl.getBoundingClientRect()
const span = tr.height - pn.offsetHeight
goal = span > 0 ? clamp(-tr.top / span, 0, 1) : 0
}
const tick = (t: number) => {
raf = 0
const dt = last ? Math.min(64, t - last) : 16
last = t
shown = shown < 0 ? goal : shown + (goal - shown) * (1 - Math.exp(-dt / 100))
if (Math.abs(goal - shown) < 0.0004) shown = goal
paintRef.current(shown)
if (shown !== goal && live) raf = requestAnimationFrame(tick)
else last = 0
}
const kick = () => {
if (!pn.offsetParent && !pn.offsetHeight) return
read()
if (!raf) raf = requestAnimationFrame(tick)
}
const full = () => {
measureRef.current()
shown = -1
kick()
}
full()
const ro = new ResizeObserver(full)
ro.observe(pn)
window.addEventListener("scroll", kick, { passive: true })
const imgs = Array.from(pn.querySelectorAll("img"))
imgs.forEach((im) => im.addEventListener("load", full))
const fonts = (document as any).fonts
if (fonts) {
fonts.load(`400 100px "${font.name}"`).then(full).catch(() => {})
fonts.ready.then(full)
fonts.addEventListener?.("loadingdone", full)
}
return () => {
live = false
ro.disconnect()
window.removeEventListener("scroll", kick)
imgs.forEach((im) => im.removeEventListener("load", full))
fonts?.removeEventListener?.("loadingdone", full)
if (raf) cancelAnimationFrame(raf)
}
}, [still, n, items, topGap, edge, corner, step, font.name, font.css])
const vars = { "--hch-ink": ink, "--hch-mute": mute, "--hch-font": stack, "--hch-edge": edge + "px", "--hch-r": corner + "px", "--hch-travel": String(Math.round(Math.max(1, n) * step)) } as CSSProperties
const sheet = <style dangerouslySetInnerHTML={{ __html: font.css + CSS }} />
if (!n) return <div style={{ ...props.style, minHeight: 1 }} />
if (still) {
// at rest (canvas, reduced motion): each step is its two words with the open picture between them
return (
<div style={props.style}>
{sheet}
{items.map((it, k) => (
<div key={k} className="lyr-hch lyr-hch-rest" style={vars}>
<h3 className="lyr-hch-sr">{`${it.from} to ${it.to}`}</h3>
<div className="lyr-hch-rest-row" aria-hidden="true">
<span>{it.from}</span>
<span className="lyr-hch-rest-pic">{it.image?.src ? <img loading="lazy" decoding="async" srcSet={it.image.srcSet} sizes={imageSizes} src={it.image.src} alt={it.image.alt || ""} /> : null}</span>
<span>{it.to}</span>
</div>
{it.note && it.note.trim() ? <p className="lyr-hch-rest-note">{it.note}</p> : null}
</div>
))}
</div>
)
}
return (
<div ref={root} className="lyr-hch" style={{ ...props.style, ...vars }}>
{sheet}
<ol className="lyr-hch-sr">
{items.map((it, k) => (
<li key={k}>{`${it.from} to ${it.to}${it.note ? ": " + it.note : ""}`}</li>
))}
</ol>
<div ref={tall} className="lyr-hch-tall">
<div ref={pin} className="lyr-hch-pin" aria-hidden="true">
{items.map((_, j) => (
<span key={"b" + j} className="lyr-hch-bar" />
))}
{items.map((it, j) => (
<span key={"p" + j} className="lyr-hch-win">
{it.image?.src ? <img loading="lazy" decoding="async" srcSet={it.image.srcSet} sizes={imageSizes} src={it.image.src} alt="" /> : <span className="lyr-hch-ph" />}
</span>
))}
{words.map((w, k) => (
<span key={"w" + k} className="lyr-hch-w">
{w}
</span>
))}
{items.map((it, j) => (
<p key={"n" + j} className="lyr-hch-note">
{it.note}
</p>
))}
<div ref={probe} className="lyr-hch-probe">
{words.map((w, k) =>
[WIDE, NARROW].map((v) => (
<span key={k + "-" + v} style={{ fontVariationSettings: `"wdth" ${v}` }}>
{w}
</span>
))
)}
</div>
</div>
</div>
</div>
)
}
addPropertyControls(HyphenChain, {
steps: {
type: ControlType.Array,
title: "Steps",
maxCount: 8,
defaultValue: STEPS,
control: {
type: ControlType.Object,
controls: {
from: { type: ControlType.String, title: "From", description: "The word before the hyphen." },
to: { type: ControlType.String, title: "To", description: "The word after the hyphen. The next step should start with the same word." },
image: { type: ControlType.ResponsiveImage, title: "Picture", description: "Shown through the hyphen." },
note: { type: ControlType.String, title: "Note", displayTextArea: true, description: "A short line under the open picture." },
},
},
description: "Each step is From - To. The chain reads its words in order: the To of one step is the From of the next.",
},
fontFile: { type: ControlType.File, title: "Font file", allowedFileTypes: ["woff2"], description: "A variable font with a width axis. Leave empty for Mona Sans from Google Fonts." },
fontFamily: { type: ControlType.String, title: "Font family", defaultValue: "", placeholder: "Mona Sans", description: "A family with a width axis that the site already loads. Leave empty to use the font file, or Mona Sans." },
ink: { type: ControlType.Color, title: "Ink colour", defaultValue: "#121211" },
mute: { type: ControlType.Color, title: "Note colour", defaultValue: "#6E6D68" },
topGap: { type: ControlType.Number, title: "Top gap", defaultValue: 72, min: 0, max: 160, step: 1, unit: "px", description: "Room kept for the navigation." },
edge: { type: ControlType.Number, title: "Side gap", defaultValue: 32, min: 0, max: 80, step: 1, unit: "px" },
corner: { type: ControlType.Number, title: "Corner radius", defaultValue: 10, min: 0, max: 40, step: 1, unit: "px" },
step: { type: ControlType.Number, title: "Scroll per step", defaultValue: 100, min: 40, max: 200, step: 5, unit: "%", description: "Share of a screen scrolled for each step." },
imageSizes: { type: ControlType.String, title: "Image width", defaultValue: "60vw", description: "One CSS length, for example 60vw." },
})
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.