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