Layr Components

Components / Text

Widths

A two-tone statement whose words widen where you are reading: a swell of width travels from the first word to the last as you scroll.

Scroll insideFull screen
Props
48 px
28 px
2.4
#0E0E0D
#6E6E6B

Set in code: 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. Widths shows up in the Insert menu, and its controls appear in the right panel.
// Widths · Layr Components
//
// A two-tone statement whose words widen where you are reading. As the block is
// scrolled through the window, a swell of width travels from the first word to
// the last. The line breaks are set once, so nothing reflows while it moves.
// Paste into a new code file in Framer. Fixed width, auto height. The width axis
// needs a variable font: without Font family or Font file it loads Mona Sans from
// Google Fonts. With a font that has no width axis the text is set plainly.

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

// The effect is 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 REST = 88,
    PEAK = 125,
    NARROW = 62,
    BREAK = 100

const CSS = `
.lyr-wd{position:relative;width:100%;container-type:inline-size;color:var(--wd-ink);font-family:var(--wd-font);font-weight:400;-webkit-font-smoothing:antialiased;font-synthesis:none}
.lyr-wd-t{margin:0;font-size:var(--wd-fs-set,clamp(var(--wd-min),5.4cqw,var(--wd-max)));line-height:1.08;letter-spacing:-.035em;font-weight:400;font-variation-settings:"wdth" ${REST};text-wrap:balance}
.lyr-wd-m{color:var(--wd-mute)}
.lyr-wd-l{display:block;white-space:nowrap}
.lyr-wd-w{display:inline-block;font-variation-settings:"wdth" var(--w,${REST});white-space:pre}
.lyr-wd-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-wd-probe span{display:inline-block;white-space:pre}
.lyr-wd-plain .lyr-wd-t{font-variation-settings:"wdth" 100}
`

interface WidthsProps {
    lead: string
    rest: string
    tag: string
    largest: number
    smallest: number
    reach: number
    fontFile?: string
    fontFamily: string
    ink: string
    mute: string
    style?: CSSProperties
}
type Word = { text: string; muted: boolean; w: number[] }
type Geo = { W: number; fs: number; space: number; count: number }

// width of a word (per px of font size) at a given width setting, from three measured points
const at = (w: number[], v: number) => (v <= 100 ? w[0] + ((w[1] - w[0]) * (v - NARROW)) / (100 - NARROW) : w[1] + ((w[2] - w[1]) * (v - 100)) / (PEAK - 100))

/**
 * @framerIntrinsicWidth 900
 * @framerIntrinsicHeight 160
 *
 * @framerSupportedLayoutWidth fixed
 * @framerSupportedLayoutHeight auto
 * @framerDisableUnlink
 */
export default function Widths(props: WidthsProps) {
    const {
        lead = "We make careful things for small teams,",
        rest = "from the first sketch to the version people actually use.",
        tag = "H2",
        largest = 48,
        smallest = 28,
        reach = 2.4,
        fontFile,
        fontFamily = "",
        ink = "#121211",
        mute = "#8B8A84",
    } = props
    const isStatic = useIsStaticRenderer()
    const [reduced, setReduced] = useState(false)
    const [lines, setLines] = useState<Word[][] | null>(null)
    const root = useRef<HTMLDivElement>(null)
    const probe = useRef<HTMLDivElement>(null)
    const geo = useRef<Geo | null>(null)
    const paintRef = useRef<(p: number) => void>(() => {})
    const plain = isStatic || reduced
    const font = widthFont(fontFamily, fontFile)
    const stack = `"${font.name}", "Helvetica Neue", Arial, sans-serif`
    const words = useMemo(() => {
        const split = (s: string, muted: boolean) =>
            s
                .trim()
                .split(/\s+/)
                .filter(Boolean)
                .map((text) => ({ text, muted }))
        return [...split(lead || "", false), ...split(rest || "", true)]
    }, [lead, rest])

    paintRef.current = (p: number) => {
        const g = geo.current,
            r = root.current
        if (!g || !r || !lines) return
        const spans = r.querySelectorAll<HTMLElement>(".lyr-wd-w")
        const n = g.count,
            F = -reach + p * (n - 1 + 2 * reach)
        let j = 0
        for (const line of lines) {
            const swell = line.map((_, k) => Math.exp(-Math.pow(j + k - F, 2) / (2 * reach * reach)))
            // keep the line inside its column: shrink the swell if it would not fit
            let amt = 1
            for (let it = 0; it < 6; it++) {
                let sum = 0
                line.forEach((w, k) => {
                    sum += at(w.w, REST + (PEAK - REST) * swell[k] * amt) * g.fs + (k ? g.space : 0)
                })
                if (sum <= g.W) break
                amt *= 0.8
            }
            line.forEach((_, k) => {
                const el = spans[j + k]
                if (el) el.style.setProperty("--w", (REST + (PEAK - REST) * swell[k] * amt).toFixed(1))
            })
            j += line.length
        }
    }

    const measure = useCallback(() => {
        const r = root.current,
            pb = probe.current
        if (!r || !pb) return
        const W = r.clientWidth
        if (!W) return
        const fs = clamp(smallest + ((largest - smallest) * (W - 340)) / 540, smallest, largest)
        const kids = pb.children
        if (kids.length < words.length * 3) return
        const measured: Word[] = words.map((w, i) => ({ ...w, w: [0, 1, 2].map((k) => kids[i * 3 + k].getBoundingClientRect().width / 100) }))
        const space = (kids[words.length * 3] ? kids[words.length * 3].getBoundingClientRect().width / 100 : 0.2) * fs
        // no width axis: set the text plainly
        if (measured.length && measured[0].w[0] >= measured[0].w[2] * 0.97) {
            geo.current = null
            setLines(null)
            r.style.setProperty("--wd-fs-set", fs.toFixed(2) + "px")
            return
        }
        const out: Word[][] = [[]]
        let run = 0
        for (const w of measured) {
            const ww = at(w.w, BREAK) * fs
            if (out[out.length - 1].length && run + space + ww > W) {
                out.push([])
                run = 0
            }
            run += (out[out.length - 1].length ? space : 0) + ww
            out[out.length - 1].push(w)
        }
        r.style.setProperty("--wd-fs-set", fs.toFixed(2) + "px")
        geo.current = { W, fs, space, count: measured.length }
        setLines((prev) => (prev && prev.length === out.length && prev.every((l, i) => l.length === out[i].length && l.every((w, k) => w.text === out[i][k].text)) ? prev : out))
    }, [words, largest, smallest])

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

    useEffect(() => {
        if (plain) return
        const r = root.current
        if (!r) return
        measure()
        const ro = new ResizeObserver(measure)
        ro.observe(r)
        const fonts = (document as any).fonts
        if (fonts) {
            fonts.load(`400 100px "${font.name}"`).then(measure).catch(() => {})
            fonts.ready.then(measure)
            fonts.addEventListener?.("loadingdone", measure)
        }
        return () => {
            ro.disconnect()
            fonts?.removeEventListener?.("loadingdone", measure)
        }
    }, [plain, measure, font.name, font.css])

    useEffect(() => {
        if (plain || !lines) return
        const r = root.current
        if (!r) return
        let raf = 0,
            shown = -1,
            goal = 0,
            last = 0,
            live = true
        const read = () => {
            const b = r.getBoundingClientRect(),
                vh = window.innerHeight
            goal = clamp((vh * 0.92 - b.top) / (vh * 0.72 + b.height), 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 / 120))
            if (Math.abs(goal - shown) < 0.0006) shown = goal
            paintRef.current(shown)
            if (shown !== goal && live) raf = requestAnimationFrame(tick)
            else last = 0
        }
        const kick = () => {
            read()
            if (!raf) raf = requestAnimationFrame(tick)
        }
        kick()
        window.addEventListener("scroll", kick, { passive: true })
        return () => {
            live = false
            window.removeEventListener("scroll", kick)
            if (raf) cancelAnimationFrame(raf)
        }
    }, [plain, lines])

    const Tag = (tag === "H1" ? "h1" : tag === "H3" ? "h3" : tag === "Paragraph" ? "p" : "h2") as "h2"
    const vars = { "--wd-ink": ink, "--wd-mute": mute, "--wd-font": stack, "--wd-min": smallest + "px", "--wd-max": largest + "px" } as CSSProperties
    return (
        <div ref={root} className={"lyr-wd" + (plain ? " lyr-wd-plain" : "")} style={{ ...props.style, ...vars }}>
            <style dangerouslySetInnerHTML={{ __html: font.css + CSS }} />
            <Tag className="lyr-wd-t">
                {lines && !plain ? (
                    lines.map((line, i) => (
                        <span className="lyr-wd-l" key={i}>
                            {line.map((w, k) => (
                                <span key={k} className={"lyr-wd-w" + (w.muted ? " lyr-wd-m" : "")}>
                                    {(k ? " " : "") + w.text}
                                </span>
                            ))}
                            {i < lines.length - 1 ? <span> </span> : null}
                        </span>
                    ))
                ) : (
                    <>
                        {lead} <span className="lyr-wd-m">{rest}</span>
                    </>
                )}
            </Tag>
            <div ref={probe} className="lyr-wd-probe" aria-hidden="true">
                {words.map((w, i) =>
                    [NARROW, 100, PEAK].map((v) => (
                        <span key={i + "-" + v} style={{ fontVariationSettings: `"wdth" ${v}` }}>
                            {w.text}
                        </span>
                    ))
                )}
                <span style={{ fontVariationSettings: `"wdth" ${REST}` }}> </span>
            </div>
        </div>
    )
}

addPropertyControls(Widths, {
    lead: { type: ControlType.String, title: "Lead", defaultValue: "We make careful things for small teams,", displayTextArea: true, description: "The first part, in the ink colour." },
    rest: { type: ControlType.String, title: "Rest", defaultValue: "from the first sketch to the version people actually use.", displayTextArea: true, description: "The second part, in the muted colour." },
    tag: { type: ControlType.Enum, title: "Heading tag", defaultValue: "H2", options: ["H1", "H2", "H3", "Paragraph"], optionTitles: ["H1", "H2", "H3", "Paragraph"] },
    largest: { type: ControlType.Number, title: "Largest size", defaultValue: 48, min: 16, max: 160, step: 1, unit: "px", description: "Type size in a wide column." },
    smallest: { type: ControlType.Number, title: "Smallest size", defaultValue: 28, min: 12, max: 120, step: 1, unit: "px", description: "Type size on a phone." },
    reach: { type: ControlType.Number, title: "Reach", defaultValue: 2.4, min: 1, max: 6, step: 0.1, description: "How many words the swell of width covers." },
    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: "Muted colour", defaultValue: "#8B8A84" },
})

In this preview

No pictures needed.