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