Layr Components

Components / Text

Wide Title

A title whose letters widen under the pointer and settle back when it leaves; inside a link the whole row reacts.

Props
24 px
64 px
125
#0E0E0D

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

Move the pointer along a row.

In Framer, open Assets, add a new code file, paste this over everything in it and save. Wide Title shows up in the Insert menu, and its controls appear in the right panel.
// Wide Title · Layr Components
//
// A title whose letters widen under the pointer and settle back when it leaves.
// Inside a link, the whole row reacts. On touch screens, or with a font that has
// no width axis, it is an ordinary title.
// Paste into a new code file in Framer. Fixed width, auto height; put it inside a
// link (a list row, a card) so the whole row widens it. 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 { Fragment, useEffect, useMemo, useRef, 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 CSS = `
.lyr-wt{margin:0;color:var(--wt-ink);font-family:var(--wt-font);font-size:var(--wt-size);font-weight:400;line-height:1.2;letter-spacing:-.025em;-webkit-font-smoothing:antialiased;font-synthesis:none;text-wrap:balance}
.lyr-wt-w{display:inline-block;white-space:nowrap}
.lyr-wt-c{display:inline-block;font-variation-settings:"wdth" var(--w,100);white-space:pre}
`

interface WideTitleProps {
    text: string
    tag: string
    size: number
    reach: number
    widest: number
    fontFile?: string
    fontFamily: string
    ink: string
    style?: CSSProperties
}

/**
 * @framerIntrinsicWidth 520
 * @framerIntrinsicHeight 30
 *
 * @framerSupportedLayoutWidth fixed
 * @framerSupportedLayoutHeight auto
 * @framerDisableUnlink
 */
export default function WideTitle(props: WideTitleProps) {
    const { text = "A closer look at the details", tag = "H3", size = 24, reach = 64, widest = 125, fontFile, fontFamily = "", ink = "#121211" } = props
    const isStatic = useIsStaticRenderer()
    const root = useRef<HTMLHeadingElement>(null)
    const font = widthFont(fontFamily, fontFile)
    const stack = `"${font.name}", "Helvetica Neue", Arial, sans-serif`
    const words = useMemo(() => String(text || "").split(/\s+/).filter(Boolean), [text])

    useEffect(() => {
        if (isStatic) return
        const el = root.current
        if (!el) return
        if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return
        // the whole row reacts when the title sits inside a link
        const host: HTMLElement = (el.closest("a") as HTMLElement) || el
        const chars = Array.from(el.querySelectorAll<HTMLElement>(".lyr-wt-c"))
        const now = chars.map(() => 100),
            want = chars.map(() => 100)
        let centres: number[] = [],
            raf = 0,
            live = true
        const rest = () => {
            chars.forEach((c) => c.style.setProperty("--w", "100"))
            centres = chars.map((c) => {
                const b = c.getBoundingClientRect()
                return b.left + b.width / 2 + window.scrollX
            })
            now.forEach((v, i) => chars[i].style.setProperty("--w", v.toFixed(1)))
        }
        const tick = () => {
            raf = 0
            let moving = false
            for (let i = 0; i < chars.length; i++) {
                const d = want[i] - now[i]
                if (Math.abs(d) > 0.15) {
                    now[i] += d * 0.22
                    moving = true
                } else now[i] = want[i]
                chars[i].style.setProperty("--w", now[i].toFixed(1))
            }
            if (moving && live) raf = requestAnimationFrame(tick)
        }
        const kick = () => {
            if (!raf) raf = requestAnimationFrame(tick)
        }
        const move = (e: PointerEvent) => {
            if (e.pointerType && e.pointerType !== "mouse") return
            if (!centres.length) rest()
            const x = e.pageX
            for (let i = 0; i < chars.length; i++) {
                const d = x - centres[i]
                want[i] = 100 + (widest - 100) * Math.exp(-(d * d) / (2 * reach * reach))
            }
            kick()
        }
        const enter = (e: PointerEvent) => {
            if (e.pointerType && e.pointerType !== "mouse") return
            if (now.every((v) => v === 100)) rest()
        }
        const leave = () => {
            for (let i = 0; i < want.length; i++) want[i] = 100
            kick()
        }
        const resize = () => {
            centres = []
        }
        host.addEventListener("pointerenter", enter)
        host.addEventListener("pointermove", move)
        host.addEventListener("pointerleave", leave)
        window.addEventListener("resize", resize)
        return () => {
            live = false
            host.removeEventListener("pointerenter", enter)
            host.removeEventListener("pointermove", move)
            host.removeEventListener("pointerleave", leave)
            window.removeEventListener("resize", resize)
            if (raf) cancelAnimationFrame(raf)
        }
    }, [isStatic, text, reach, widest, fontFamily, fontFile, size])

    const Tag = (tag === "H2" ? "h2" : tag === "H4" ? "h4" : tag === "Paragraph" ? "p" : "h3") as "h3"
    const vars = { "--wt-ink": ink, "--wt-font": stack, "--wt-size": size + "px" } as CSSProperties
    return (
        <>
            <style dangerouslySetInnerHTML={{ __html: font.css + CSS }} />
            <Tag ref={root} className="lyr-wt" style={{ ...props.style, ...vars }} aria-label={text}>
                {words.map((w, i) => (
                    <Fragment key={i}>
                        <span className="lyr-wt-w" aria-hidden="true">
                            {Array.from(w).map((ch, k) => (
                                <span className="lyr-wt-c" key={k}>
                                    {ch}
                                </span>
                            ))}
                        </span>
                        {i < words.length - 1 ? " " : null}
                    </Fragment>
                ))}
            </Tag>
        </>
    )
}

addPropertyControls(WideTitle, {
    text: { type: ControlType.String, title: "Text", defaultValue: "A closer look at the details", description: "Bind it to a title in the CMS." },
    tag: { type: ControlType.Enum, title: "Heading tag", defaultValue: "H3", options: ["H2", "H3", "H4", "Paragraph"], optionTitles: ["H2", "H3", "H4", "Paragraph"] },
    size: { type: ControlType.Number, title: "Text size", defaultValue: 24, min: 12, max: 96, step: 1, unit: "px", description: "Set it per breakpoint." },
    reach: { type: ControlType.Number, title: "Reach", defaultValue: 64, min: 20, max: 200, step: 2, unit: "px", description: "How far from the cursor the letters respond." },
    widest: { type: ControlType.Number, title: "Widest", defaultValue: 125, min: 100, max: 125, step: 1, description: "Width setting right under the cursor. 100 is normal." },
    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" },
})

In this preview

No pictures needed.