Layr Components

Components / Hero

Hyphen

Two words joined by a hyphen that is a window onto a picture; scrolling opens it while the words narrow, until the picture fills the screen.

Scroll insideFull screen
Props
#0E0E0D
#6E6E6B
#FFFFFF
#FFFFFF
72 px
32 px
10 px
1.7
82%
22%
63%

Set in code: Picture, Project link, Font file. The preview fills these in with demo content.

Scroll inside the preview to open the hyphen.

In Framer, open Assets, add a new code file, paste this over everything in it and save. Hyphen shows up in the Insert menu, and its controls appear in the right panel.
// Hyphen · Layr Components
//
// Two words joined by a hyphen that is a window onto a picture. Scroll opens the
// hyphen: the words narrow to make room (width axis), then the picture takes the
// window and the project's title, summary and link land on it. Three behaviours:
// a pinned hero, a block that opens as it scrolls past, or the line on its own.
// Paste into a new code file in Framer. Full width, auto height: as a pinned scene it
// is taller than the screen and pins itself, so keep its parents' overflow visible.
// Without a picture the hyphen 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=block"
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:block}` }
    }
    return { name: "Mona Sans", css: `@import url("${GOOGLE_FONT}");` }
}
// Advance widths (per 1000 em) of Mona Sans at its widest, for a first size before the font is measured.
const ADV = [229, 209, 346, 685, 681, 970, 790, 159, 339, 339, 301, 720, 181, 389, 176, 470, 751, 346, 617, 662, 713, 672, 688, 601, 674, 688, 176, 181, 720, 720, 720, 549, 969, 822, 742, 809, 794, 674, 657, 842, 827, 222, 438, 745, 586, 950, 827, 843, 710, 878, 756, 699, 663, 773, 838, 1097, 749, 733, 682, 351, 449, 351, 720, 459, 245, 666, 665, 626, 666, 648, 346, 645, 653, 208, 208, 611, 208, 1037, 653, 655, 665, 666, 364, 564, 370, 654, 608, 848, 592, 591, 552, 400, 236, 400, 720]
const TRACK = -0.03
const WIDE = 125
const NARROW = 62
const guess = (s: string) => {
    let n = 0
    for (const ch of s) { const c = ch.charCodeAt(0); n += (c >= 32 && c < 127 ? ADV[c - 32] : 640) / 1000 + TRACK }
    return Math.max(0.6, n)
}
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
// a small copy is enough for a closed hyphen: the first entry of srcSet when there is one
const small = (src: string, srcSet?: string) => (srcSet ? srcSet.split(",")[0].trim().split(/\s+/)[0] : src)
// Shown when the picture is not set yet: a quiet tone, so the hyphen still opens onto something.
const EMPTY = "linear-gradient(160deg, #c9c4ba, #8f897e)"

const CSS = `
.lyr-hy{--hy-g:clamp(14px,2.4cqw,40px);--hy-foot:150px;position:relative;width:100%;container-type:inline-size;isolation:isolate;color:var(--hy-ink);font-family:var(--hy-font);font-weight:400;-webkit-font-smoothing:antialiased;font-synthesis:none}
.lyr-hy *{box-sizing:border-box}
.lyr-hy-pin{position:sticky;top:0;height:100vh;height:100lvh;width:100%;overflow:hidden;overflow:clip;background:var(--hy-paper)}
.lyr-hy-stage{position:absolute;left:0;top:0;width:100%;height:100vh;height:100svh}
.lyr-hy-line{position:absolute;left:var(--hy-edge);right:var(--hy-edge);top:calc(var(--hy-top) + (100% - var(--hy-top) - var(--hy-foot-set,var(--hy-foot))) * .5);transform:translateY(-50%);margin:0;display:flex;align-items:center;gap:var(--hy-g-set,var(--hy-g));font-size:var(--hy-fs-set,var(--hy-fs));line-height:1;letter-spacing:${TRACK}em;font-weight:400;white-space:nowrap}
.lyr-hy-w{flex:none;display:block;font-variation-settings:"wdth" var(--hy-wd,${WIDE});will-change:transform}
.lyr-hy-slot{flex:1 1 0;min-width:.5em;height:.36em;border-radius:.03em;background:var(--hy-ink);transform:translateY(.07em)}
.lyr-hy-pic .lyr-hy-slot{background-color:transparent;transition:background-color .2s linear .5s}
.lyr-hy-row2{display:contents}
.lyr-hy-foot{position:absolute;left:var(--hy-edge);right:var(--hy-edge);bottom:var(--hy-edge);display:flex;justify-content:space-between;align-items:flex-end;gap:32px}
.lyr-hy-intro{margin:0;max-width:17.5em;font-size:clamp(19px,1.82cqw,26px);line-height:1.18;letter-spacing:-.028em;text-wrap:pretty}
.lyr-hy-m{color:var(--hy-mute)}
.lyr-hy-cap{margin:0;text-align:right;font-size:15px;line-height:1.45;letter-spacing:-.01em;white-space:nowrap}
.lyr-hy-cap span{display:block}
.lyr-hy-win{position:absolute;left:var(--hy-edge);right:var(--hy-edge);top:var(--hy-top);bottom:var(--hy-edge);overflow:hidden;border-radius:var(--hy-r);opacity:0;transition:opacity .5s ease;z-index:2;color:var(--hy-on)}
.lyr-hy-win.lyr-hy-ready{opacity:1}
.lyr-hy-win img{position:absolute;left:0;top:0;width:100%;height:100%;max-width:none;object-fit:cover;display:block;transform-origin:50% 50%;will-change:transform}
.lyr-hy-ph{position:absolute;inset:0;background:${EMPTY}}
.lyr-hy-shade{position:absolute;inset:0;background:linear-gradient(to top,rgba(0,0,0,.5),rgba(0,0,0,0) 46%);opacity:0;transition:opacity .6s ease}
.lyr-hy-land{position:absolute;left:clamp(16px,2.2cqw,32px);right:clamp(16px,2.2cqw,32px);bottom:clamp(18px,2.2cqw,30px);display:flex;justify-content:space-between;align-items:flex-end;gap:24px;opacity:0;transform:translateY(14px);transition:opacity .55s ease,transform .7s cubic-bezier(.2,.7,.2,1);pointer-events:none}
[data-lyr-hy-on="1"] .lyr-hy-land{opacity:1;transform:none;pointer-events:auto}
[data-lyr-hy-on="1"] .lyr-hy-shade{opacity:1}
.lyr-hy-title{margin:0;overflow-wrap:anywhere;text-wrap:balance;max-width:11em;font-size:clamp(44px,7.2cqw,104px);line-height:.95;letter-spacing:-.045em;font-weight:400}
.lyr-hy-sum{margin:.9em 0 0;max-width:24em;font-size:clamp(16px,1.4cqw,20px);line-height:1.3;letter-spacing:-.018em;opacity:.86}
.lyr-hy-link{flex:none;color:inherit;text-decoration:none;font-size:15px;letter-spacing:-.01em;line-height:1.3;padding-bottom:5px;border-bottom:1px solid currentColor;transition:opacity .2s ease;white-space:nowrap}
.lyr-hy-link:hover{opacity:.66}
.lyr-hy-link:focus-visible{outline:2px solid currentColor;outline-offset:4px}
.lyr-hy-note{position:absolute;left:0;top:0;margin:0;max-width:min(30em,80cqw);font-size:15px;line-height:1.45;letter-spacing:-.01em;color:var(--hy-soft,var(--hy-mute));opacity:0;transition:opacity .5s ease;will-change:transform;white-space:normal}
[data-lyr-hy-on="1"] .lyr-hy-note{opacity:1}
.lyr-hy-rest-wrap{display:flex;flex-direction:column;gap:14px;height:100%}
.lyr-hy-rest-wrap .lyr-hy-rest{flex:1 1 0;min-height:0;height:auto}
.lyr-hy-rest-wrap .lyr-hy-note{position:static;opacity:1;margin:0 var(--hy-edge)}
.lyr-hy-caplink{color:inherit;text-decoration:none;border-bottom:1px solid currentColor;padding-bottom:3px;transition:opacity .2s ease}
.lyr-hy-caplink:hover{opacity:.66}
.lyr-hy-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-hy-probe span{display:inline-block}
@container (max-width:719px){
.lyr-hy-line{flex-wrap:wrap;row-gap:.06em;font-size:var(--hy-fs-set,var(--hy-fsn));top:calc(var(--hy-top) + (100% - var(--hy-top) - var(--hy-foot-set,var(--hy-foot))) * .42)}
.lyr-hy-row2{display:block;flex:0 0 100%}
.lyr-hy-row2 .lyr-hy-w{display:inline-block}
.lyr-hy.lyr-hy-between .lyr-hy-line{top:0;transform:none}
.lyr-hy-foot{flex-direction:column;align-items:flex-start;gap:14px}
.lyr-hy-cap{text-align:left}
.lyr-hy-cap span{display:inline}
.lyr-hy-cap span+span::before{content:" · "}
.lyr-hy-land{flex-direction:column;align-items:flex-start;gap:18px}
}
@media (max-width:1023px) and (max-aspect-ratio:5/6){
.lyr-hy-line{flex-wrap:wrap;row-gap:.06em;font-size:var(--hy-fs-set,var(--hy-fsn));top:calc(var(--hy-top) + (100% - var(--hy-top) - var(--hy-foot-set,var(--hy-foot))) * .42)}
.lyr-hy-row2{display:block;flex:0 0 100%}
.lyr-hy-row2 .lyr-hy-w{display:inline-block}
.lyr-hy.lyr-hy-between .lyr-hy-line{top:0;transform:none}
}
.lyr-hy-tall{height:calc(100vh + var(--hy-travel) * 1vh);height:calc(100lvh + var(--hy-travel) * 1vh)}
.lyr-hy-flow{height:clamp(300px,calc(var(--hy-flowh) * 1vh),1100px);height:clamp(300px,calc(var(--hy-flowh) * 1svh),1100px)}
.lyr-hy-flow .lyr-hy-pin{position:relative;height:100%;background:none}
.lyr-hy-flow .lyr-hy-stage{height:100%}
.lyr-hy-flow .lyr-hy-foot{display:none}
.lyr-hy-flow .lyr-hy-win{top:0;bottom:0}
.lyr-hy-flow .lyr-hy-line{top:50%}
.lyr-hy-lineonly .lyr-hy-stage{height:auto!important}
.lyr-hy-lineonly .lyr-hy-line{position:relative;left:auto;right:auto;top:auto;transform:none;margin:0 var(--hy-edge)}
.lyr-hy-lineonly .lyr-hy-foot,.lyr-hy-lineonly .lyr-hy-win{display:none!important}
.lyr-hy-rest{display:flex;align-items:center;gap:var(--hy-g);padding:0 var(--hy-edge);font-size:clamp(34px,9.4cqw,150px);line-height:1;letter-spacing:-0.03em;white-space:nowrap;--hy-wd:62}
.lyr-hy-rest .lyr-hy-win{position:relative;left:auto;right:auto;top:auto;bottom:auto;flex:1 1 0;min-width:0;height:100%;margin:0;aspect-ratio:auto;opacity:1}
.lyr-hy-rest .lyr-hy-win img{width:100%!important;height:100%!important;left:0!important;top:0!important;transform:none!important}
.lyr-hy-rest .lyr-hy-land{opacity:1;transform:none;pointer-events:auto}
.lyr-hy-rest .lyr-hy-shade{opacity:1}
.lyr-hy-sr{position:absolute;width:1px;height:1px;margin:-1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
@container (max-width:719px){.lyr-hy-rest{flex-direction:column;align-items:flex-start;justify-content:center;gap:.12em;font-size:clamp(34px,17cqw,120px)}.lyr-hy-rest .lyr-hy-win{flex:1 1 0;width:100%}}
.lyr-hy-still .lyr-hy-pin{position:relative;height:auto;overflow:visible;background:none}
.lyr-hy-still .lyr-hy-stage{position:relative;height:var(--hy-still-h,760px)}
.lyr-hy-still .lyr-hy-win img{width:100%!important;height:100%!important;left:0!important;top:0!important;transform:none!important}
.lyr-hy-still .lyr-hy-win{position:relative;left:auto;right:auto;top:auto;bottom:auto;margin:0 var(--hy-edge) var(--hy-edge);aspect-ratio:16/10;opacity:1}
.lyr-hy-still .lyr-hy-land{opacity:1;transform:none;pointer-events:auto}
.lyr-hy-still .lyr-hy-shade{opacity:1}
.lyr-hy-still .lyr-hy-slot{overflow:hidden;background-size:900% auto;background-position:var(--hy-sx,22%) var(--hy-sy,63%)}
@media (prefers-reduced-motion:reduce){.lyr-hy-win,.lyr-hy-land,.lyr-hy-shade{transition:none}}
`

type Props = {
    left: string; right: string; image?: { src: string; srcSet?: string; alt?: string }; title: string; year: string; summary: string
    link?: string; linkLabel: string; lead: string; intro: string; hint: string
    fontFile?: string; fontFamily: string; ink: string; mute: string; paper: string; onPhoto: string
    topGap: number; edge: number; corner: number; travel: number; stripX: number; stripY: number; imageSizes: string; tag: string; mode: string; flowHeight: number; opensTo: string; reverse: boolean; note: string; eager: boolean; style?: React.CSSProperties
}

/**
 * @framerIntrinsicWidth 1200
 * @framerIntrinsicHeight 800
 *
 * @framerSupportedLayoutWidth fixed
 * @framerSupportedLayoutHeight auto
 * @framerDisableUnlink
 */
export default function Hyphen(props: Props) {
    const {
        left = "Design", right = "studio", image, title = "Harbour", year = "2026", summary = "A booking flow for a small group of guest houses.", link, linkLabel = "Read the case study",
        lead = "A small design studio.", intro = "We draw interfaces and write the code that ships them.", hint = "Scroll to open the hyphen",
        fontFile, fontFamily = "", ink = "#121211", mute = "#8B8A84", paper = "#F5F4F0", onPhoto = "#FFFFFF",
        topGap = 72, edge = 32, corner = 10, travel = 1.7, imageSizes = "100vw", tag = "H1", stripX = 22, stripY = 63, mode = "Pinned scene", flowHeight = 82, opensTo = "Whole picture", reverse = false, note = "", eager = false,
    } = props
    const flow = String(mode || "").toLowerCase().includes("view")
    const lineOnly = String(mode || "").toLowerCase().includes("line")
    const between = flow && String(opensTo || "").toLowerCase().includes("between")
    const back = !flow && !lineOnly && !!reverse
    const isStatic = useIsStaticRenderer()
    const [reduced, setReduced] = React.useState(false)
    const [ready, setReady] = React.useState(false)
    const root = React.useRef<HTMLElement>(null)
    const pin = React.useRef<HTMLDivElement>(null)
    const stage = React.useRef<HTMLDivElement>(null)
    const w1 = React.useRef<HTMLSpanElement>(null)
    const w2 = React.useRef<HTMLSpanElement>(null)
    const slot = React.useRef<HTMLSpanElement>(null)
    const win = React.useRef<HTMLDivElement>(null)
    const pic = React.useRef<HTMLImageElement>(null)
    const probe = React.useRef<HTMLDivElement>(null)
    const noteRef = React.useRef<HTMLParagraphElement>(null)
    const geo = React.useRef<any>(null)
    const paintRef = React.useRef<(p: number) => void>(() => {})
    const measureRef = React.useRef<() => void>(() => {})
    const still = isStatic || reduced || lineOnly
    const font = widthFont(fontFamily, fontFile)
    const stack = `"${font.name}", "Helvetica Neue", Arial, sans-serif`
    const hasPic = !!image?.src
    const e1 = guess(left), e2 = guess(right)

    // one frame of the scene; p runs 0 to 1 over the pinned scroll
    paintRef.current = (p: number) => {
        const g = geo.current
        const a = w1.current, b = w2.current, s = slot.current, wn = win.current, r = root.current
        if (!g || !a || !b || !s || !wn || !r) return
        if (g.back) p = 1 - p
        const A = g.between ? ease(clamp(p, 0, 1)) : ease(clamp(p / 0.48, 0, 1)), B = g.between ? 0 : ease(clamp((p - 0.48) / 0.42, 0, 1))
        if (g.axis) { r.style.setProperty("--hy-wd", String(lerp(WIDE, NARROW, A))); a.style.transform = b.style.transform = "" }
        else { a.style.transformOrigin = "0 50%"; b.style.transformOrigin = g.stacked ? "0 50%" : "100% 50%" }
        // where the hyphen is now, inside the pin
        const pr = pin.current!.getBoundingClientRect()
        let sx: number, sw: number
        const sr = s.getBoundingClientRect()
        const scy = sr.top - pr.top + sr.height / 2
        if (g.axis) { sx = sr.left - pr.left; sw = sr.width }
        else { const k = lerp(1, 0.54, A); sx = g.edge + g.n1 * k + g.gap; sw = (g.stacked ? g.W - g.edge : g.W - g.edge - g.n2 * k - g.gap) - sx }
        const h0 = sr.height
        const hA = g.between ? Math.min(sw / 1.6, g.Hl - 64) : Math.min(sw / 1.6, (g.Hs - g.top) * (g.stacked ? 0.4 : 0.56))
        const h = lerp(h0, Math.max(h0, hA), A)
        const up = g.stacked ? 0.3 : 0.5
        let x0 = sx, x1 = sx + sw, y0 = scy - h0 / 2 - (h - h0) * up, y1 = y0 + h
        const R = { x0: g.edge, y0: g.top, x1: g.W - g.edge, y1: g.Hl - g.base }
        if (g.between && g.stacked) {
            // narrow screens: the hyphen drops open into a picture between the two words
            const by0 = g.w1bottom + g.gap * 0.6, bh = Math.max(h0, Math.min((R.x1 - R.x0) / 1.5, g.Hl - by0 - g.fs * 1.25 - 28 - g.noteH))
            x0 = lerp(sx, R.x0, A); x1 = lerp(sx + sw, R.x1, A); y0 = lerp(scy - h0 / 2, by0, A); y1 = y0 + lerp(h0, bh, A)
        }
        if (y0 < R.y0 + 6 && !g.between) { y1 += R.y0 + 6 - y0; y0 = R.y0 + 6 }
        x0 = lerp(x0, R.x0, B); x1 = lerp(x1, R.x1, B); y0 = lerp(y0, R.y0, B); y1 = lerp(y1, R.y1, B)
        const rad = lerp(Math.max(2, g.fs * 0.03), g.corner, clamp(A * 0.6 + B, 0, 1))
        wn.style.clipPath = `inset(${(y0 - R.y0).toFixed(2)}px ${(R.x1 - x1).toFixed(2)}px ${(R.y1 - y1).toFixed(2)}px ${(x0 - R.x0).toFixed(2)}px round ${rad.toFixed(2)}px)`
        if (pic.current) {
            // closed, the hyphen shows the chosen part of the picture; opening, the picture slides home
            const sc = 1.14 - 0.14 * (A * 0.45 + B * 0.55), E = 1 - Math.pow(1 - (A * 0.5 + B * 0.5), 2)
            const mx = (R.x0 + R.x1) / 2, my = (R.y0 + R.y1) / 2, dw = g.dw * sc, dh = g.dh * sc
            let tx = (sx + sw / 2 - (mx + (g.fx - 0.5) * dw)) * (1 - E), ty = (scy - (my + (g.fy - 0.5) * dh)) * (1 - E)
            tx = clamp(tx, x1 - mx - dw / 2, x0 - mx + dw / 2); ty = clamp(ty, y1 - my - dh / 2, y0 - my + dh / 2)
            pic.current.style.transform = `translate3d(${tx.toFixed(2)}px,${ty.toFixed(2)}px,0) scale(${sc.toFixed(4)})`
        }
        // the picture pushes the words out of the window
        const drop = g.between && g.stacked
        // once the picture is taking the window, the words leave it completely
        const out = B * (g.edge + 12)
        const dx1 = drop ? 0 : Math.min(0, x0 - sx) - out, dx2 = Math.max(0, x1 - (sx + sw)) + out
        if (drop) s.style.opacity = (1 - A).toFixed(3)
        const k = g.axis ? 1 : lerp(1, 0.54, A)
        const sc = g.axis ? "" : ` scale(${k.toFixed(4)})`
        a.style.transform = dx1 || sc ? `translate3d(${dx1.toFixed(2)}px,0,0)${sc}` : ""
        if (g.stacked) { const dy = Math.max(0, y1 + (drop ? g.noteH * A : 0) + g.gap * 0.7 - g.w2top) + B * (g.edge + 12); b.style.transform = dy || sc ? `translate3d(0,${dy.toFixed(2)}px,0)${sc}` : "" }
        else b.style.transform = dx2 || sc ? `translate3d(${dx2.toFixed(2)}px,0,0)${sc}` : ""
        if (noteRef.current) noteRef.current.style.transform = `translate3d(${x0.toFixed(1)}px,${(y1 + 12).toFixed(1)}px,0)`
        const on = p > 0.9
        if (r.dataset.lyrHyOn !== (on ? "1" : "0")) { r.dataset.lyrHyOn = on ? "1" : "0"; const l = r.querySelector(".lyr-hy-link") as HTMLAnchorElement | null; if (l) l.tabIndex = on ? 0 : -1 }
    }

    // sizes that only change with the window or the font
    measureRef.current = () => {
        const r = root.current, pn = pin.current, st = stage.current, pb = probe.current, b = w2.current
        if (!r || !pn || !st || !pb || !b) return
        const W = pn.clientWidth, Hl = pn.clientHeight, Hs = st.clientHeight
        const top = flow ? 0 : topGap, base = flow ? 0 : edge
        const stacked = W < 720 || (W < 1024 && W / Math.max(1, window.innerHeight) < 5 / 6)
        const kids = pb.children as any
        if (kids.length < 3) return
        const wide1 = kids[0].getBoundingClientRect().width / 100, wide2 = kids[1].getBoundingClientRect().width / 100
        const thin1 = kids[2].getBoundingClientRect().width / 100
        const axis = thin1 < wide1 * 0.96
        const gap = stacked ? Math.max(8, W * 0.03) : clamp(W * 0.024, 14, 40)
        const hw = stacked ? 0 : clamp(W * 0.122, 64, 190)
        let fs = stacked ? (W - 2 * edge) / Math.max(wide2, wide1 + 1.1) : (W - 2 * edge - hw - 2 * gap) / (wide1 + wide2)
        const foot = flow ? 0 : (r.querySelector(".lyr-hy-foot") as HTMLElement).offsetHeight + edge
        const room = lineOnly || (between && stacked) ? 1e6 : between ? Hs * 0.5 : Hs - top - foot - (flow ? 24 : 48)
        fs = Math.max(20, Math.min(fs, stacked ? room / 2.3 : room / 1.25, 260))
        r.style.setProperty("--hy-fs-set", fs.toFixed(2) + "px")
        r.style.setProperty("--hy-g-set", gap.toFixed(2) + "px")
        r.style.setProperty("--hy-foot-set", foot + "px")
        r.style.setProperty("--hy-wd", String(WIDE))
        if (w1.current) w1.current.style.transform = ""
        b.style.transform = ""
        // the picture is drawn at the size that covers the open window, so the closed hyphen can show any part of it
        const rw = W - 2 * edge, rh = Hl - top - base
        const im = pic.current
        let dw = rw, dh = rh
        if (im && im.naturalWidth) { const k = Math.max(rw / im.naturalWidth, rh / im.naturalHeight); dw = im.naturalWidth * k; dh = im.naturalHeight * k }
        if (im) { im.style.width = dw.toFixed(2) + "px"; im.style.height = dh.toFixed(2) + "px"; im.style.left = ((rw - dw) / 2).toFixed(2) + "px"; im.style.top = ((rh - dh) / 2).toFixed(2) + "px" }
        const pr = pn.getBoundingClientRect()
        geo.current = { W, Hl, Hs, stacked, axis, gap, fs, edge, base, top, corner, dw, dh, between, back, noteH: noteRef.current ? noteRef.current.offsetHeight + 20 : 0, w1bottom: w1.current ? w1.current.getBoundingClientRect().bottom - pr.top : 0, fx: clamp(stripX, 0, 100) / 100, fy: clamp(stripY, 0, 100) / 100, n1: wide1 * fs, n2: wide2 * fs, w2top: b.getBoundingClientRect().top - pr.top }
    }

    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 (!lineOnly || isStatic) return
        const pn = pin.current
        if (!pn) return
        const fit = () => measureRef.current()
        fit()
        const ro = new ResizeObserver(fit); ro.observe(pn)
        const fonts = (document as any).fonts
        if (fonts) { fonts.load(`400 100px "${font.name}"`).then(fit).catch(() => {}); fonts.ready.then(fit); fonts.addEventListener?.("loadingdone", fit) }
        return () => { ro.disconnect(); fonts?.removeEventListener?.("loadingdone", fit) }
    }, [lineOnly, isStatic, left, right, edge, font.name, font.css])

    React.useEffect(() => {
        if (still) return
        const r = root.current, pn = pin.current
        if (!r || !pn) return
        let raf = 0, shown = -1, goal = 0, last = 0, live = true
        const read = () => {
            const rr = r.getBoundingClientRect()
            if (flow) {
                // closed while the line comes up the screen, open once the block is centred (or as far as the page can scroll)
                const vh = window.innerHeight, c = rr.top + rr.height / 2
                const left = Math.max(0, document.documentElement.scrollHeight - vh - window.scrollY)
                const end = Math.max(vh * 0.5, c - left + 8), start = Math.max(end + vh * 0.2, vh * 0.74)
                goal = clamp((start - c) / (start - end), 0, 1)
            }
            else { const span = rr.height - pn.offsetHeight; goal = span > 0 ? clamp(-rr.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 / 90))
            if (Math.abs(goal - shown) < 0.0004) shown = goal
            paintRef.current(shown)
            if (shown !== goal && live) raf = requestAnimationFrame(tick); else last = 0
        }
        const kick = () => { 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 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, flow, between, back, left, right, topGap, edge, corner, travel, font.name, font.css, stripX, stripY, flowHeight])

    React.useEffect(() => { const im = pic.current; if (im && im.complete && im.naturalWidth) setReady(true) }, [image?.src])
    React.useEffect(() => { if (ready && !still) { measureRef.current(); window.dispatchEvent(new Event("scroll")) } }, [ready, still])

    const Tag: any = tag === "H2" ? "h2" : tag === "H3" ? "h3" : tag === "Paragraph" ? "p" : "h1"
    const fsGuess = `calc((100cqw - ${2 * edge}px - clamp(64px, 12.2cqw, 190px) - 2 * clamp(14px, 2.4cqw, 40px)) / ${(e1 + e2).toFixed(3)})`
    const fsGuessNarrow = `calc((100cqw - ${2 * edge}px) / ${Math.max(e2, e1 + 1.1).toFixed(3)})`
    const face = font.css
    const tall = Math.round(travel * 100)
    const vars: any = {
        "--hy-ink": ink, "--hy-mute": mute, "--hy-paper": paper, "--hy-on": onPhoto, "--hy-font": stack,
        "--hy-top": (flow ? 0 : topGap) + "px", "--hy-edge": edge + "px", "--hy-r": corner + "px", "--hy-flowh": String(flowHeight), "--hy-fs": fsGuess, "--hy-fsn": fsGuessNarrow, "--hy-travel": String(tall), "--hy-sx": stripX + "%", "--hy-sy": stripY + "%",
    }
    const img = image?.src ? <img ref={pic} loading={eager ? undefined : "lazy"} decoding="async" srcSet={image.srcSet} sizes={imageSizes} src={image.src} alt={image.alt || ""} onLoad={() => setReady(true)} /> : <div className="lyr-hy-ph" />
    if (still && flow && !lineOnly) {
        // at rest (canvas, reduced motion): the two words with the open picture between them
        return (
            <section className={"lyr-hy lyr-hy-flow" + (isStatic ? " lyr-hy-canvas" : "")} style={{ ...props.style, ...vars, ...(isStatic ? { height: between ? 420 : 620 } : {}) }}>
                <style dangerouslySetInnerHTML={{ __html: face + CSS }} />
                <div className="lyr-hy-rest-wrap"><div className="lyr-hy-rest">
                <Tag className="lyr-hy-sr">{`${left} ${right}`.trim()}</Tag>
                <span className="lyr-hy-w" aria-hidden="true">{left}</span>
                <div className="lyr-hy-win lyr-hy-ready">
                    {img}
                    <div className="lyr-hy-shade" style={title && title.trim() ? undefined : { display: "none" }} />
                    <div className="lyr-hy-land" style={title && title.trim() ? undefined : { display: "none" }}>
                        <div>
                            <p className="lyr-hy-title">{title}</p>
                            {summary && summary.trim() ? <p className="lyr-hy-sum">{summary}</p> : null}
                        </div>
                        {link ? <Link href={link}><a className="lyr-hy-link">{linkLabel}</a></Link> : null}
                    </div>
                </div>
                <span className="lyr-hy-w" aria-hidden="true">{right}</span>
                </div>{between && note && note.trim() ? <p className="lyr-hy-note">{note}</p> : null}</div>
            </section>
        )
    }
    return (
        <section ref={root as any} className={"lyr-hy" + (flow && !lineOnly ? " lyr-hy-flow" : "") + (between ? " lyr-hy-between" : "") + (lineOnly ? " lyr-hy-lineonly" : "") + (still ? " lyr-hy-still" : flow ? "" : " lyr-hy-tall") + ((ready || !hasPic) && !still ? " lyr-hy-pic" : "")} style={{ ...props.style, ...vars }}>
            <style dangerouslySetInnerHTML={{ __html: face + CSS }} />
            <div ref={pin} className="lyr-hy-pin">
                <div ref={stage} className="lyr-hy-stage" style={isStatic ? ({ "--hy-still-h": "760px" } as any) : still ? ({ "--hy-still-h": "min(100svh, 900px)" } as any) : undefined}>
                    <Tag className="lyr-hy-line" aria-label={`${left} ${right}`.trim()}>
                        <span ref={w1} className="lyr-hy-w" aria-hidden="true">{left}</span>
                        <span ref={slot} className="lyr-hy-slot" aria-hidden="true" style={still && image?.src ? { backgroundImage: `url(${small(image.src, image.srcSet)})` } : undefined} />
                        <span className="lyr-hy-row2" aria-hidden="true"><span ref={w2} className="lyr-hy-w">{right}</span></span>
                    </Tag>
                    <div className="lyr-hy-foot">
                        <p className="lyr-hy-intro">{lead} <span className="lyr-hy-m">{intro}</span></p>
                        <p className="lyr-hy-cap">{back && link ? <span><Link href={link}><a className="lyr-hy-caplink">{linkLabel}</a></Link></span> : <span>{[title, year].filter(x => x && x.trim()).join(", ")}</span>}<span className="lyr-hy-m">{still ? "" : hint}</span></p>
                    </div>
                </div>
                <div ref={win} className={"lyr-hy-win" + (ready || !hasPic || still ? " lyr-hy-ready" : "")} style={still ? undefined : { clipPath: "inset(50% 50% 50% 50%)" }}>
                    {img}
                    <div className="lyr-hy-shade" style={title && title.trim() ? undefined : { display: "none" }} />
                    <div className="lyr-hy-land" style={title && title.trim() ? undefined : { display: "none" }}>
                        <div>
                            <p className="lyr-hy-title">{title}</p>
                            {summary && summary.trim() ? <p className="lyr-hy-sum">{summary}</p> : null}
                        </div>
                        {link ? <Link href={link}><a className="lyr-hy-link">{linkLabel}</a></Link> : null}
                    </div>
                </div>
                {between && note && note.trim() ? <p ref={noteRef} className="lyr-hy-note">{note}</p> : null}
                <div ref={probe} className="lyr-hy-probe" aria-hidden="true">
                    <span style={{ fontVariationSettings: `"wdth" ${WIDE}` }}>{left}</span>
                    <span style={{ fontVariationSettings: `"wdth" ${WIDE}` }}>{right}</span>
                    <span style={{ fontVariationSettings: `"wdth" ${NARROW}` }}>{left}</span>
                </div>
            </div>
        </section>
    )
}

addPropertyControls(Hyphen, {
    mode: { type: ControlType.Enum, title: "Behaviour", defaultValue: "Pinned scene", options: ["Pinned scene", "Opens in view", "Line only"], optionTitles: ["Pinned scene", "Opens in view", "Line only"], description: "Pinned scene holds the screen while it opens (hero). Opens in view opens as it scrolls past. Line only is the two words and a closed hyphen (footer)." },
    opensTo: { type: ControlType.Enum, title: "Opens to", defaultValue: "Whole picture", options: ["Whole picture", "Between the words"], optionTitles: ["Whole picture", "Between the words"], description: "Opens in view: fill the block, or stop with the picture between the two words.", hidden: (p: Partial<Props>) => !String(p.mode || "").toLowerCase().includes("view") },
    reverse: { type: ControlType.Boolean, title: "Start open", defaultValue: false, description: "Pinned scene: begin with the whole picture and close down to the hyphen (a closing scene).", hidden: (p: Partial<Props>) => /view|line/.test(String(p.mode || "").toLowerCase()) },
    note: { type: ControlType.String, title: "Note", defaultValue: "", displayTextArea: true, description: "Between the words: a short line under the picture.", hidden: (p: Partial<Props>) => !String(p.opensTo || "").toLowerCase().includes("between") },
    left: { type: ControlType.String, title: "Left word", defaultValue: "Design", description: "The word before the hyphen." },
    right: { type: ControlType.String, title: "Right word", defaultValue: "studio", description: "The word after the hyphen." },
    image: { type: ControlType.ResponsiveImage, title: "Picture", description: "Shown through the hyphen. Bind it to a project's cover." },
    title: { type: ControlType.String, title: "Project title", defaultValue: "Harbour" },
    year: { type: ControlType.String, title: "Project year", defaultValue: "2026" },
    summary: { type: ControlType.String, title: "Project summary", defaultValue: "A booking flow for a small group of guest houses.", displayTextArea: true },
    link: { type: ControlType.Link, title: "Project link" },
    linkLabel: { type: ControlType.String, title: "Link label", defaultValue: "Read the case study" },
    lead: { type: ControlType.String, title: "Intro lead", defaultValue: "A small design studio.", description: "First words of the intro, in the ink colour." },
    intro: { type: ControlType.String, title: "Intro text", defaultValue: "We draw interfaces and write the code that ships them.", displayTextArea: true, description: "The rest of the intro, in the muted colour." },
    hint: { type: ControlType.String, title: "Scroll hint", defaultValue: "Scroll to open the hyphen" },
    tag: { type: ControlType.Enum, title: "Heading tag", defaultValue: "H1", options: ["H1", "H2", "H3", "Paragraph"], optionTitles: ["H1", "H2", "H3", "Paragraph"], description: "Use H1 once per page." },
    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 scale instead. Leave empty for Mona Sans from Google Fonts." },
    fontFamily: { type: ControlType.String, title: "Font family", defaultValue: "", placeholder: "Mona Sans", description: "Leave empty to use the font file, or Mona Sans. Type a family with a width axis that the site already loads to use that instead." },
    ink: { type: ControlType.Color, title: "Ink colour", defaultValue: "#121211" },
    mute: { type: ControlType.Color, title: "Muted colour", defaultValue: "#8B8A84" },
    paper: { type: ControlType.Color, title: "Paper colour", defaultValue: "#F5F4F0" },
    onPhoto: { type: ControlType.Color, title: "Text on picture", defaultValue: "#FFFFFF" },
    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" },
    travel: { type: ControlType.Number, title: "Scroll length", defaultValue: 1.7, min: 0.8, max: 4, step: 0.1, description: "Pinned scene: how many screens of scrolling it takes.", hidden: (p: Partial<Props>) => String(p.mode || "").toLowerCase().includes("view") },
    flowHeight: { type: ControlType.Number, title: "Open height", defaultValue: 82, min: 30, max: 100, step: 1, unit: "%", description: "Opens in view: height of the open picture, as a share of the screen.", hidden: (p: Partial<Props>) => !String(p.mode || "").toLowerCase().includes("view") },
    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." },
    eager: { type: ControlType.Boolean, title: "Load first", defaultValue: false, description: "Switch on for the picture at the top of a page. Everything else loads as it comes near." },
    imageSizes: { type: ControlType.String, title: "Image width", defaultValue: "100vw", description: "One CSS length, for example 100vw." },
})

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.