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
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.