A sticky section holds a single word while a manufactured scroll range plays a zoom out: the word starts several screens wide, showing a gradient through its letters, and settles into a normal headline position. No library — the progress is read from the sticky section's own rect, smoothed exponentially in wall-clock time, and written as one composited transform per changed frame.
A single self-contained brief: the technique, the constants and why they hold, the pitfalls, and the definition of done. Paste it and the component gets written in your own conventions instead of copied in ours.
# Build: Knockout Zoom PinA sticky section holds a single word while a manufactured scroll range plays a zoom out: the word starts several screens wide, showing a gradient through its letters, and settles into a normal headline position. No library — the progress is read from the sticky section's own rect, smoothed exponentially in wall-clock time, and written as one composited transform per changed frame.Build this as a React component in my project. Match my existing conventions —file layout, styling system, and naming — rather than the ones implied below.Everything after this line is specification, not code to copy verbatim.## What it doesA word the size of the screen shrinks into a headline as you scroll, with the scene showing through its letters.- Category: scroll- Runtime cost: dom- Dependencies: none beyond React## How it works1. The scroll range is manufactured from the scroller's own height: the section is sized `hostHeight * (1 + viewports)` in pixels and the sticky child is sized `hostHeight`, so the leftover is exactly the pin duration. Pixels, not `vh`, because inside a scrollable box viewport units describe the wrong element.2. Progress is `(hostTop - sectionTop) / (total - hostHeight)`, clamped. It comes from `getBoundingClientRect()` on the frame it is used, so it stays correct through resizes, font swaps and layout shifts with nothing to invalidate.3. The raw progress is smoothed with `damp(smoothed, target, smoothing, dt)` — `x + (t - x) * (1 - s^dt)` — so the settle takes the same wall-clock time at 60 and 144 Hz. The per-frame form (`x += (t - x) * 0.1`) converges more than twice as fast on a high-refresh display.4. Scale and position use different curves on purpose. Scale is `cover - (cover - 1) * easeOutCubic(L)`, front-loading the shrink; the recentre is `easeInCubic(L)`, which keeps the word centred until it is small enough for its position to mean anything.5. The transform is built as a string, rounded to 4 decimals of scale and 0.1 px of translate, and compared with the previous frame's string. Unchanged frames skip the DOM write entirely, which at both ends of the range is most of them.6. The letters are a window rather than a fill: `background-clip: text` with a transparent colour, so the gradient behind them scales with the word and the section reads as cut out of the page.7. The word is sized in `cqw` against a `container-type: inline-size` pin box, so the component is identical in a 320 px preview card and a full-bleed page without a single breakpoint.## Constants — use these valuesThese are tuned. Each one has a reason; do not round them off or replace themwith values that "look cleaner".- `cover = 7` Scale at progress 0 — enough that only the counters of one or two letters are on screen, so the opening frame reads as texture and the reveal of the word is the payoff. Below ~4 the word is legible immediately and there is nothing to reveal.- `viewports = 1.6` Scroll travel as a multiple of the scroller height. Under ~1 the zoom is over before it registers; past ~2.5 the pin outstays its welcome and users start scrolling hard to escape it.- `smoothing = 0.0067` Fraction of the gap remaining after one second — `e^-5`, i.e. the familiar `1 - exp(-5 * dt)` filter. It removes trackpad and wheel-step judder without the word feeling detached from the hand. At 0.05 the lag is visible as rubberiness.- `rest = -0.3` Final vertical position as a fraction of the pinned box: three tenths above centre, which is where a display headline sits when body copy follows it.- `veil window = progress 0.45 → 0.95` When the content behind fades up. Earlier and it competes with a word still too large to read past; later and it arrives after the motion has stopped, which reads as a second, unrelated event.- `write quantisation = 4 dp scale, 0.1 px translate` Below a tenth of a device pixel nothing changes on screen, so the comparison lets idle frames cost one string build and one comparison instead of a style write and a composite.## API```tsfunction KnockoutZoomPin(props: { children: ReactNode; behind?: ReactNode; cover?: number; viewports?: number; smoothing?: number; rest?: number; scroller?: RefObject<HTMLElement | null>; className?: string }): JSX.Element```- `children: ReactNode` The word. Kept on one line — it is a display element.- `behind: ReactNode` Content faded up behind the word as it settles.- `cover: number` — default `7` Scale at progress 0.- `viewports: number` — default `1.6` Scroll travel as a multiple of the scroller height.- `smoothing: number` — default `0.0067` Fraction of the gap left after one second.- `rest: number` — default `-0.3` Final vertical position as a fraction of the pinned box height.- `scroller: RefObject<HTMLElement | null>` Scroll container. Omit to auto-detect the nearest scrollable ancestor.- `className: string` Merged onto the outer section.## Reference implementation of the coreThis is the part that is hard to get right. Adapt it; do not treat the rest ofthe component as fixed.```tsxexport function KnockoutZoomPin({ children, behind, cover = 7, viewports = 1.6, smoothing = 0.0067, rest = -0.3, scroller, className,}: KnockoutZoomPinProps) { const section = useRef<HTMLElement>(null); const pin = useRef<HTMLDivElement>(null); const word = useRef<HTMLDivElement>(null); const veil = useRef<HTMLDivElement>(null); const smoothed = useRef(0); const lastTransform = useRef(''); const lastVeil = useRef(''); const lastHeights = useRef(0); const reduced = useReducedMotion(); useRaf((dt) => { const sectionEl = section.current; const pinEl = pin.current; const wordEl = word.current; if (!sectionEl || !pinEl || !wordEl) return; const host = scroller?.current ?? nearestScroller(sectionEl); const hostIsRoot = host === document.documentElement; const hostTop = hostIsRoot ? 0 : host.getBoundingClientRect().top; const hostHeight = hostIsRoot ? window.innerHeight : host.clientHeight; if (hostHeight === 0) return; // The pin range is manufactured from the scroller's own height, not from // `vh`: inside a box, viewport units describe the wrong thing entirely. const total = Math.round(hostHeight * (reduced ? 1 : 1 + viewports)); if (total !== lastHeights.current) { lastHeights.current = total; sectionEl.style.height = `${total}px`; pinEl.style.height = `${hostHeight}px`; } const rect = sectionEl.getBoundingClientRect(); const travel = total - hostHeight; const target = travel > 0 ? clamp((hostTop - rect.top) / travel, 0, 1) : 1; // `damp(x, t, s, dt)` is `x + (t - x) * (1 - s^dt)`; with s = e^-5 that is // exactly the `1 - exp(-5 * dt)` constant the technique is named for. smoothed.current = reduced ? target : damp(smoothed.current, target, smoothing, dt); const L = smoothed.current; // Ease-out on the scale: most of the shrink happens early, so the last // third of the scroll is a slow settle rather than a continued rush. const scale = cover - (cover - 1) * easeOutCubic(L); // Ease-IN on the recentre: while the word is huge its position is // meaningless, so it must not start travelling until it is nearly small. const y = lerp(0, rest * hostHeight, easeInCubic(L)); const transform = `translate3d(0, ${y.toFixed(1)}px, 0) scale(${scale.toFixed(4)})`; if (transform !== lastTransform.current) { lastTransform.current = transform; wordEl.style.transform = transform; } const veilEl = veil.current; if (veilEl) { const opacity = easeInCubic(clamp((L - 0.45) / 0.5, 0, 1)).toFixed(3); if (opacity !== lastVeil.current) { lastVeil.current = opacity; veilEl.style.opacity = opacity; } } });```## RequirementsThese are not optional. A version of this component that skips any of them isnot finished.**Cleanup.** `useRaf` unsubscribes from the shared loop on unmount; the only state is refs. The inline `height` values written to the section and the pin die with the elements, and no observers, listeners or timers are registered.**Reduced motion.** The manufactured range collapses to one screen and the smoothing is bypassed, so progress tracks scroll exactly. The word passes through at its resting scale with the caption already present — the section becomes an ordinary headline block instead of leaving a screen and a half of empty scrolling behind.**Frame-rate independence.** Any smoothing must be a function of delta time.`x += (target - x) * 0.1` converges twice as fast at 120 Hz as at 60 Hz; use`x = target + (x - target) * Math.pow(smoothing, dt)` instead, where`smoothing` is the fraction of the gap remaining after one second.**Strict mode.** Assume effects run twice in development. Setup must beidempotent and teardown must fully undo it.## Known pitfalls — avoid these specifically- Sizing the range in `vh` inside a scrollable container. The section is then sized against the window while progress is measured against the box, so the zoom finishes early, late, or never — and it looks correct only when the container happens to be full-height.- Smoothing with a per-frame constant. The zoom lags noticeably further behind the scroll on a 60 Hz display than on a 120 Hz one, and every attempt to tune it makes the other refresh rate worse.- Using the same easing for scale and for position. Move the word early and it slides around the screen while still too large to locate, which reads as a bug rather than as choreography.- Animating `font-size` or `width` instead of `transform: scale`. Every frame relayouts and re-rasterises glyphs at a new size — the single most reliable way to turn this effect into a slideshow on a laptop.- Writing the transform unconditionally each frame. At the top and bottom of the range the value is identical for dozens of frames, and each write still costs a style invalidation and a composite.- Caching the section rect. A font swap or an image landing above the section moves it, and a cached top makes the zoom finish at the wrong scroll position for the rest of the session.- Leaving the manufactured range in place under reduced motion. The user gets a screen and a half of scrolling where nothing happens at all; the height must collapse along with the animation.## Notes- The scroll container is auto-detected by walking ancestors for a scrollable `overflow-y`, so the component works unchanged on a normal page. Pass `scroller` when the container is ambiguous or created later.- The knockout is `background-clip: text`, not an SVG mask: no extra element, no viewBox to keep in sync, and the text stays selectable and readable to assistive technology.- A single word works best. Two lines re-wrap as the scale changes and the knockout stops reading as one shape.## Definition of doneThe word is unreadably large at the top of the section and lands as a normal headline exactly as the section releases. Scrolling backwards reverses it at the same rate. Stopping mid-zoom settles within about a quarter second with no drift. Resizing the window mid-zoom keeps the word at the same progress. With reduced motion on, the section is one screen tall and nothing scales.When you are finished, tell me which of the requirements above you implementedand where, so I can check them.
'use client';import { useRef, type ReactNode, type RefObject } from 'react';import { useRaf } from '@/hooks/use-raf';import { useReducedMotion } from '@/hooks/use-reduced-motion';import { clamp, damp, lerp } from '@/lib/damp';import { easeInCubic, easeOutCubic } from '@/lib/easing';import { cn } from '@/lib/utils';/* ========================================================================== Knockout Zoom Pin -------------------------------------------------------------------------- A word starts so large that only the counters of two letters are on screen, and shrinks — as you scroll, not on a timer — until it is a normal headline sitting in a normal layout. The letters are a window onto a gradient, so the "scene" zooms out with them. Two details separate this from the version that ships broken: 1. The smoothing is exponential in wall-clock time. `smoothed += (target - smoothed) * (1 - exp(-rate * dt))` converges at the same rate on a 60 Hz and a 144 Hz display. The per-frame form everyone writes instead converges more than twice as fast at 144 Hz. 2. Nothing is written to the DOM unless it changed. A scale rounded to four decimals and a translate rounded to a tenth of a pixel are identical for several frames at the top and bottom of the range, and re-writing them there is a layout invalidation for no visual result. ========================================================================== */export interface KnockoutZoomPinProps { /** The word. Kept on one line — this is a display element, not a paragraph. */ children: ReactNode; /** Content revealed behind the word as it shrinks. */ behind?: ReactNode; /** Scale at progress 0. */ cover?: number; /** Scroll travel of the pin as a multiple of the scroller's height. */ viewports?: number; /** Fraction of the gap left after one second. */ smoothing?: number; /** Final vertical position, as a fraction of the pinned box height. */ rest?: number; /** Scroll container. Omitted, the nearest scrollable ancestor is used. */ scroller?: RefObject<HTMLElement | null>; className?: string;}const SCROLLABLE = /(auto|scroll|overlay)/;function nearestScroller(el: HTMLElement): HTMLElement { let node = el.parentElement; while (node) { if (SCROLLABLE.test(getComputedStyle(node).overflowY)) return node; node = node.parentElement; } return document.documentElement;}// #region criticalexport function KnockoutZoomPin({ children, behind, cover = 7, viewports = 1.6, smoothing = 0.0067, rest = -0.3, scroller, className,}: KnockoutZoomPinProps) { const section = useRef<HTMLElement>(null); const pin = useRef<HTMLDivElement>(null); const word = useRef<HTMLDivElement>(null); const veil = useRef<HTMLDivElement>(null); const smoothed = useRef(0); const lastTransform = useRef(''); const lastVeil = useRef(''); const lastHeights = useRef(0); const reduced = useReducedMotion(); useRaf((dt) => { const sectionEl = section.current; const pinEl = pin.current; const wordEl = word.current; if (!sectionEl || !pinEl || !wordEl) return; const host = scroller?.current ?? nearestScroller(sectionEl); const hostIsRoot = host === document.documentElement; const hostTop = hostIsRoot ? 0 : host.getBoundingClientRect().top; const hostHeight = hostIsRoot ? window.innerHeight : host.clientHeight; if (hostHeight === 0) return; // The pin range is manufactured from the scroller's own height, not from // `vh`: inside a box, viewport units describe the wrong thing entirely. const total = Math.round(hostHeight * (reduced ? 1 : 1 + viewports)); if (total !== lastHeights.current) { lastHeights.current = total; sectionEl.style.height = `${total}px`; pinEl.style.height = `${hostHeight}px`; } const rect = sectionEl.getBoundingClientRect(); const travel = total - hostHeight; const target = travel > 0 ? clamp((hostTop - rect.top) / travel, 0, 1) : 1; // `damp(x, t, s, dt)` is `x + (t - x) * (1 - s^dt)`; with s = e^-5 that is // exactly the `1 - exp(-5 * dt)` constant the technique is named for. smoothed.current = reduced ? target : damp(smoothed.current, target, smoothing, dt); const L = smoothed.current; // Ease-out on the scale: most of the shrink happens early, so the last // third of the scroll is a slow settle rather than a continued rush. const scale = cover - (cover - 1) * easeOutCubic(L); // Ease-IN on the recentre: while the word is huge its position is // meaningless, so it must not start travelling until it is nearly small. const y = lerp(0, rest * hostHeight, easeInCubic(L)); const transform = `translate3d(0, ${y.toFixed(1)}px, 0) scale(${scale.toFixed(4)})`; if (transform !== lastTransform.current) { lastTransform.current = transform; wordEl.style.transform = transform; } const veilEl = veil.current; if (veilEl) { const opacity = easeInCubic(clamp((L - 0.45) / 0.5, 0, 1)).toFixed(3); if (opacity !== lastVeil.current) { lastVeil.current = opacity; veilEl.style.opacity = opacity; } } }); // #endregion critical return ( <section ref={section} className={cn('relative w-full', className)}> {/* A size container so the word can be sized in `cqw` — a fraction of the pinned box, not of the window, which is what makes the same component work in a 320 px preview and a full-bleed page. */} <div ref={pin} className="sticky top-0 w-full overflow-hidden" style={{ containerType: 'inline-size' }} > <div ref={veil} className="absolute inset-0 flex items-end justify-start p-6" style={{ opacity: 0 }} > {behind} </div> <div className="absolute inset-0 flex items-center justify-center"> <div ref={word} className="origin-center will-change-transform" style={{ transform: 'translate3d(0,0,0) scale(1)' }} > <span className="block whitespace-nowrap text-[13cqw] leading-none font-medium tracking-tight" style={{ // The letters are the window. Everything outside them is the // page, so the word reads as cut out of the surface rather // than drawn on top of it. backgroundImage: 'linear-gradient(120deg, color-mix(in oklab, var(--k-accent) 90%, white), color-mix(in oklab, var(--k-accent) 35%, transparent))', backgroundClip: 'text', WebkitBackgroundClip: 'text', color: 'transparent', }} > {children} </span> </div> </div> </div> </section> );}
/* ========================================================================== Frame-rate-independent interpolation. -------------------------------------------------------------------------- The everywhere-idiom `x += (target - x) * 0.1` is a bug: it converges twice as fast on a 120 Hz display as on a 60 Hz one, so the same code feels different on different hardware and visibly speeds up when the browser drops frames. `damp` fixes it by raising the retention factor to the power of elapsed time, which is the exact continuous form of the same curve. ========================================================================== *//** * Fraction of the remaining distance to close this frame. * * @param smoothing Fraction of the gap that REMAINS after one second. * Smaller is snappier. 0.001 ≈ very fast, 0.01 ≈ fast, 0.1 ≈ soft, * 0.5 ≈ slow drift. * @param dt Seconds elapsed since the previous frame. */export function dampFactor(smoothing: number, dt: number): number { return 1 - Math.pow(smoothing, dt);}/** Move `current` toward `target` at a rate independent of frame rate. */export function damp( current: number, target: number, smoothing: number, dt: number,): number { return current + (target - current) * dampFactor(smoothing, dt);}/** * The per-frame lerp constant an author measured at 60fps, converted into the * `smoothing` argument `damp` wants. Lets a source that says "0.1 per frame" * be ported without re-tuning by eye: * * damp(x, t, perFrameToSmoothing(0.1), dt) */export function perFrameToSmoothing(alpha: number, baseFps = 60): number { return Math.pow(1 - alpha, baseFps);}export function lerp(a: number, b: number, t: number): number { return a + (b - a) * t;}export function clamp(v: number, min: number, max: number): number { return v < min ? min : v > max ? max : v;}export function inverseLerp(a: number, b: number, v: number): number { return a === b ? 0 : (v - a) / (b - a);}export function mapRange( v: number, inMin: number, inMax: number, outMin: number, outMax: number, doClamp = true,): number { const t = inverseLerp(inMin, inMax, v); const r = lerp(outMin, outMax, doClamp ? clamp(t, 0, 1) : t); return r;}/** GLSL `smoothstep`, same semantics, for parity between JS and shader code. */export function smoothstep(edge0: number, edge1: number, x: number): number { const t = clamp(inverseLerp(edge0, edge1, x), 0, 1); return t * t * (3 - 2 * t);}/** * Gaussian falloff, `exp(-d²)`. Yields 1.000 / 0.368 / 0.018 / 0.0001 at * distances 0 / 1 / 2 / 3 — a continuous focus scalar with no thresholds and * no branches, which is why carousels that use it feel analogue rather than * stepped. */export function gaussian(distance: number, falloff = 1): number { const d = distance * falloff; return Math.exp(-(d * d));}
/* ========================================================================== Easing. -------------------------------------------------------------------------- Plain functions on [0,1] so a component can ease without pulling in GSAP, plus the matching `cubic-bezier()` strings for CSS. The two lists are the same curves, so a component can animate in JS and its reduced-motion fallback can animate in CSS without the timing changing character. ========================================================================== */export type EasingFn = (t: number) => number;export const linear: EasingFn = (t) => t;export const easeInQuad: EasingFn = (t) => t * t;export const easeOutQuad: EasingFn = (t) => 1 - (1 - t) * (1 - t);export const easeInOutQuad: EasingFn = (t) => t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;export const easeInCubic: EasingFn = (t) => t * t * t;export const easeOutCubic: EasingFn = (t) => 1 - Math.pow(1 - t, 3);export const easeInOutCubic: EasingFn = (t) => t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;export const easeInQuart: EasingFn = (t) => t * t * t * t;export const easeOutQuart: EasingFn = (t) => 1 - Math.pow(1 - t, 4);export const easeInOutQuart: EasingFn = (t) => t < 0.5 ? 8 * t * t * t * t : 1 - Math.pow(-2 * t + 2, 4) / 2;export const easeInExpo: EasingFn = (t) => (t === 0 ? 0 : Math.pow(2, 10 * t - 10));export const easeOutExpo: EasingFn = (t) => (t === 1 ? 1 : 1 - Math.pow(2, -10 * t));export const easeInOutExpo: EasingFn = (t) => t === 0 ? 0 : t === 1 ? 1 : t < 0.5 ? Math.pow(2, 20 * t - 10) / 2 : (2 - Math.pow(2, -20 * t + 10)) / 2;/** Overshoots past 1 then settles. `s` is the overshoot amount. */export const backOut = (s = 1.70158): EasingFn => { const c = s + 1; return (t) => 1 + c * Math.pow(t - 1, 3) + s * Math.pow(t - 1, 2);};export const elasticOut: EasingFn = (t) => { const c = (2 * Math.PI) / 3; return t === 0 ? 0 : t === 1 ? 1 : Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c) + 1;};/** CSS equivalents of the curves above, for reduced-motion fallbacks. */export const CSS_EASING = { outQuad: 'cubic-bezier(0.5, 1, 0.89, 1)', outCubic: 'cubic-bezier(0.33, 1, 0.68, 1)', outQuart: 'cubic-bezier(0.25, 1, 0.5, 1)', outExpo: 'cubic-bezier(0.16, 1, 0.3, 1)', inOutQuart: 'cubic-bezier(0.76, 0, 0.24, 1)', inOutExpo: 'cubic-bezier(0.87, 0, 0.13, 1)',} as const;
'use client';import { useEffect, useRef } from 'react';/* ========================================================================== One shared requestAnimationFrame loop. -------------------------------------------------------------------------- Forty components each calling requestAnimationFrame means forty callbacks the browser schedules separately and forty chances to read layout after a write. One loop, one ordered pass, one place to clamp delta time. Delta is clamped to 100 ms. Without that, returning to a backgrounded tab delivers a multi-second dt and every physics integration in the page explodes on the first frame back. ========================================================================== */export type RafCallback = (dt: number, elapsed: number) => void;const callbacks = new Set<RafCallback>();let frame = 0;let last = 0;let start = 0;const MAX_DT = 0.1;const DEFAULT_DT = 1 / 60;function tick(now: number) { frame = requestAnimationFrame(tick); const dt = last === 0 ? DEFAULT_DT : Math.min((now - last) / 1000, MAX_DT); last = now; const elapsed = (now - start) / 1000; // Copy: a callback may unsubscribe itself (or another) mid-iteration. for (const cb of Array.from(callbacks)) { if (callbacks.has(cb)) cb(dt, elapsed); }}function subscribe(cb: RafCallback) { callbacks.add(cb); if (frame === 0) { start = performance.now(); last = 0; frame = requestAnimationFrame(tick); } return () => { callbacks.delete(cb); if (callbacks.size === 0 && frame !== 0) { cancelAnimationFrame(frame); frame = 0; last = 0; } };}/** * Run `cb` every frame while `enabled`. * * The callback is held in a ref, so it does not need to be memoised and the * subscription is never torn down just because the closure changed identity. */export function useRaf(cb: RafCallback, enabled = true): void { const ref = useRef(cb); ref.current = cb; useEffect(() => { if (!enabled) return; return subscribe((dt, elapsed) => ref.current(dt, elapsed)); }, [enabled]);}
'use client';import { useSyncExternalStore } from 'react';/* ========================================================================== prefers-reduced-motion, SSR-safe. -------------------------------------------------------------------------- useSyncExternalStore rather than useState + useEffect: the server snapshot is an explicit `false`, so the markup React renders on the server and the markup it hydrates with agree, and the real value lands in the same commit as hydration instead of one frame later. A useEffect version flashes the animated variant before removing it — the exact thing the user asked not to see. Reduced motion is not "duration: 0". For most components here it means a different render: the final state, a static poster, or a paused frame. ========================================================================== */const QUERY = '(prefers-reduced-motion: reduce)';function subscribe(onChange: () => void) { if (typeof window === 'undefined' || !window.matchMedia) return () => {}; const mql = window.matchMedia(QUERY); mql.addEventListener('change', onChange); return () => mql.removeEventListener('change', onChange);}function getSnapshot() { if (typeof window === 'undefined' || !window.matchMedia) return false; return window.matchMedia(QUERY).matches;}function getServerSnapshot() { return false;}export function useReducedMotion(): boolean { return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);}/** Generic media-query hook, same SSR guarantees. */export function useMediaQuery(query: string): boolean { return useSyncExternalStore( (onChange) => { if (typeof window === 'undefined' || !window.matchMedia) return () => {}; const mql = window.matchMedia(query); mql.addEventListener('change', onChange); return () => mql.removeEventListener('change', onChange); }, () => { if (typeof window === 'undefined' || !window.matchMedia) return false; return window.matchMedia(query).matches; }, () => false, );}
05
API
Every parameter, its type and what happens when you change it.
The word. Kept on one line — it is a display element.
behind
ReactNode
—
Content faded up behind the word as it settles.
cover
number
= 7
Scale at progress 0.
viewports
number
= 1.6
Scroll travel as a multiple of the scroller height.
smoothing
number
= 0.0067
Fraction of the gap left after one second.
rest
number
= -0.3
Final vertical position as a fraction of the pinned box height.
scroller
RefObject<HTMLElement | null>
—
Scroll container. Omit to auto-detect the nearest scrollable ancestor.
className
string
—
Merged onto the outer section.
06
How it works
The technique in order, not the code in order.
01
The scroll range is manufactured from the scroller's own height: the section is sized hostHeight * (1 + viewports) in pixels and the sticky child is sized hostHeight, so the leftover is exactly the pin duration. Pixels, not vh, because inside a scrollable box viewport units describe the wrong element.
02
Progress is (hostTop - sectionTop) / (total - hostHeight), clamped. It comes from getBoundingClientRect() on the frame it is used, so it stays correct through resizes, font swaps and layout shifts with nothing to invalidate.
03
The raw progress is smoothed with damp(smoothed, target, smoothing, dt) — x + (t - x) * (1 - s^dt) — so the settle takes the same wall-clock time at 60 and 144 Hz. The per-frame form (x += (t - x) * 0.1) converges more than twice as fast on a high-refresh display.
04
Scale and position use different curves on purpose. Scale is cover - (cover - 1) * easeOutCubic(L), front-loading the shrink; the recentre is easeInCubic(L), which keeps the word centred until it is small enough for its position to mean anything.
05
The transform is built as a string, rounded to 4 decimals of scale and 0.1 px of translate, and compared with the previous frame's string. Unchanged frames skip the DOM write entirely, which at both ends of the range is most of them.
06
The letters are a window rather than a fill: background-clip: text with a transparent colour, so the gradient behind them scales with the word and the section reads as cut out of the page.
07
The word is sized in cqw against a container-type: inline-size pin box, so the component is identical in a 320 px preview card and a full-bleed page without a single breakpoint.
Load-bearing constants
Load-bearing constants
Constant
Value
Why this number
cover
= 7
Scale at progress 0 — enough that only the counters of one or two letters are on screen, so the opening frame reads as texture and the reveal of the word is the payoff. Below ~4 the word is legible immediately and there is nothing to reveal.
viewports
= 1.6
Scroll travel as a multiple of the scroller height. Under ~1 the zoom is over before it registers; past ~2.5 the pin outstays its welcome and users start scrolling hard to escape it.
smoothing
= 0.0067
Fraction of the gap remaining after one second — e^-5, i.e. the familiar 1 - exp(-5 * dt) filter. It removes trackpad and wheel-step judder without the word feeling detached from the hand. At 0.05 the lag is visible as rubberiness.
rest
= -0.3
Final vertical position as a fraction of the pinned box: three tenths above centre, which is where a display headline sits when body copy follows it.
veil window
= progress 0.45 → 0.95
When the content behind fades up. Earlier and it competes with a word still too large to read past; later and it arrives after the motion has stopped, which reads as a second, unrelated event.
write quantisation
= 4 dp scale, 0.1 px translate
Below a tenth of a device pixel nothing changes on screen, so the comparison lets idle frames cost one string build and one comparison instead of a style write and a composite.
07
Behaviour
What it does about motion sensitivity, teardown and being finished.
Reduced motion
The manufactured range collapses to one screen and the smoothing is bypassed, so progress tracks scroll exactly. The word passes through at its resting scale with the caption already present — the section becomes an ordinary headline block instead of leaving a screen and a half of empty scrolling behind.
Cleanup
useRaf unsubscribes from the shared loop on unmount; the only state is refs. The inline height values written to the section and the pin die with the elements, and no observers, listeners or timers are registered.
Definition of done
The word is unreadably large at the top of the section and lands as a normal headline exactly as the section releases. Scrolling backwards reverses it at the same rate. Stopping mid-zoom settles within about a quarter second with no drift. Resizing the window mid-zoom keeps the word at the same progress. With reduced motion on, the section is one screen tall and nothing scales.
Pitfalls
Sizing the range in vh inside a scrollable container. The section is then sized against the window while progress is measured against the box, so the zoom finishes early, late, or never — and it looks correct only when the container happens to be full-height.
Smoothing with a per-frame constant. The zoom lags noticeably further behind the scroll on a 60 Hz display than on a 120 Hz one, and every attempt to tune it makes the other refresh rate worse.
Using the same easing for scale and for position. Move the word early and it slides around the screen while still too large to locate, which reads as a bug rather than as choreography.
Animating font-size or width instead of transform: scale. Every frame relayouts and re-rasterises glyphs at a new size — the single most reliable way to turn this effect into a slideshow on a laptop.
Writing the transform unconditionally each frame. At the top and bottom of the range the value is identical for dozens of frames, and each write still costs a style invalidation and a composite.
Caching the section rect. A font swap or an image landing above the section moves it, and a cached top makes the zoom finish at the wrong scroll position for the rest of the session.
Leaving the manufactured range in place under reduced motion. The user gets a screen and a half of scrolling where nothing happens at all; the height must collapse along with the animation.
Notes
The scroll container is auto-detected by walking ancestors for a scrollable overflow-y, so the component works unchanged on a normal page. Pass scroller when the container is ambiguous or created later.
The knockout is background-clip: text, not an SVG mask: no extra element, no viewBox to keep in sync, and the text stays selectable and readable to assistive technology.
A single word works best. Two lines re-wrap as the scale changes and the knockout stops reading as one shape.