Layr Components

Components / Text

Focus Word

One line of type fitted edge to edge, soft until the whole line is in view, then pulled sharp. Made for a name at the foot of a page.

Scroll insideFull screen
Props
400
-0.05 em
#0E0E0D

Scroll to the foot of the preview.

In Framer, open Assets, add a new code file, paste this over everything in it and save. Focus Word shows up in the Insert menu, and its controls appear in the right panel.
// Focus Word · Layr Components
//
// One line of type fitted edge to edge. It stays out of focus until the whole
// line is in view, then pulls sharp. Made for a name at the foot of a page.
// Paste into a new code file in Framer. Set a width; the height follows the text.

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

interface FocusWordProps {
    text: string
    fontFamily: string
    weight: number
    tracking: number
    ink: string
    style?: CSSProperties
}

const clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v))

const SHEET = `
.lyr-fw{position:relative;width:100%;container-type:inline-size;overflow:hidden}
.lyr-fw-box{position:relative;font-size:var(--fs);line-height:.86;white-space:nowrap;padding:.06em 0 .1em}
.lyr-fw-s{display:block;margin:0;font:inherit;opacity:var(--k)}
.lyr-fw-b{position:absolute;left:0;top:0;padding:inherit;filter:blur(max(8px,1.6cqw));opacity:var(--bo);pointer-events:none}
.lyr-fw-probe{position:absolute;left:0;top:0;visibility:hidden;font-size:100px;white-space:nowrap;pointer-events:none}
`

/**
 * @framerIntrinsicWidth 1120
 * @framerIntrinsicHeight 180
 *
 * @framerSupportedLayoutWidth fixed
 * @framerSupportedLayoutHeight auto
 */
export default function FocusWord(props: FocusWordProps) {
    const { text = "Field Studio", fontFamily = "Inter", weight = 400, tracking = -0.05, ink = "#ECE9E2" } = props
    const isStatic = useIsStaticRenderer()
    const root = useRef<HTMLDivElement>(null)
    const probe = useRef<HTMLSpanElement>(null)

    // Fit the line to the width, and again when the typeface arrives.
    useEffect(() => {
        const host = root.current
        const p = probe.current
        if (!host || !p) return
        let alive = true
        const fit = () => {
            if (!alive) return
            const w = host.clientWidth
            const natural = p.getBoundingClientRect().width
            if (!w || !natural) return
            // Negative tracking also pulls in after the last letter; add it
            // back so the last letter is not clipped.
            const overhang = Math.abs(Math.min(0, tracking)) * 100
            host.style.setProperty("--fs", ((w / (natural + overhang)) * 100).toFixed(2) + "px")
        }
        fit()
        const ro = new ResizeObserver(fit)
        ro.observe(host)
        const fonts = (document as any).fonts
        if (fonts) {
            fonts.ready?.then(fit)
            fonts.load?.(`${weight} 100px "${fontFamily}"`).then(fit, () => {})
            fonts.addEventListener?.("loadingdone", fit)
        }
        return () => {
            alive = false
            ro.disconnect()
            fonts?.removeEventListener?.("loadingdone", fit)
        }
    }, [text, fontFamily, weight, tracking])

    // Pull focus once the whole line is in view. It sits at the foot of the
    // page, so it must be sharp as soon as it is fully visible.
    useEffect(() => {
        const host = root.current
        if (!host || isStatic) return
        if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return
        let raf = 0
        let last = 0
        let goal = 1
        let k = -1

        const read = () => {
            const r = host.getBoundingClientRect()
            const vh = window.innerHeight
            // 0 while only the top edge shows, 1 once the bottom edge is in.
            const f = r.height > 0 ? clamp((vh - r.top - r.height * 0.25) / (r.height * 0.75), 0, 1) : 1
            goal = f * f * (3 - 2 * f)
        }

        const tick = (time: number) => {
            raf = 0
            const dt = Math.min(64, Math.max(1, time - last))
            last = time
            k = k < 0 ? goal : k + (goal - k) * (1 - Math.exp(-dt / 160))
            if (Math.abs(goal - k) < 0.002) k = goal
            host.style.setProperty("--k", k.toFixed(3))
            host.style.setProperty("--bo", (1 - Math.pow(k, 4)).toFixed(3))
            if (k !== goal) raf = requestAnimationFrame(tick)
        }

        const wake = () => {
            read()
            if (!raf) {
                last = performance.now()
                raf = requestAnimationFrame(tick)
            }
        }

        wake()
        window.addEventListener("scroll", wake, { passive: true })
        window.addEventListener("resize", wake)
        return () => {
            window.removeEventListener("scroll", wake)
            window.removeEventListener("resize", wake)
            if (raf) cancelAnimationFrame(raf)
        }
    }, [isStatic])

    const font = `"${fontFamily}", "Inter", system-ui, sans-serif`
    const estimate = `calc(100cqw / ${Math.max(4, text.length * 0.5).toFixed(2)})`
    const vars = { "--fs": estimate, "--k": "1", "--bo": "0" } as CSSProperties
    const type = { fontFamily: font, fontWeight: weight, letterSpacing: `${tracking}em` } as CSSProperties

    return (
        <div ref={root} className="lyr-fw" style={{ ...props.style, ...vars, color: ink }}>
            <style dangerouslySetInnerHTML={{ __html: SHEET }} />
            <span ref={probe} className="lyr-fw-probe" style={type} aria-hidden="true">
                {text}
            </span>
            <div className="lyr-fw-box" style={type}>
                <span className="lyr-fw-b" aria-hidden="true">
                    {text}
                </span>
                <p className="lyr-fw-s">{text}</p>
            </div>
        </div>
    )
}

addPropertyControls(FocusWord, {
    text: { type: ControlType.String, title: "Text", defaultValue: "Field Studio" },
    fontFamily: {
        type: ControlType.String,
        title: "Font family",
        defaultValue: "Inter",
        description: "Use the family name of your text styles.",
    },
    weight: { type: ControlType.Number, title: "Weight", defaultValue: 400, min: 100, max: 900, step: 100 },
    tracking: {
        type: ControlType.Number,
        title: "Letter spacing",
        defaultValue: -0.05,
        min: -0.1,
        max: 0.1,
        step: 0.005,
        unit: "em",
    },
    ink: { type: ControlType.Color, title: "Text colour", defaultValue: "#ECE9E2" },
})

In this preview

No pictures needed.