Layr Components

Components / Scroll scene

Hyphen Chain

A pinned chain of steps, Brief - Sketch - Prototype - Product: each hyphen opens onto a picture, closes, and the next word slides in.

Scroll insideFull screen
Props
#0E0E0D
#6E6E6B
72 px
32 px
10 px
100%

Set in code: Steps, Font file. The preview fills these in with demo content.

Scroll inside the preview.

In Framer, open Assets, add a new code file, paste this over everything in it and save. Hyphen Chain shows up in the Insert menu, and its controls appear in the right panel.
// Hyphen Chain · Layr Components
//
// A chain of steps written as "From - To": Brief - Sketch - Prototype - Product.
// The screen holds while it is scrolled: each hyphen opens onto its picture and
// closes again, then the right word slides left to start the next step.
// Paste into a new code file in Framer. Full width, auto height: it is taller than
// the screen and pins itself, so keep its parents' overflow visible. Without a
// picture a step shows a plain tone. The width axis needs a variable font: without
// Font family or Font file it loads Mona Sans from Google Fonts.

import { addPropertyControls, ControlType, useIsStaticRenderer } from "framer"
import { useEffect, useMemo, useRef, useState, type CSSProperties } from "react"

// The words narrow on the font's width axis ("wdth"). Without a font of your own, Mona Sans loads from
// Google Fonts: free (SIL Open Font License), with a width axis from 75 to 125.
const GOOGLE_FONT = "https://fonts.googleapis.com/css2?family=Mona+Sans:wdth,wght@75..125,200..900&display=swap"
const hashOf = (s: string) => {
    let h = 5381
    for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0
    return (h >>> 0).toString(36)
}
function widthFont(fontFamily: string, fontFile?: string) {
    const family = String(fontFamily || "").trim()
    if (family) return { name: family, css: "" }
    if (fontFile) {
        const name = `Lyr Width ${hashOf(fontFile)}`
        return { name, css: `@font-face{font-family:"${name}";src:url("${fontFile}") format("woff2");font-weight:100 900;font-stretch:25% 200%;font-display:swap}` }
    }
    return { name: "Mona Sans", css: `@import url("${GOOGLE_FONT}");` }
}

const TRACK = -0.03,
    WIDE = 125,
    NARROW = 62
const clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v))
const ease = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2)
const lerp = (a: number, b: number, t: number) => a + (b - a) * t
// Shown when a step has no picture yet: a quiet tone, so the hyphen still opens onto something.
const EMPTY = "linear-gradient(160deg, #cfcac0, #8f897e)"

type Pic = { src: string; srcSet?: string; alt?: string }
type Step = { from: string; to: string; image?: Pic; note?: string }
interface HyphenChainProps {
    steps: Step[]
    fontFile?: string
    fontFamily: string
    ink: string
    mute: string
    topGap: number
    edge: number
    corner: number
    step: number
    imageSizes: string
    style?: CSSProperties
}

const STEPS: Step[] = [
    { from: "Brief", to: "Sketch", note: "A page of goals and limits, agreed before anything is drawn." },
    { from: "Sketch", to: "Prototype", note: "Rough screens on paper, then in code, tried on real devices." },
    { from: "Prototype", to: "Product", note: "The tested version, built to last and handed over with notes." },
]

const CSS = `
.lyr-hch{position:relative;width:100%;color:var(--hch-ink);font-family:var(--hch-font);font-weight:400;-webkit-font-smoothing:antialiased;font-synthesis:none}
.lyr-hch *{box-sizing:border-box}
.lyr-hch-tall{position:relative;width:100%;height:calc(100vh + var(--hch-travel) * 1vh);height:calc(100svh + var(--hch-travel) * 1svh)}
.lyr-hch-pin{position:sticky;top:0;width:100%;height:100vh;height:100svh;overflow:hidden;overflow:clip}
.lyr-hch-w{position:absolute;left:0;top:0;margin:0;white-space:nowrap;font-size:var(--hch-fs,10vw);line-height:1;letter-spacing:${TRACK}em;font-variation-settings:"wdth" var(--w,${WIDE});will-change:transform;visibility:hidden}
.lyr-hch-bar{position:absolute;left:0;top:0;background:var(--hch-ink);border-radius:2px;will-change:transform;visibility:hidden}
.lyr-hch-win{position:absolute;left:0;top:0;overflow:hidden;border-radius:3px;will-change:transform;visibility:hidden}
.lyr-hch-win>img,.lyr-hch-win>.lyr-hch-ph{position:absolute;left:50%;top:50%;display:block;object-fit:cover;max-width:none;will-change:transform}
.lyr-hch-ph{background:${EMPTY}}
.lyr-hch-note{position:absolute;left:0;top:0;margin:0;max-width:min(30em,80vw);font-size:15px;line-height:1.45;letter-spacing:-.01em;color:var(--hch-mute);opacity:0;transition:opacity .45s ease;will-change:transform}
.lyr-hch-note[data-on="1"]{opacity:1}
.lyr-hch-sr{position:absolute;width:1px;height:1px;margin:-1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
.lyr-hch-probe{position:absolute;left:0;top:0;width:0;height:0;overflow:hidden;visibility:hidden;pointer-events:none;white-space:nowrap;font-size:100px;line-height:1;letter-spacing:${TRACK}em}
.lyr-hch-probe span{display:inline-block}
.lyr-hch-rest{container-type:inline-size;padding:0 var(--hch-edge) 28px}
.lyr-hch-rest:not(.lyr-hch-rest + .lyr-hch-rest){padding-top:48px}
.lyr-hch-rest-row{display:flex;align-items:center;gap:clamp(12px,2.4cqw,36px);font-size:clamp(34px,9.4cqw,150px);line-height:1;letter-spacing:${TRACK}em;white-space:nowrap;font-variation-settings:"wdth" ${NARROW}}
.lyr-hch-rest-pic{position:relative;flex:1 1 0;min-width:0;aspect-ratio:1.6;max-height:420px;border-radius:var(--hch-r);overflow:hidden;background:${EMPTY}}
.lyr-hch-rest-pic img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block}
.lyr-hch-rest-note{margin:12px 0 0;font-size:15px;line-height:1.45;letter-spacing:-.01em;color:var(--hch-mute);max-width:30em}
@container (max-width:719px){.lyr-hch-rest-row{flex-direction:column;align-items:flex-start;gap:.14em;font-size:clamp(34px,17cqw,120px)}.lyr-hch-rest-pic{flex:none;width:100%}}
`

/**
 * @framerIntrinsicWidth 1200
 * @framerIntrinsicHeight 800
 *
 * @framerSupportedLayoutWidth fixed
 * @framerSupportedLayoutHeight auto
 * @framerDisableUnlink
 */
export default function HyphenChain(props: HyphenChainProps) {
    const { steps = STEPS, fontFile, fontFamily = "", ink = "#121211", mute = "#6E6D68", topGap = 72, edge = 32, corner = 10, step = 100, imageSizes = "60vw" } = props
    const isStatic = useIsStaticRenderer()
    const [reduced, setReduced] = useState(false)
    const items = useMemo(() => (Array.isArray(steps) ? steps : []).filter((s) => s && String(s.from || "").trim() && String(s.to || "").trim()), [steps])
    const root = useRef<HTMLDivElement>(null)
    const tall = useRef<HTMLDivElement>(null)
    const pin = useRef<HTMLDivElement>(null)
    const probe = useRef<HTMLDivElement>(null)
    const geo = useRef<any>(null)
    const paintRef = useRef<(p: number) => void>(() => {})
    const measureRef = useRef<() => void>(() => {})
    const still = isStatic || reduced
    const font = widthFont(fontFamily, fontFile)
    const stack = `"${font.name}", "Helvetica Neue", Arial, sans-serif`

    useEffect(() => {
        if (typeof window === "undefined") return
        const mq = window.matchMedia("(prefers-reduced-motion: reduce)")
        const on = () => setReduced(mq.matches)
        on()
        mq.addEventListener?.("change", on)
        return () => mq.removeEventListener?.("change", on)
    }, [])

    const n = items.length
    const words = n ? [...items.map((it) => it.from), items[n - 1].to] : []

    // p runs 0 to 1 over the pinned scroll
    paintRef.current = (p: number) => {
        const g = geo.current,
            pn = pin.current
        if (!g || !pn) return
        const F = p * (g.n - 0.48)
        const i = Math.min(g.n - 1, Math.floor(F)),
            t = F - i
        const A = ease(clamp(t / 0.34, 0, 1)) - (i < g.n - 1 ? ease(clamp((t - 0.58) / 0.18, 0, 1)) : 0)
        const s = i < g.n - 1 ? ease(clamp((t - 0.76) / 0.24, 0, 1)) : 0
        const wd = lerp(WIDE, NARROW, A)
        const wEls = pn.querySelectorAll<HTMLElement>(".lyr-hch-w"),
            bEls = pn.querySelectorAll<HTMLElement>(".lyr-hch-bar"),
            winEls = pn.querySelectorAll<HTMLElement>(".lyr-hch-win"),
            nEls = pn.querySelectorAll<HTMLElement>(".lyr-hch-note")
        const width = (k: number, active: boolean) => g.fs * (active ? lerp(g.wide[k], g.thin[k], A) : g.wide[k])
        const xs: number[] = [],
            ys: number[] = [],
            ws: number[] = [],
            on: boolean[] = []
        const M = g.edge,
            W = g.W,
            far = 80
        for (let k = 0; k <= g.n; k++) {
            const active = (k === i || k === i + 1) && s === 0
            const w = width(k, active)
            ws[k] = w
            let x = 0,
                y = g.y1,
                vis = true
            if (g.stacked) {
                const push = A * (g.bh + g.noteH + g.gap * 0.8)
                if (k < i) {
                    vis = false
                } else if (k === i) {
                    x = M - s * (w + M + far)
                    y = g.y1
                } else if (k === i + 1) {
                    x = M
                    y = lerp(g.y2 + push, g.y1, s)
                } else if (k === i + 2) {
                    x = lerp(W + far, M, s)
                    y = g.y2
                } else vis = false
            } else {
                if (k < i) vis = false
                else if (k === i) x = M - s * (w + M + far)
                else if (k === i + 1) x = lerp(W - M - w, M, s)
                else if (k === i + 2) x = lerp(W + far, W - M - w, s)
                else vis = false
                if (k === i + 2 && s === 0) vis = false
            }
            xs[k] = x
            ys[k] = y
            on[k] = vis
            const el = wEls[k]
            if (!el) continue
            el.style.visibility = vis ? "visible" : "hidden"
            if (vis) {
                el.style.setProperty("--w", (active ? wd : WIDE).toFixed(1))
                el.style.transform = `translate3d(${x.toFixed(2)}px,${y.toFixed(2)}px,0)`
            }
        }
        // one hyphen between each pair of neighbouring words that are on screen
        for (let j = 0; j < g.n; j++) {
            const bar = bEls[j],
                win = winEls[j],
                nt = nEls[j]
            let show = on[j] && on[j + 1],
                x0 = 0,
                x1 = 0,
                cy = 0
            if (g.stacked) {
                // narrow screens: the hyphen ends the first line; it leaves with the old word and grows out of the new one
                if (j === i) {
                    show = on[j]
                    x0 = xs[j] + ws[j] + g.gap
                    x1 = lerp(W - M, x0, s)
                    cy = ys[j] + g.fs * 0.57
                } else if (j === i + 1 && s > 0) {
                    show = true
                    x0 = xs[j] + ws[j] + g.gap
                    x1 = lerp(x0, W - M, s)
                    cy = ys[j] + g.fs * 0.57
                } else show = false
            } else if (show) {
                x0 = xs[j] + ws[j] + g.gap
                x1 = xs[j + 1] - g.gap
                cy = g.y1 + g.fs * 0.57
            }
            if (show && x1 - x0 < 2) show = false
            const a = j === i ? A : 0
            let rx0 = x0,
                rx1 = x1,
                ry0 = cy - g.h0 / 2,
                ry1 = cy + g.h0 / 2
            if (show && a > 0) {
                if (g.stacked) {
                    const by0 = g.y1 + g.fs + g.gap * 0.6
                    rx0 = lerp(x0, M, a)
                    rx1 = lerp(x1, W - M, a)
                    ry0 = lerp(ry0, by0, a)
                    ry1 = ry0 + lerp(g.h0, g.bh, a)
                } else {
                    const h = lerp(g.h0, Math.min((x1 - x0) / 1.6, g.hmax), a)
                    ry0 = cy - h / 2
                    ry1 = cy + h / 2
                }
            }
            if (bar) {
                bar.style.visibility = show && !g.loaded[j] ? "visible" : "hidden"
                if (show) {
                    bar.style.transform = `translate3d(${x0.toFixed(2)}px,${(cy - g.h0 / 2).toFixed(2)}px,0)`
                    bar.style.width = (x1 - x0).toFixed(2) + "px"
                    bar.style.height = g.h0.toFixed(2) + "px"
                }
            }
            if (win) {
                win.style.visibility = show ? "visible" : "hidden"
                if (show) {
                    const w = rx1 - rx0,
                        h = ry1 - ry0
                    win.style.transform = `translate3d(${rx0.toFixed(2)}px,${ry0.toFixed(2)}px,0)`
                    win.style.width = w.toFixed(2) + "px"
                    win.style.height = h.toFixed(2) + "px"
                    win.style.borderRadius = lerp(Math.max(2, g.fs * 0.03), g.corner, clamp(a * 2, 0, 1)).toFixed(2) + "px"
                    const im = win.firstElementChild as HTMLElement | null
                    if (im) {
                        // closed, the hyphen shows the lower edge of the picture (clear of anything in its middle); opening, the picture slides home
                        const iw = Math.max(g.ow[j], w / 1.04),
                            ih = g.oh[j] * (iw / g.ow[j])
                        im.style.width = iw.toFixed(1) + "px"
                        im.style.height = ih.toFixed(1) + "px"
                        const my = Math.max(0, (ih * 1.08 - h) / 2)
                        im.style.transform = `translate(-50%,-50%) translate3d(0,${clamp(-ih * 0.37 * (1 - a), -my, my).toFixed(2)}px,0) scale(${(1.08 - 0.08 * a).toFixed(4)})`
                    }
                }
            }
            if (nt) {
                const open = j === i && A > 0.9
                if (nt.dataset.on !== (open ? "1" : "0")) nt.dataset.on = open ? "1" : "0"
                if (j === i) nt.style.transform = `translate3d(${rx0.toFixed(1)}px,${(ry1 + 12).toFixed(1)}px,0)`
            }
        }
    }

    measureRef.current = () => {
        const r = root.current,
            pn = pin.current,
            pb = probe.current
        if (!r || !pn || !pb) return
        const W = pn.clientWidth,
            H = pn.clientHeight
        if (!W || !H) return
        const kids = pb.children,
            count = words.length
        if (kids.length < count * 2) return
        const wide: number[] = [],
            thin: number[] = []
        for (let k = 0; k < count; k++) {
            wide[k] = kids[k * 2].getBoundingClientRect().width / 100
            thin[k] = kids[k * 2 + 1].getBoundingClientRect().width / 100
        }
        if (thin[0] > wide[0] * 0.96) for (let k = 0; k < count; k++) thin[k] = wide[k] * 0.62
        const stacked = W < 720 || (W < 1024 && W / Math.max(1, window.innerHeight) < 5 / 6)
        const gap = stacked ? Math.max(8, W * 0.03) : clamp(W * 0.024, 14, 40)
        const hw = clamp(W * 0.122, 64, 190)
        let pair = 0,
            longest = 0
        for (let k = 0; k < count - 1; k++) pair = Math.max(pair, wide[k] + wide[k + 1])
        for (let k = 0; k < count; k++) longest = Math.max(longest, wide[k])
        const notes = Array.from(pn.querySelectorAll<HTMLElement>(".lyr-hch-note"))
        const noteH = notes.length ? Math.max(...notes.map((e) => e.offsetHeight)) + 16 : 0
        let fs: number,
            y1: number,
            y2 = 0,
            bh = 0,
            hmax = 0
        if (stacked) {
            fs = Math.min((W - 2 * edge) / (longest + 1.1), (H - topGap) * 0.16, 200)
            bh = Math.max(60, Math.min((W - 2 * edge) / 1.5, H - topGap - 24 - fs * 2.3 - noteH - 40))
            y1 = topGap + Math.max(12, (H - topGap - (fs * 2.2 + bh + noteH + gap)) * 0.4)
            y2 = y1 + fs * 1.06
        } else {
            fs = Math.min((W - 2 * edge - hw - 2 * gap) / pair, (H - topGap) * 0.26, 260)
            hmax = (H - topGap) * 0.62 - noteH
            y1 = topGap + (H - topGap) * 0.47 - fs * 0.5
        }
        const ow: number[] = [],
            oh: number[] = [],
            loaded: boolean[] = []
        const wins = pn.querySelectorAll<HTMLElement>(".lyr-hch-win")
        for (let j = 0; j < count - 1; j++) {
            if (stacked) {
                ow[j] = W - 2 * edge
                oh[j] = bh
            } else {
                const sw = W - 2 * edge - 2 * gap - fs * (thin[j] + thin[j + 1])
                oh[j] = Math.min(sw / 1.6, hmax)
                ow[j] = sw
            }
            const im = wins[j] && (wins[j].firstElementChild as HTMLElement | null)
            if (im) {
                im.style.width = ow[j].toFixed(1) + "px"
                im.style.height = oh[j].toFixed(1) + "px"
                loaded[j] = im instanceof HTMLImageElement ? !!(im.complete && im.naturalWidth) : true
            } else loaded[j] = false
        }
        r.style.setProperty("--hch-fs", fs.toFixed(2) + "px")
        geo.current = { W, H, n: count - 1, wide, thin, stacked, gap, fs, edge, y1, y2, bh, hmax, noteH, h0: fs * 0.36, corner, ow, oh, loaded }
    }

    useEffect(() => {
        if (still || !n) return
        const tl = tall.current,
            pn = pin.current
        if (!tl || !pn) return
        let raf = 0,
            shown = -1,
            goal = 0,
            last = 0,
            live = true
        const read = () => {
            const tr = tl.getBoundingClientRect()
            const span = tr.height - pn.offsetHeight
            goal = span > 0 ? clamp(-tr.top / span, 0, 1) : 0
        }
        const tick = (t: number) => {
            raf = 0
            const dt = last ? Math.min(64, t - last) : 16
            last = t
            shown = shown < 0 ? goal : shown + (goal - shown) * (1 - Math.exp(-dt / 100))
            if (Math.abs(goal - shown) < 0.0004) shown = goal
            paintRef.current(shown)
            if (shown !== goal && live) raf = requestAnimationFrame(tick)
            else last = 0
        }
        const kick = () => {
            if (!pn.offsetParent && !pn.offsetHeight) return
            read()
            if (!raf) raf = requestAnimationFrame(tick)
        }
        const full = () => {
            measureRef.current()
            shown = -1
            kick()
        }
        full()
        const ro = new ResizeObserver(full)
        ro.observe(pn)
        window.addEventListener("scroll", kick, { passive: true })
        const imgs = Array.from(pn.querySelectorAll("img"))
        imgs.forEach((im) => im.addEventListener("load", full))
        const fonts = (document as any).fonts
        if (fonts) {
            fonts.load(`400 100px "${font.name}"`).then(full).catch(() => {})
            fonts.ready.then(full)
            fonts.addEventListener?.("loadingdone", full)
        }
        return () => {
            live = false
            ro.disconnect()
            window.removeEventListener("scroll", kick)
            imgs.forEach((im) => im.removeEventListener("load", full))
            fonts?.removeEventListener?.("loadingdone", full)
            if (raf) cancelAnimationFrame(raf)
        }
    }, [still, n, items, topGap, edge, corner, step, font.name, font.css])

    const vars = { "--hch-ink": ink, "--hch-mute": mute, "--hch-font": stack, "--hch-edge": edge + "px", "--hch-r": corner + "px", "--hch-travel": String(Math.round(Math.max(1, n) * step)) } as CSSProperties
    const sheet = <style dangerouslySetInnerHTML={{ __html: font.css + CSS }} />

    if (!n) return <div style={{ ...props.style, minHeight: 1 }} />
    if (still) {
        // at rest (canvas, reduced motion): each step is its two words with the open picture between them
        return (
            <div style={props.style}>
                {sheet}
                {items.map((it, k) => (
                    <div key={k} className="lyr-hch lyr-hch-rest" style={vars}>
                        <h3 className="lyr-hch-sr">{`${it.from} to ${it.to}`}</h3>
                        <div className="lyr-hch-rest-row" aria-hidden="true">
                            <span>{it.from}</span>
                            <span className="lyr-hch-rest-pic">{it.image?.src ? <img loading="lazy" decoding="async" srcSet={it.image.srcSet} sizes={imageSizes} src={it.image.src} alt={it.image.alt || ""} /> : null}</span>
                            <span>{it.to}</span>
                        </div>
                        {it.note && it.note.trim() ? <p className="lyr-hch-rest-note">{it.note}</p> : null}
                    </div>
                ))}
            </div>
        )
    }
    return (
        <div ref={root} className="lyr-hch" style={{ ...props.style, ...vars }}>
            {sheet}
            <ol className="lyr-hch-sr">
                {items.map((it, k) => (
                    <li key={k}>{`${it.from} to ${it.to}${it.note ? ": " + it.note : ""}`}</li>
                ))}
            </ol>
            <div ref={tall} className="lyr-hch-tall">
                <div ref={pin} className="lyr-hch-pin" aria-hidden="true">
                    {items.map((_, j) => (
                        <span key={"b" + j} className="lyr-hch-bar" />
                    ))}
                    {items.map((it, j) => (
                        <span key={"p" + j} className="lyr-hch-win">
                            {it.image?.src ? <img loading="lazy" decoding="async" srcSet={it.image.srcSet} sizes={imageSizes} src={it.image.src} alt="" /> : <span className="lyr-hch-ph" />}
                        </span>
                    ))}
                    {words.map((w, k) => (
                        <span key={"w" + k} className="lyr-hch-w">
                            {w}
                        </span>
                    ))}
                    {items.map((it, j) => (
                        <p key={"n" + j} className="lyr-hch-note">
                            {it.note}
                        </p>
                    ))}
                    <div ref={probe} className="lyr-hch-probe">
                        {words.map((w, k) =>
                            [WIDE, NARROW].map((v) => (
                                <span key={k + "-" + v} style={{ fontVariationSettings: `"wdth" ${v}` }}>
                                    {w}
                                </span>
                            ))
                        )}
                    </div>
                </div>
            </div>
        </div>
    )
}

addPropertyControls(HyphenChain, {
    steps: {
        type: ControlType.Array,
        title: "Steps",
        maxCount: 8,
        defaultValue: STEPS,
        control: {
            type: ControlType.Object,
            controls: {
                from: { type: ControlType.String, title: "From", description: "The word before the hyphen." },
                to: { type: ControlType.String, title: "To", description: "The word after the hyphen. The next step should start with the same word." },
                image: { type: ControlType.ResponsiveImage, title: "Picture", description: "Shown through the hyphen." },
                note: { type: ControlType.String, title: "Note", displayTextArea: true, description: "A short line under the open picture." },
            },
        },
        description: "Each step is From - To. The chain reads its words in order: the To of one step is the From of the next.",
    },
    fontFile: { type: ControlType.File, title: "Font file", allowedFileTypes: ["woff2"], description: "A variable font with a width axis. Leave empty for Mona Sans from Google Fonts." },
    fontFamily: { type: ControlType.String, title: "Font family", defaultValue: "", placeholder: "Mona Sans", description: "A family with a width axis that the site already loads. Leave empty to use the font file, or Mona Sans." },
    ink: { type: ControlType.Color, title: "Ink colour", defaultValue: "#121211" },
    mute: { type: ControlType.Color, title: "Note colour", defaultValue: "#6E6D68" },
    topGap: { type: ControlType.Number, title: "Top gap", defaultValue: 72, min: 0, max: 160, step: 1, unit: "px", description: "Room kept for the navigation." },
    edge: { type: ControlType.Number, title: "Side gap", defaultValue: 32, min: 0, max: 80, step: 1, unit: "px" },
    corner: { type: ControlType.Number, title: "Corner radius", defaultValue: 10, min: 0, max: 40, step: 1, unit: "px" },
    step: { type: ControlType.Number, title: "Scroll per step", defaultValue: 100, min: 40, max: 200, step: 5, unit: "%", description: "Share of a screen scrolled for each step." },
    imageSizes: { type: ControlType.String, title: "Image width", defaultValue: "60vw", description: "One CSS length, for example 60vw." },
})

In this preview

The photos are demo pictures shown on this site only; they are not in the files you copy. Set your own through the image props.