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