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