Layr Components

Components / Cards and lists

Hyphen Index

A pinned list of "Title - kind" rows; as you scroll, each row's hyphen opens in turn onto its picture or a text panel.

Scroll insideFull screen
Props
#0E0E0D
#6E6E6B
#E6E5E1
72 px
32 px
10 px
60%
22%
40%
#F5F4F1

Set in code: Items, All link, 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 Index shows up in the Insert menu, and its controls appear in the right panel.
// Hyphen Index · Layr Components
//
// A list of projects written as "Title - kind". The screen holds while the list is
// scrolled: each row's hyphen opens in turn onto its picture (or, with no picture, a
// text panel such as a price) and the words narrow to make room.
// 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. Fill the Items list
// by hand (or from a CMS collection through its fields). Without a picture or panel
// text a row opens onto a plain tone. The words narrow on a variable font's width axis:
// without Font family or Font file it loads Mona Sans from Google Fonts.

import * as React from "react"
import { addPropertyControls, ControlType, Link, useIsStaticRenderer } from "framer"

// 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 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 row has no picture and no panel text: a quiet tone, so the hyphen still opens onto something.
const EMPTY = "linear-gradient(160deg, #c9c4ba, #8f897e)"

const CSS = `
.lyr-hx{--hx-hc:96px;--hx-ex:300px;--hx-fs:clamp(30px,4.4cqw,64px);position:relative;width:100%;container-type:inline-size;pointer-events:none;color:var(--hx-ink);font-family:var(--hx-font);font-weight:400;-webkit-font-smoothing:antialiased;font-synthesis:none}
.lyr-hx *{box-sizing:border-box}
.lyr-hx-tall{position:relative;width:100%;height:calc(100vh + var(--hx-travel) * 1vh);height:calc(100svh + var(--hx-travel) * 1svh)}
.lyr-hx-pin{position:sticky;top:0;width:100%;height:100vh;height:100svh;overflow:hidden;overflow:clip}
.lyr-hx-head{position:absolute;left:var(--hx-edge);right:var(--hx-edge);top:var(--hx-top);height:var(--hx-headh);display:flex;align-items:center;justify-content:space-between;gap:24px;pointer-events:auto}
.lyr-hx-h{margin:0;font-size:clamp(22px,2.4cqw,32px);line-height:1.1;letter-spacing:-.03em;font-weight:400}
.lyr-hx-h span{color:var(--hx-mute)}
.lyr-hx-all{color:inherit;text-decoration:none;font-size:15px;letter-spacing:-.01em;line-height:1.3;padding-bottom:4px;border-bottom:1px solid currentColor;transition:opacity .2s ease;white-space:nowrap}
.lyr-hx-all:hover{opacity:.66}
.lyr-hx-row{position:absolute;left:var(--hx-edge);right:var(--hx-edge);top:calc(var(--hx-top) + var(--hx-headh) + var(--hx-i) * var(--hx-hc-set,var(--hx-hc)) + var(--hx-after) * var(--hx-ex-set,var(--hx-ex)));height:var(--hx-hc-set,var(--hx-hc));display:flex;align-items:center;gap:var(--hx-g,24px);border-top:1px solid var(--hx-line);color:inherit;text-decoration:none;pointer-events:auto;font-size:var(--hx-fs-set,var(--hx-fs));line-height:1.1;letter-spacing:-.035em;white-space:nowrap;overflow:hidden}
.lyr-hx-row.lyr-hx-last{border-bottom:1px solid var(--hx-line)}
.lyr-hx-t,.lyr-hx-k{flex:none;font-variation-settings:"wdth" var(--hx-wd,100);transition:opacity .2s ease}
.lyr-hx-k{color:var(--hx-mute);flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;padding-bottom:.08em}
.lyr-hx-row:hover .lyr-hx-t,.lyr-hx-row:hover .lyr-hx-k{opacity:.62}
.lyr-hx-row:focus-visible{outline:2px solid var(--hx-ink);outline-offset:-2px}
.lyr-hx-slot{flex:none;position:relative;width:1.7em;height:.36em;border-radius:.05em;overflow:hidden;background:var(--hx-ink);transform:translateY(.05em)}
.lyr-hx-media{position:absolute;left:50%;top:50%;display:block;object-fit:cover;max-width:none;will-change:transform}
.lyr-hx-ph{background:${EMPTY}}
.lyr-hx-panel{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:var(--hx-pw,480px);height:var(--hx-ph,260px);display:flex;flex-direction:column;justify-content:space-between;gap:16px;padding:clamp(18px,2cqw,28px);background:var(--hx-card);color:var(--hx-ink);border:1px solid var(--hx-line);border-radius:var(--hx-r);white-space:normal;letter-spacing:-.015em;opacity:var(--hx-po,0)}
.lyr-hx-panel>*{opacity:0;transform:translateY(8px);transition:opacity .4s ease,transform .5s cubic-bezier(.2,.7,.2,1)}
[data-lyr-hx-open="1"] .lyr-hx-panel>*{opacity:1;transform:none}
.lyr-hx-pt{margin:0;font-size:clamp(17px,1.7cqw,24px);line-height:1.32;letter-spacing:-.02em;text-wrap:pretty}
.lyr-hx-pb{display:flex;justify-content:space-between;align-items:baseline;gap:16px;font-size:15px;line-height:1.3;color:var(--hx-mute)}
.lyr-hx-pb b{font-weight:400;color:var(--hx-ink);font-size:clamp(20px,2cqw,28px);letter-spacing:-.03em}
.lyr-hx-pic .lyr-hx-panel{left:auto;right:0;top:0;transform:none}
.lyr-hx-y{margin-left:auto;flex:none;font-size:15px;letter-spacing:-.01em;line-height:1;font-variant-numeric:tabular-nums}
.lyr-hx-l2{display:none}
.lyr-hx-pic{display:none}
.lyr-hx-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:-.035em}
.lyr-hx-probe span{display:inline-block}
@container (max-width:899px){
.lyr-hx-pin{--hx-fs:clamp(26px,7.6cqw,46px);--hx-hc:88px;--hx-ex:220px}
.lyr-hx-row{display:block;white-space:normal}
.lyr-hx-l1{position:absolute;left:0;right:0;top:14px;display:flex!important;align-items:center;gap:.28em;white-space:nowrap}
.lyr-hx-l1 .lyr-hx-slot{flex:1 1 0;width:auto;min-width:.8em}
.lyr-hx-row>.lyr-hx-k,.lyr-hx-row>.lyr-hx-y{display:none}
.lyr-hx-l2{position:absolute;left:0;right:0;bottom:12px;display:flex;justify-content:space-between;align-items:baseline;gap:16px;font-size:15px;letter-spacing:-.01em;line-height:1.3;will-change:transform}
.lyr-hx-l2 .lyr-hx-k{font-variation-settings:normal}
.lyr-hx-pic{display:block;position:absolute;left:0;right:0;top:0;overflow:hidden;border-radius:var(--hx-r);opacity:0}
.lyr-hx-l1 .lyr-hx-slot .lyr-hx-panel{display:none}
.lyr-hx-plain .lyr-hx-l1 .lyr-hx-slot{height:.13em;transform:translateY(.09em)}
}
.lyr-hx-still{pointer-events:auto}
.lyr-hx-still .lyr-hx-tall{position:relative;height:auto!important;bottom:auto}
.lyr-hx-still .lyr-hx-pin{position:relative;height:auto;overflow:visible}
.lyr-hx-still .lyr-hx-head{position:relative;left:auto;right:auto;top:auto;margin:var(--hx-top) var(--hx-edge) 0}
.lyr-hx-still .lyr-hx-row{position:relative;left:auto;right:auto;top:auto;margin:0 var(--hx-edge);height:auto;min-height:var(--hx-hc)}
.lyr-hx-still .lyr-hx-panel{position:relative;left:auto;top:auto;transform:none;width:100%;height:auto;min-height:200px;opacity:1}
.lyr-hx-still .lyr-hx-panel>*{opacity:1;transform:none}
.lyr-hx-still .lyr-hx-slot .lyr-hx-media{position:absolute;inset:0;left:0;top:0;width:100%;height:100%;transform:none}
`

type Pic = { src: string; srcSet?: string; alt?: string }
type Item = { title: string; kind?: string; year?: string; image?: Pic; link?: string; panelText?: string; panelLeft?: string; panelRight?: string }
interface HyphenIndexProps {
    items: Item[]
    heading: string
    headingNote: string
    allLabel: string
    allLink?: string
    fontFile?: string
    fontFamily: string
    ink: string
    mute: string
    line: string
    topGap: number
    edge: number
    corner: number
    step: number
    stripX: number
    stripY: number
    imageSizes: string
    card: string
    style?: React.CSSProperties
}

const ITEMS: Item[] = [
    { title: "Harbour", kind: "booking flow", year: "2026" },
    { title: "Kettle", kind: "recipe planner", year: "2025" },
    { title: "Waypost", kind: "route maps", year: "2024" },
    { title: "Fieldbook", kind: "survey tool", year: "2023" },
]

type RowGeo = { pw: number; wd: number; shrink: number; sw: number; sh: number; sx: number; sy: number; l1b: number }

/**
 * @framerIntrinsicWidth 1200
 * @framerIntrinsicHeight 800
 *
 * @framerSupportedLayoutWidth fixed
 * @framerSupportedLayoutHeight auto
 * @framerDisableUnlink
 */
export default function HyphenIndex(props: HyphenIndexProps) {
    const {
        items = ITEMS,
        heading = "Selected work",
        headingNote = "{n} projects",
        allLabel = "All work",
        allLink,
        fontFile,
        fontFamily = "",
        ink = "#121211",
        mute = "#8B8A84",
        line = "rgba(18,18,17,0.16)",
        topGap = 72,
        edge = 32,
        corner = 10,
        step = 60,
        stripX = 22,
        stripY = 63,
        imageSizes = "50vw",
        card = "#FFFFFF",
    } = props
    // with no Kind, a hyphenated title is split at its first hyphen: "Design-to-code" becomes "Design" and "to code"
    const rows = React.useMemo(
        () =>
            (Array.isArray(items) ? items : [])
                .filter((it) => it && String(it.title || "").trim())
                .map((it) => {
                    const cut = !String(it.kind || "").trim() && String(it.title).includes("-") ? String(it.title).indexOf("-") : -1
                    const title = cut > 0 ? String(it.title).slice(0, cut).trim() : it.title
                    const kind = cut > 0 ? String(it.title).slice(cut + 1).replace(/-/g, " ").trim() : it.kind || ""
                    const panel = !it.image?.src && !!(String(it.panelText || "").trim() || String(it.panelLeft || "").trim())
                    return { ...it, title, kind, panel }
                }),
        [items]
    )
    const total = Math.max(1, rows.length)
    const isStatic = useIsStaticRenderer()
    const [reduced, setReduced] = React.useState(false)
    const still = isStatic || reduced
    const root = React.useRef<HTMLDivElement>(null)
    const tall = React.useRef<HTMLDivElement>(null)
    const pin = React.useRef<HTMLDivElement>(null)
    const probe = React.useRef<HTMLDivElement>(null)
    const geo = React.useRef<any>(null)
    const paintRef = React.useRef<(f: number) => void>(() => {})
    const measureRef = React.useRef<() => void>(() => {})
    const font = widthFont(fontFamily, fontFile)
    const stack = `"${font.name}", "Helvetica Neue", Arial, sans-serif`
    const headH = heading || allLabel ? 64 : 12
    const allPanel = rows.length > 0 && rows.every((r) => r.panel)
    const years = rows.map((r) => r.year || "").join("|")

    // f is the row that is open (0 = first); halfway values are two rows trading places
    paintRef.current = (f: number) => {
        const g = geo.current,
            pn = pin.current
        if (!g || !pn) return
        const els = Array.from(pn.querySelectorAll<HTMLElement>(".lyr-hx-row"))
        const w = (j: number) => ease(clamp(1 - Math.abs(j - f), 0, 1))
        const fx = 0.5 - g.fx,
            fy = 0.5 - g.fy
        let after = 0
        els.forEach((rw, i) => {
            const rg: RowGeo | undefined = g.rows[i]
            const s = rw.querySelector<HTMLElement>(".lyr-hx-slot")
            if (!rg || !s) return
            const e = w(i)
            rw.style.top = (g.top + g.headH + i * g.hc + after * g.ex).toFixed(2) + "px"
            rw.style.height = (g.hc + g.ex * e).toFixed(2) + "px"
            after += e
            rw.style.setProperty("--hx-po", clamp(e * 4, 0, 1).toFixed(3))
            const open = e > 0.86 ? "1" : "0"
            if (rw.dataset.lyrHxOpen !== open) rw.dataset.lyrHxOpen = open
            if (g.below) {
                // phones and tablets: the hyphen drops open into a picture under the title
                const p = rw.querySelector<HTMLElement>(".lyr-hx-pic"),
                    im = rw.querySelector<HTMLElement>(".lyr-hx-pic > .lyr-hx-media"),
                    sim = rw.querySelector<HTMLElement>(".lyr-hx-slot > .lyr-hx-media")
                if (sim) {
                    // the long hyphen shows a close crop of the picture, so it reads as colour, not as a slice of interface
                    const zw = rg.sw * 7,
                        zh = zw / 1.6
                    sim.style.width = zw.toFixed(1) + "px"
                    sim.style.height = zh.toFixed(1) + "px"
                    sim.style.transform = `translate(-50%,-50%) translate3d(${(fx * zw).toFixed(2)}px,${(fy * zh).toFixed(2)}px,0)`
                }
                if (p) {
                    const y0 = lerp(rg.sy, rg.l1b + 8, e),
                        h = lerp(rg.sh, g.ph, e),
                        x0 = lerp(rg.sx, 0, e)
                    p.style.top = y0.toFixed(2) + "px"
                    p.style.height = h.toFixed(2) + "px"
                    p.style.left = x0.toFixed(2) + "px"
                    p.style.opacity = e > 0.004 ? "1" : "0"
                    if (im) {
                        const pw = g.rowW - x0
                        const mx = Math.max(0, (g.rowW * 1.06 - pw) / 2),
                            my = Math.max(0, (g.ph * 1.06 - h) / 2)
                        im.style.transform = `translate(-50%,-50%) translate3d(${clamp(fx * g.rowW * (1 - e), -mx, mx).toFixed(2)}px,${clamp(fy * g.ph * (1 - e), -my, my).toFixed(2)}px,0) scale(1.06)`
                    }
                }
                return
            }
            const sw = lerp(rg.sw, rg.pw, e),
                sh = lerp(rg.sh, g.ph, e)
            s.style.width = sw.toFixed(2) + "px"
            s.style.height = sh.toFixed(2) + "px"
            s.style.borderRadius = lerp(g.fs * 0.05, g.corner, clamp(e * 3, 0, 1)).toFixed(2) + "px"
            if (g.axis) rw.style.setProperty("--hx-wd", lerp(100, rg.wd, e).toFixed(2))
            else rw.style.fontSize = (g.fs * lerp(1, rg.shrink, e)).toFixed(2) + "px"
            const im = rw.querySelector<HTMLElement>(".lyr-hx-slot > .lyr-hx-media")
            if (im) {
                const mx = Math.max(0, (rg.pw * 1.06 - sw) / 2),
                    my = Math.max(0, (g.ph * 1.06 - sh) / 2)
                im.style.transform = `translate(-50%,-50%) translate3d(${clamp(fx * rg.pw * (1 - e), -mx, mx).toFixed(2)}px,${clamp(fy * g.ph * (1 - e), -my, my).toFixed(2)}px,0) scale(1.06)`
            }
        })
    }

    measureRef.current = () => {
        const r = root.current,
            pn = pin.current,
            pb = probe.current
        if (!r || !pn || !pb) return
        const els = Array.from(pn.querySelectorAll<HTMLElement>(".lyr-hx-row"))
        const W = pn.clientWidth,
            H = pn.clientHeight
        const below = W < 900
        const rowW = W - 2 * edge
        const kids = pb.children
        if (!els.length || kids.length < els.length * 4) return
        const m = (k: number) => kids[k].getBoundingClientRect().width / 100
        const t100 = els.map((_, i) => m(i * 4)),
            k100 = els.map((_, i) => m(i * 4 + 1)),
            t62 = els.map((_, i) => m(i * 4 + 2)),
            k62 = els.map((_, i) => m(i * 4 + 3))
        const axis = t62[0] < t100[0] * 0.96
        const avail = H - topGap - headH - 20
        let fs = below ? clamp(W * 0.076, 26, 46) : clamp(W * 0.044, 30, 64)
        let hc: number, ph: number, ex: number
        const gap = below ? fs * 0.28 : clamp(W * 0.016, 14, 26)
        if (below) {
            hc = fs * 1.1 + 62
            ph = clamp(avail - total * hc - 18, 96, allPanel ? 230 : rowW / 1.5)
            if (total * hc + ph + 18 > avail) {
                hc = Math.max(68, (avail - ph - 18) / total)
                fs = Math.max(20, Math.min(fs, (hc - 46) / 1.1))
            }
            ex = ph + 18
        } else {
            hc = fs * 1.5
            ph = clamp(avail - (total - 1) * hc - 28, 150, allPanel ? 290 : Math.min(W * 0.33, 480))
            if ((total - 1) * hc + ph + 28 > avail) {
                hc = Math.max(40, (avail - ph - 28) / Math.max(1, total - 1))
                fs = Math.max(18, Math.min(fs, hc / 1.5))
            }
            ex = Math.max(0, ph + 28 - hc)
        }
        r.style.setProperty("--hx-fs-set", fs.toFixed(2) + "px")
        r.style.setProperty("--hx-hc-set", hc.toFixed(2) + "px")
        r.style.setProperty("--hx-ex-set", ex.toFixed(2) + "px")
        r.style.setProperty("--hx-g", gap.toFixed(2) + "px")
        // each row's open size and how far its words narrow
        const sizes = els.map((rw, i) => {
            let pw = below ? rowW : rows[i]?.panel ? Math.min(ph * 2, 560) : ph * 1.6,
                wd = 100,
                shrink = 1
            if (!below) {
                const yEl = rw.querySelector(".lyr-hx-y") as HTMLElement | null
                const yearW = yEl && yEl.offsetWidth ? yEl.offsetWidth + 24 : 0,
                    words = rowW - yearW - pw - 2 * gap - 12
                const s100 = (t100[i] + k100[i]) * fs,
                    s62 = (t62[i] + k62[i]) * fs
                if (axis) {
                    if (words < s62) {
                        pw = Math.max(Math.min(pw, 200), pw - (s62 - words))
                        wd = 62
                    } else wd = clamp(62 + ((words - s62) / Math.max(1, s100 - s62)) * 38, 62, 100)
                } else shrink = clamp(words / s100, 0.5, 1)
            }
            rw.style.setProperty("--hx-pw", pw.toFixed(2) + "px")
            rw.style.setProperty("--hx-ph", ph.toFixed(2) + "px")
            rw.style.fontSize = ""
            rw.style.setProperty("--hx-wd", "100")
            const s = rw.querySelector<HTMLElement>(".lyr-hx-slot")
            if (s) {
                s.style.width = ""
                s.style.height = ""
            }
            return { pw, wd, shrink }
        })
        // the closed rows, read once everything above is set
        const rowGeo: RowGeo[] = els.map((rw, i) => {
            const rr = rw.getBoundingClientRect(),
                s = rw.querySelector<HTMLElement>(".lyr-hx-slot"),
                sr = s ? s.getBoundingClientRect() : rr
            const l1 = rw.querySelector(".lyr-hx-l1") as HTMLElement | null
            for (const im of Array.from(rw.querySelectorAll<HTMLElement>(".lyr-hx-media"))) {
                im.style.width = sizes[i].pw.toFixed(2) + "px"
                im.style.height = ph.toFixed(2) + "px"
            }
            return { ...sizes[i], sw: sr.width, sh: sr.height, sx: sr.left - rr.left, sy: sr.top - rr.top, l1b: l1 ? l1.getBoundingClientRect().bottom - rr.top : 0 }
        })
        geo.current = { W, H, below, rowW, axis, fs, hc, ph, ex, top: topGap, headH, corner, fx: clamp(stripX, 0, 100) / 100, fy: clamp(stripY, 0, 100) / 100, rows: rowGeo }
    }

    React.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)
    }, [])

    React.useEffect(() => {
        if (still || !rows.length) return
        const tl = tall.current,
            pn = pin.current
        if (!tl || !pn) return
        let raf = 0,
            shown = -1,
            goal = 0,
            last = 0,
            live = true,
            seen = false
        const read = () => {
            const tr = tl.getBoundingClientRect(),
                vh = window.innerHeight
            seen = tr.bottom > -vh * 0.5 && tr.top < vh * 1.5
            const stepPx = (step / 100) * vh
            const u = clamp(-tr.top / Math.max(1, stepPx), 0, total - 1)
            const seg = Math.min(total - 1, Math.floor(u)),
                frac = u - seg
            goal = seg + clamp((frac - 0.3) / 0.7, 0, 1)
        }
        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 / 110))
            if (Math.abs(goal - shown) < 0.0005) shown = goal
            paintRef.current(shown)
            if (shown !== goal && live) raf = requestAnimationFrame(tick)
            else last = 0
        }
        const kick = () => {
            read()
            if (!seen && shown === goal) return
            if (!raf) raf = requestAnimationFrame(tick)
        }
        const full = () => {
            measureRef.current()
            shown = -1
            read()
            if (!raf) raf = requestAnimationFrame(tick)
        }
        full()
        const ro = new ResizeObserver(full)
        ro.observe(pn)
        window.addEventListener("scroll", kick, { passive: true })
        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)
            fonts?.removeEventListener?.("loadingdone", full)
            if (raf) cancelAnimationFrame(raf)
        }
    }, [still, rows, total, topGap, edge, corner, step, font.name, font.css, stripX, stripY, headH, allPanel, years])

    const vars = { "--hx-ink": ink, "--hx-mute": mute, "--hx-line": line, "--hx-font": stack, "--hx-top": topGap + "px", "--hx-edge": edge + "px", "--hx-r": corner + "px", "--hx-headh": headH + "px", "--hx-card": card, "--hx-travel": String((total - 1) * step + 40) } as React.CSSProperties
    const media = (it: Item, lazy = true) =>
        it.image?.src ? <img className="lyr-hx-media" loading={lazy ? "lazy" : undefined} decoding="async" srcSet={it.image.srcSet} sizes={imageSizes} src={it.image.src} alt="" /> : <span className="lyr-hx-media lyr-hx-ph" />

    return (
        <div ref={root} className={"lyr-hx" + (still ? " lyr-hx-still" : "")} style={{ ...props.style, ...vars }}>
            <style dangerouslySetInnerHTML={{ __html: font.css + CSS }} />
            <div ref={tall} className="lyr-hx-tall">
                <div ref={pin} className="lyr-hx-pin">
                    {heading || allLabel ? (
                        <div className="lyr-hx-head">
                            <h2 className="lyr-hx-h">
                                {heading} {headingNote ? <span>{headingNote.replace("{n}", String(rows.length))}</span> : null}
                            </h2>
                            {allLabel && allLink ? (
                                <Link href={allLink}>
                                    <a className="lyr-hx-all">{allLabel}</a>
                                </Link>
                            ) : null}
                        </div>
                    ) : null}
                    {rows.map((it, i) => {
                        // at rest (canvas, reduced motion) the first row, or with reduced motion every row, is drawn open
                        const stillOpen = still && (reduced || i === 0)
                        const panelBox = it.panel ? (
                            <span className="lyr-hx-panel" aria-hidden="true">
                                <span className="lyr-hx-pt">{it.panelText}</span>
                                <span className="lyr-hx-pb">
                                    <b>{it.panelLeft}</b>
                                    <span>{it.panelRight}</span>
                                </span>
                            </span>
                        ) : null
                        const body = (
                            <>
                                <span className="lyr-hx-l1" style={{ display: "contents" }}>
                                    <span className="lyr-hx-t">{it.title}</span>
                                    <span className="lyr-hx-slot" aria-hidden="true" style={stillOpen ? { width: "min(46%, 420px)", height: "auto", aspectRatio: it.panel ? undefined : "1.6", margin: "14px 0", borderRadius: corner } : undefined}>
                                        {it.panel ? panelBox : media(it)}
                                    </span>
                                </span>
                                <span className="lyr-hx-k">{it.kind}</span>
                                {String(it.year || "").trim() ? <span className="lyr-hx-y">{it.year}</span> : null}
                                <span className="lyr-hx-l2">
                                    <span className="lyr-hx-k">{it.kind}</span>
                                    <span>{it.year}</span>
                                </span>
                                <span className="lyr-hx-pic" aria-hidden="true">
                                    {it.panel ? panelBox : media(it)}
                                </span>
                            </>
                        )
                        const cls = "lyr-hx-row" + (i === rows.length - 1 ? " lyr-hx-last" : "") + (it.panel ? " lyr-hx-plain" : "")
                        const rowVars = { "--hx-i": i, "--hx-after": i > 0 ? 1 : 0 } as React.CSSProperties
                        const label = [`${it.title} ${it.kind}`.trim(), it.year, it.panelText, it.panelLeft, it.panelRight].filter((x) => x && String(x).trim()).join(". ")
                        return it.link ? (
                            <Link key={i} href={it.link}>
                                <a className={cls} style={rowVars} aria-label={label}>
                                    {body}
                                </a>
                            </Link>
                        ) : (
                            <a key={i} className={cls} style={rowVars}>
                                {body}
                            </a>
                        )
                    })}
                    <div ref={probe} className="lyr-hx-probe" aria-hidden="true">
                        {rows.map((it, i) => (
                            <React.Fragment key={i}>
                                <span style={{ fontVariationSettings: `"wdth" 100` }}>{it.title}</span>
                                <span style={{ fontVariationSettings: `"wdth" 100` }}>{it.kind}</span>
                                <span style={{ fontVariationSettings: `"wdth" 62` }}>{it.title}</span>
                                <span style={{ fontVariationSettings: `"wdth" 62` }}>{it.kind}</span>
                            </React.Fragment>
                        ))}
                    </div>
                </div>
            </div>
        </div>
    )
}

addPropertyControls(HyphenIndex, {
    items: {
        type: ControlType.Array,
        title: "Items",
        maxCount: 12,
        defaultValue: ITEMS,
        control: {
            type: ControlType.Object,
            controls: {
                title: { type: ControlType.String, title: "Title", description: "The word before the hyphen." },
                kind: { type: ControlType.String, title: "Kind", description: "The words after the hyphen, two or three at most. Leave empty to split a hyphenated title at its first hyphen." },
                year: { type: ControlType.String, title: "Year" },
                image: { type: ControlType.ResponsiveImage, title: "Picture", description: "Shown through the hyphen." },
                link: { type: ControlType.Link, title: "Link" },
                panelText: { type: ControlType.String, title: "Panel text", displayTextArea: true, description: "With no picture, the hyphen opens onto a panel instead. This is its text." },
                panelLeft: { type: ControlType.String, title: "Panel figure", description: "Large words at the foot of the panel, such as a price." },
                panelRight: { type: ControlType.String, title: "Panel note", description: "Small words beside the figure." },
            },
        },
        description: "One row each, in order. Each opens in turn as the list is scrolled.",
    },
    heading: { type: ControlType.String, title: "Heading", defaultValue: "Selected work" },
    headingNote: { type: ControlType.String, title: "Heading note", defaultValue: "{n} projects", description: "Muted words after the heading. {n} becomes the number of items." },
    allLabel: { type: ControlType.String, title: "All label", defaultValue: "All work" },
    allLink: { type: ControlType.Link, title: "All link" },
    fontFile: { type: ControlType.File, title: "Font file", allowedFileTypes: ["woff2"], description: "A variable font with a width axis. The words narrow with it; without one they shrink instead. 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: "Muted colour", defaultValue: "#8B8A84" },
    line: { type: ControlType.Color, title: "Line colour", defaultValue: "rgba(18,18,17,0.16)" },
    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 item", defaultValue: 60, min: 20, max: 150, step: 5, unit: "%", description: "Share of a screen scrolled for each item." },
    stripX: { type: ControlType.Number, title: "Strip across", defaultValue: 22, min: 0, max: 100, step: 1, unit: "%", description: "Which part of the picture the closed hyphen shows, from the left." },
    stripY: { type: ControlType.Number, title: "Strip down", defaultValue: 63, min: 0, max: 100, step: 1, unit: "%", description: "Which part of the picture the closed hyphen shows, from the top." },
    imageSizes: { type: ControlType.String, title: "Image width", defaultValue: "50vw", description: "One CSS length, for example 50vw." },
    card: { type: ControlType.Color, title: "Panel colour", defaultValue: "#FFFFFF" },
})

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.