Scroll Timeline
EffectsContinuous scroll-linked animation on the native Scroll-Driven Animations API, with a JS fallback that is numerically identical rather than merely similar. Both paths drive one registered custom property, `--st-progress`, so there is a single renderer and only the source of the number changes — the spec's four view-progress ranges and a real cubic-bézier solver make the fallback match to ~1e-6. Set `forceFallback` on one of two instances to check it yourself.
Driven by --st-progress, cover · ease-in-out
Driven by --st-progress, cover · ease-in-out
Driven by --st-progress, cover · ease-in-out
Driven by --st-progress, cover · ease-in-out
Driven by --st-progress, cover · ease-in-out
Driven by --st-progress, cover · ease-in-out
Scroll each panel. The two columns must stay locked together — the left one uses animation-timeline: view() where the browser has it, the right one is forced onto the rAF fallback. Both drive the same custom property, so there is only one thing rendering.
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
/**
* ScrollTimeline — continuous scroll-linked animation on the native
* Scroll-Driven Animations API, with a JS fallback that is not merely similar
* to it but numerically identical.
*
* Distinct from `scroll-story`, which is JS scrollytelling resolving to
* *discrete* steps. This is continuous, and where supported it runs off the
* main thread entirely.
*
* ## How the two paths are made indistinguishable
*
* The obvious design — declarative CSS keyframes on one path, JS writing
* `opacity`/`transform` on the other — gives you two renderers to keep in
* sync, and they drift the moment anyone edits one of them.
*
* Instead there is **one renderer and two drivers**. Both paths animate
* exactly one quantity: a registered custom property `--st-progress`, 0 → 1.
* Everything visual is ordinary CSS reading `var(--st-progress)`, so it is
* literally the same code in both cases. The only difference is where the
* number comes from:
*
* - **native** — `animation-timeline: view()` drives the `@property` through
* a two-keyframe animation, off the main thread.
* - **fallback** — a rAF loop writes the same property as an inline style.
*
* There is no second rendering path to keep honest, which is what makes
* "nobody can tell which one they got" a structural guarantee rather than a
* matter of careful matching.
*
* ## Reimplementing the spec's timing
*
* Two things still have to agree exactly, and both are spec work:
*
* **1 · The range boundaries.** Every named range reduces to the same shape —
* progress is `(start − top) / (start − end)`, where `top` is the subject's
* offset inside the scrollport and the two bounds come from the spec:
*
* | range | start (progress 0) | end (progress 1) |
* | --------- | -------------------- | -------------------- |
* | `cover` | `viewport` | `−size` |
* | `contain` | `max(viewport−size,0)` | `min(viewport−size,0)` |
* | `entry` | `viewport` | `viewport − size` |
* | `exit` | `0` | `−size` |
*
* `contain` is the one worth reading twice: it flips meaning depending on
* whether the subject fits in the scrollport. Shorter than the scrollport, it
* runs from "fully visible at the bottom" to "fully visible at the top";
* taller, it runs from "top-aligned" to "bottom-aligned" while the subject
* covers the scrollport. The `max`/`min` pair expresses both without a branch.
*
* **2 · The easing.** CSS applies `animation-timing-function` to map timeline
* progress onto effect progress, so the fallback has to apply the *same* curve
* to the same number. That means a real cubic-bézier solver — Newton–Raphson
* with a bisection fallback for the flat regions where the derivative
* collapses, which is the algorithm browsers themselves use. The four CSS
* keywords are their spec control points, so `ease-out` here and `ease-out`
* there are the same curve to ~1e-6.
*
* The scrollport is resolved the way `view()` resolves it — the nearest
* ancestor that actually scrolls, else the viewport — so both paths measure
* against the same box.
*
* ## Verifying it
*
* `forceFallback` exists precisely so the claim is checkable: set it on one of
* two identical instances and scroll. If the two ever separate, the fallback is
* wrong.
*
* Requires `@property --st-progress` and `@keyframes scroll-timeline-progress`,
* which ship in this item's registry `css`.
*/
export type ScrollRange = "cover" | "contain" | "entry" | "exit";
export type ScrollEasing =
| "linear"
| "ease"
| "ease-in"
| "ease-out"
| "ease-in-out"
| [number, number, number, number];
export type ScrollEffect =
"fade" | "fade-up" | "zoom" | "reveal" | "parallax" | "progress-bar" | "none";
/** The CSS spec's control points for the four easing keywords. */
const KEYWORD_CURVES: Record<string, [number, number, number, number]> = {
ease: [0.25, 0.1, 0.25, 1],
"ease-in": [0.42, 0, 1, 1],
"ease-out": [0, 0, 0.58, 1],
"ease-in-out": [0.42, 0, 0.58, 1],
};
/**
* A cubic-bézier easing solver — the same approach browsers use. Given the two
* control points, invert x(t) for the supplied progress, then evaluate y(t).
*
* Newton–Raphson converges in a handful of iterations almost everywhere, but
* its derivative collapses on the flat shoulders of curves like `ease-in`
* (`[0.42, 0, 1, 1]`), so it needs the bisection fallback to stay correct
* there. Without it, easings with a near-zero slope silently return the wrong
* value and the fallback path drifts from the native one exactly where the
* motion is slowest — the hardest place to spot it.
*/
export function cubicBezierEasing(
x1: number,
y1: number,
x2: number,
y2: number,
): (t: number) => number {
const A = (a: number, b: number) => 1 - 3 * b + 3 * a;
const B = (a: number, b: number) => 3 * b - 6 * a;
const C = (a: number) => 3 * a;
const calc = (t: number, a: number, b: number) =>
((A(a, b) * t + B(a, b)) * t + C(a)) * t;
const slope = (t: number, a: number, b: number) =>
3 * A(a, b) * t * t + 2 * B(a, b) * t + C(a);
return (x: number) => {
if (x <= 0) return 0;
if (x >= 1) return 1;
let t = x;
for (let i = 0; i < 8; i++) {
const err = calc(t, x1, x2) - x;
if (Math.abs(err) < 1e-7) return calc(t, y1, y2);
const d = slope(t, x1, x2);
if (Math.abs(d) < 1e-6) break;
t -= err / d;
}
// Bisection — slower, but it cannot fail where Newton's slope vanishes.
let lo = 0;
let hi = 1;
t = x;
while (lo < hi) {
const err = calc(t, x1, x2);
if (Math.abs(err - x) < 1e-7) break;
if (x > err) lo = t;
else hi = t;
const next = (lo + hi) / 2;
if (Math.abs(next - t) < 1e-9) break;
t = next;
}
return calc(t, y1, y2);
};
}
function easingFn(easing: ScrollEasing): (t: number) => number {
if (easing === "linear") return (t) => t;
const curve = Array.isArray(easing) ? easing : KEYWORD_CURVES[easing];
if (!curve) return (t) => t;
return cubicBezierEasing(curve[0], curve[1], curve[2], curve[3]);
}
function easingCss(easing: ScrollEasing): string {
return Array.isArray(easing) ? `cubic-bezier(${easing.join(",")})` : easing;
}
/**
* The two scroll offsets at which the named range reads 0 and 1. Straight from
* the spec's view-progress definitions — see the table in the file header.
*/
export function rangeBounds(
range: ScrollRange,
viewport: number,
size: number,
): [number, number] {
switch (range) {
case "cover":
return [viewport, -size];
case "contain":
return [Math.max(viewport - size, 0), Math.min(viewport - size, 0)];
case "entry":
return [viewport, viewport - size];
case "exit":
return [0, -size];
}
}
/** Raw 0→1 view progress, before easing. `top` is offset inside the scrollport. */
export function viewProgress(
range: ScrollRange,
top: number,
viewport: number,
size: number,
): number {
const [start, end] = rangeBounds(range, viewport, size);
const span = start - end;
// `contain` on a subject exactly as tall as the scrollport has zero span:
// the range is a single instant, so it is fully progressed the moment it
// is reached.
if (Math.abs(span) < 1e-6) return top <= start ? 1 : 0;
const p = (start - top) / span;
return p < 0 ? 0 : p > 1 ? 1 : p;
}
/** The nearest ancestor that actually scrolls — how `view()` picks its box. */
function scrollportOf(el: HTMLElement): HTMLElement | null {
let node = el.parentElement;
while (node) {
const overflow = getComputedStyle(node).overflowY;
if (
overflow === "auto" ||
overflow === "scroll" ||
overflow === "overlay"
) {
return node;
}
node = node.parentElement;
}
return null;
}
/** Every effect is ordinary CSS over `--st-progress`, so both paths share it. */
const EFFECTS: Record<ScrollEffect, React.CSSProperties> = {
none: {},
fade: { opacity: "var(--st-progress)" },
"fade-up": {
opacity: "var(--st-progress)",
transform: "translateY(calc((1 - var(--st-progress)) * 2rem))",
},
zoom: {
opacity: "var(--st-progress)",
transform: "scale(calc(0.88 + var(--st-progress) * 0.12))",
},
reveal: {
opacity: "var(--st-progress)",
filter: "blur(calc((1 - var(--st-progress)) * 8px))",
},
parallax: {
transform: "translateY(calc((0.5 - var(--st-progress)) * 4rem))",
},
"progress-bar": {
transform: "scaleX(var(--st-progress))",
transformOrigin: "left center",
},
};
const supportsNative = () =>
typeof CSS !== "undefined" &&
typeof CSS.supports === "function" &&
CSS.supports("animation-timeline: view()");
const noop = () => () => {};
export interface ScrollTimelineProps extends React.ComponentProps<"div"> {
/** Which slice of the element's pass through the scrollport drives 0→1. */
range?: ScrollRange;
/** Applied identically on both paths — see the solver in this file. */
easing?: ScrollEasing;
/** Built-in visual. `"none"` still publishes `--st-progress` for your own CSS. */
effect?: ScrollEffect;
/**
* Ignore the native API and use the rAF path. This is the verification hook:
* render two instances side by side, set it on one, and they must stay
* locked together at every scroll position.
*/
forceFallback?: boolean;
}
export function ScrollTimeline({
range = "cover",
easing = "linear",
effect = "fade-up",
forceFallback = false,
className,
style,
children,
...props
}: ScrollTimelineProps) {
const ref = React.useRef<HTMLDivElement>(null);
const portRef = React.useRef<HTMLElement | null>(null);
const lastRef = React.useRef("");
// Feature detection without an effect, so there is no post-mount flash and
// the server render stays deterministic.
const detected = React.useSyncExternalStore(
noop,
supportsNative,
() => false,
);
const reduce = React.useSyncExternalStore(
noop,
() =>
typeof matchMedia === "function" &&
matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
const native = detected && !forceFallback && !reduce;
const useFallback = !native && !reduce;
const ease = React.useMemo(() => easingFn(easing), [easing]);
React.useLayoutEffect(() => {
if (!useFallback) return;
const el = ref.current;
if (!el) return;
portRef.current = scrollportOf(el);
const compute = () => {
const port = portRef.current;
const rect = el.getBoundingClientRect();
let top: number;
let viewport: number;
if (port) {
const portRect = port.getBoundingClientRect();
top = rect.top - portRect.top;
viewport = port.clientHeight;
} else {
top = rect.top;
viewport = window.innerHeight || document.documentElement.clientHeight;
}
const value = ease(
viewProgress(range, top, viewport, rect.height),
).toFixed(4);
// Skip the write when nothing moved — style writes invalidate more than
// they look like they do.
if (value !== lastRef.current) {
lastRef.current = value;
el.style.setProperty("--st-progress", value);
}
};
// Before the first paint, so the element is never briefly at its initial
// value in the wrong place.
compute();
let raf = 0;
let running = false;
const tick = () => {
compute();
raf = requestAnimationFrame(tick);
};
const start = () => {
if (running) return;
running = true;
raf = requestAnimationFrame(tick);
};
const stop = () => {
if (!running) return;
running = false;
cancelAnimationFrame(raf);
// One last read so the resting value is the clamped 0 or 1, not
// whatever the final frame happened to catch.
compute();
};
// Every named range sits inside the intersecting window, so pausing on
// intersection can never truncate one. The margin just buys a frame.
const io = new IntersectionObserver(
(entries) => {
if (entries[entries.length - 1]?.isIntersecting) start();
else stop();
},
{ root: portRef.current, rootMargin: "64px" },
);
io.observe(el);
const onVisibility = () => {
if (document.hidden) stop();
};
document.addEventListener("visibilitychange", onVisibility);
return () => {
io.disconnect();
document.removeEventListener("visibilitychange", onVisibility);
cancelAnimationFrame(raf);
running = false;
};
}, [useFallback, range, ease]);
const timelineStyle = native
? ({
animationName: "scroll-timeline-progress",
animationTimingFunction: easingCss(easing),
animationFillMode: "both",
animationTimeline: "view()",
animationRange: range,
} as React.CSSProperties)
: undefined;
return (
<div
ref={ref}
data-slot="scroll-timeline"
data-driver={reduce ? "reduced" : native ? "native" : "fallback"}
style={{
// Server-rendered and pre-hydration this reads 1, so content is
// visible without JS; the layout effect corrects it before paint, and
// on the native path the animation (higher cascade origin than an
// inline style) overrides it outright.
["--st-progress" as string]: 1,
...EFFECTS[effect],
...timelineStyle,
...style,
}}
className={cn(className)}
{...props}
>
{children}
</div>
);
}Installation
npx shadcn@latest add https://ui.saumyarex.xyz/r/scroll-timeline.json1. Install dependencies
npm install clsx tailwind-merge2. Copy the source into your project
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
/**
* ScrollTimeline — continuous scroll-linked animation on the native
* Scroll-Driven Animations API, with a JS fallback that is not merely similar
* to it but numerically identical.
*
* Distinct from `scroll-story`, which is JS scrollytelling resolving to
* *discrete* steps. This is continuous, and where supported it runs off the
* main thread entirely.
*
* ## How the two paths are made indistinguishable
*
* The obvious design — declarative CSS keyframes on one path, JS writing
* `opacity`/`transform` on the other — gives you two renderers to keep in
* sync, and they drift the moment anyone edits one of them.
*
* Instead there is **one renderer and two drivers**. Both paths animate
* exactly one quantity: a registered custom property `--st-progress`, 0 → 1.
* Everything visual is ordinary CSS reading `var(--st-progress)`, so it is
* literally the same code in both cases. The only difference is where the
* number comes from:
*
* - **native** — `animation-timeline: view()` drives the `@property` through
* a two-keyframe animation, off the main thread.
* - **fallback** — a rAF loop writes the same property as an inline style.
*
* There is no second rendering path to keep honest, which is what makes
* "nobody can tell which one they got" a structural guarantee rather than a
* matter of careful matching.
*
* ## Reimplementing the spec's timing
*
* Two things still have to agree exactly, and both are spec work:
*
* **1 · The range boundaries.** Every named range reduces to the same shape —
* progress is `(start − top) / (start − end)`, where `top` is the subject's
* offset inside the scrollport and the two bounds come from the spec:
*
* | range | start (progress 0) | end (progress 1) |
* | --------- | -------------------- | -------------------- |
* | `cover` | `viewport` | `−size` |
* | `contain` | `max(viewport−size,0)` | `min(viewport−size,0)` |
* | `entry` | `viewport` | `viewport − size` |
* | `exit` | `0` | `−size` |
*
* `contain` is the one worth reading twice: it flips meaning depending on
* whether the subject fits in the scrollport. Shorter than the scrollport, it
* runs from "fully visible at the bottom" to "fully visible at the top";
* taller, it runs from "top-aligned" to "bottom-aligned" while the subject
* covers the scrollport. The `max`/`min` pair expresses both without a branch.
*
* **2 · The easing.** CSS applies `animation-timing-function` to map timeline
* progress onto effect progress, so the fallback has to apply the *same* curve
* to the same number. That means a real cubic-bézier solver — Newton–Raphson
* with a bisection fallback for the flat regions where the derivative
* collapses, which is the algorithm browsers themselves use. The four CSS
* keywords are their spec control points, so `ease-out` here and `ease-out`
* there are the same curve to ~1e-6.
*
* The scrollport is resolved the way `view()` resolves it — the nearest
* ancestor that actually scrolls, else the viewport — so both paths measure
* against the same box.
*
* ## Verifying it
*
* `forceFallback` exists precisely so the claim is checkable: set it on one of
* two identical instances and scroll. If the two ever separate, the fallback is
* wrong.
*
* Requires `@property --st-progress` and `@keyframes scroll-timeline-progress`,
* which ship in this item's registry `css`.
*/
export type ScrollRange = "cover" | "contain" | "entry" | "exit";
export type ScrollEasing =
| "linear"
| "ease"
| "ease-in"
| "ease-out"
| "ease-in-out"
| [number, number, number, number];
export type ScrollEffect =
"fade" | "fade-up" | "zoom" | "reveal" | "parallax" | "progress-bar" | "none";
/** The CSS spec's control points for the four easing keywords. */
const KEYWORD_CURVES: Record<string, [number, number, number, number]> = {
ease: [0.25, 0.1, 0.25, 1],
"ease-in": [0.42, 0, 1, 1],
"ease-out": [0, 0, 0.58, 1],
"ease-in-out": [0.42, 0, 0.58, 1],
};
/**
* A cubic-bézier easing solver — the same approach browsers use. Given the two
* control points, invert x(t) for the supplied progress, then evaluate y(t).
*
* Newton–Raphson converges in a handful of iterations almost everywhere, but
* its derivative collapses on the flat shoulders of curves like `ease-in`
* (`[0.42, 0, 1, 1]`), so it needs the bisection fallback to stay correct
* there. Without it, easings with a near-zero slope silently return the wrong
* value and the fallback path drifts from the native one exactly where the
* motion is slowest — the hardest place to spot it.
*/
export function cubicBezierEasing(
x1: number,
y1: number,
x2: number,
y2: number,
): (t: number) => number {
const A = (a: number, b: number) => 1 - 3 * b + 3 * a;
const B = (a: number, b: number) => 3 * b - 6 * a;
const C = (a: number) => 3 * a;
const calc = (t: number, a: number, b: number) =>
((A(a, b) * t + B(a, b)) * t + C(a)) * t;
const slope = (t: number, a: number, b: number) =>
3 * A(a, b) * t * t + 2 * B(a, b) * t + C(a);
return (x: number) => {
if (x <= 0) return 0;
if (x >= 1) return 1;
let t = x;
for (let i = 0; i < 8; i++) {
const err = calc(t, x1, x2) - x;
if (Math.abs(err) < 1e-7) return calc(t, y1, y2);
const d = slope(t, x1, x2);
if (Math.abs(d) < 1e-6) break;
t -= err / d;
}
// Bisection — slower, but it cannot fail where Newton's slope vanishes.
let lo = 0;
let hi = 1;
t = x;
while (lo < hi) {
const err = calc(t, x1, x2);
if (Math.abs(err - x) < 1e-7) break;
if (x > err) lo = t;
else hi = t;
const next = (lo + hi) / 2;
if (Math.abs(next - t) < 1e-9) break;
t = next;
}
return calc(t, y1, y2);
};
}
function easingFn(easing: ScrollEasing): (t: number) => number {
if (easing === "linear") return (t) => t;
const curve = Array.isArray(easing) ? easing : KEYWORD_CURVES[easing];
if (!curve) return (t) => t;
return cubicBezierEasing(curve[0], curve[1], curve[2], curve[3]);
}
function easingCss(easing: ScrollEasing): string {
return Array.isArray(easing) ? `cubic-bezier(${easing.join(",")})` : easing;
}
/**
* The two scroll offsets at which the named range reads 0 and 1. Straight from
* the spec's view-progress definitions — see the table in the file header.
*/
export function rangeBounds(
range: ScrollRange,
viewport: number,
size: number,
): [number, number] {
switch (range) {
case "cover":
return [viewport, -size];
case "contain":
return [Math.max(viewport - size, 0), Math.min(viewport - size, 0)];
case "entry":
return [viewport, viewport - size];
case "exit":
return [0, -size];
}
}
/** Raw 0→1 view progress, before easing. `top` is offset inside the scrollport. */
export function viewProgress(
range: ScrollRange,
top: number,
viewport: number,
size: number,
): number {
const [start, end] = rangeBounds(range, viewport, size);
const span = start - end;
// `contain` on a subject exactly as tall as the scrollport has zero span:
// the range is a single instant, so it is fully progressed the moment it
// is reached.
if (Math.abs(span) < 1e-6) return top <= start ? 1 : 0;
const p = (start - top) / span;
return p < 0 ? 0 : p > 1 ? 1 : p;
}
/** The nearest ancestor that actually scrolls — how `view()` picks its box. */
function scrollportOf(el: HTMLElement): HTMLElement | null {
let node = el.parentElement;
while (node) {
const overflow = getComputedStyle(node).overflowY;
if (
overflow === "auto" ||
overflow === "scroll" ||
overflow === "overlay"
) {
return node;
}
node = node.parentElement;
}
return null;
}
/** Every effect is ordinary CSS over `--st-progress`, so both paths share it. */
const EFFECTS: Record<ScrollEffect, React.CSSProperties> = {
none: {},
fade: { opacity: "var(--st-progress)" },
"fade-up": {
opacity: "var(--st-progress)",
transform: "translateY(calc((1 - var(--st-progress)) * 2rem))",
},
zoom: {
opacity: "var(--st-progress)",
transform: "scale(calc(0.88 + var(--st-progress) * 0.12))",
},
reveal: {
opacity: "var(--st-progress)",
filter: "blur(calc((1 - var(--st-progress)) * 8px))",
},
parallax: {
transform: "translateY(calc((0.5 - var(--st-progress)) * 4rem))",
},
"progress-bar": {
transform: "scaleX(var(--st-progress))",
transformOrigin: "left center",
},
};
const supportsNative = () =>
typeof CSS !== "undefined" &&
typeof CSS.supports === "function" &&
CSS.supports("animation-timeline: view()");
const noop = () => () => {};
export interface ScrollTimelineProps extends React.ComponentProps<"div"> {
/** Which slice of the element's pass through the scrollport drives 0→1. */
range?: ScrollRange;
/** Applied identically on both paths — see the solver in this file. */
easing?: ScrollEasing;
/** Built-in visual. `"none"` still publishes `--st-progress` for your own CSS. */
effect?: ScrollEffect;
/**
* Ignore the native API and use the rAF path. This is the verification hook:
* render two instances side by side, set it on one, and they must stay
* locked together at every scroll position.
*/
forceFallback?: boolean;
}
export function ScrollTimeline({
range = "cover",
easing = "linear",
effect = "fade-up",
forceFallback = false,
className,
style,
children,
...props
}: ScrollTimelineProps) {
const ref = React.useRef<HTMLDivElement>(null);
const portRef = React.useRef<HTMLElement | null>(null);
const lastRef = React.useRef("");
// Feature detection without an effect, so there is no post-mount flash and
// the server render stays deterministic.
const detected = React.useSyncExternalStore(
noop,
supportsNative,
() => false,
);
const reduce = React.useSyncExternalStore(
noop,
() =>
typeof matchMedia === "function" &&
matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
const native = detected && !forceFallback && !reduce;
const useFallback = !native && !reduce;
const ease = React.useMemo(() => easingFn(easing), [easing]);
React.useLayoutEffect(() => {
if (!useFallback) return;
const el = ref.current;
if (!el) return;
portRef.current = scrollportOf(el);
const compute = () => {
const port = portRef.current;
const rect = el.getBoundingClientRect();
let top: number;
let viewport: number;
if (port) {
const portRect = port.getBoundingClientRect();
top = rect.top - portRect.top;
viewport = port.clientHeight;
} else {
top = rect.top;
viewport = window.innerHeight || document.documentElement.clientHeight;
}
const value = ease(
viewProgress(range, top, viewport, rect.height),
).toFixed(4);
// Skip the write when nothing moved — style writes invalidate more than
// they look like they do.
if (value !== lastRef.current) {
lastRef.current = value;
el.style.setProperty("--st-progress", value);
}
};
// Before the first paint, so the element is never briefly at its initial
// value in the wrong place.
compute();
let raf = 0;
let running = false;
const tick = () => {
compute();
raf = requestAnimationFrame(tick);
};
const start = () => {
if (running) return;
running = true;
raf = requestAnimationFrame(tick);
};
const stop = () => {
if (!running) return;
running = false;
cancelAnimationFrame(raf);
// One last read so the resting value is the clamped 0 or 1, not
// whatever the final frame happened to catch.
compute();
};
// Every named range sits inside the intersecting window, so pausing on
// intersection can never truncate one. The margin just buys a frame.
const io = new IntersectionObserver(
(entries) => {
if (entries[entries.length - 1]?.isIntersecting) start();
else stop();
},
{ root: portRef.current, rootMargin: "64px" },
);
io.observe(el);
const onVisibility = () => {
if (document.hidden) stop();
};
document.addEventListener("visibilitychange", onVisibility);
return () => {
io.disconnect();
document.removeEventListener("visibilitychange", onVisibility);
cancelAnimationFrame(raf);
running = false;
};
}, [useFallback, range, ease]);
const timelineStyle = native
? ({
animationName: "scroll-timeline-progress",
animationTimingFunction: easingCss(easing),
animationFillMode: "both",
animationTimeline: "view()",
animationRange: range,
} as React.CSSProperties)
: undefined;
return (
<div
ref={ref}
data-slot="scroll-timeline"
data-driver={reduce ? "reduced" : native ? "native" : "fallback"}
style={{
// Server-rendered and pre-hydration this reads 1, so content is
// visible without JS; the layout effect corrects it before paint, and
// on the native path the animation (higher cascade origin than an
// inline style) overrides it outright.
["--st-progress" as string]: 1,
...EFFECTS[effect],
...timelineStyle,
...style,
}}
className={cn(className)}
{...props}
>
{children}
</div>
);
}import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
/** Merge conditional class names and resolve Tailwind conflicts. */
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/** Shared view-transition name so a card preview morphs into the detail
* page's preview. Must match on both ends; unique per registry entry. */
export function previewTransitionName(name: string) {
return `preview-${name}`;
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
range | "cover" | "contain" | "entry" | "exit" | "cover" | Which slice of the element's pass through the scrollport maps to 0→1, matching the CSS `animation-range` keywords. `contain` inverts meaning depending on whether the subject fits in the scrollport — shorter, it runs fully-visible-at-bottom → at-top; taller, top-aligned → bottom-aligned. |
easing | "linear" | "ease" | "ease-in" | "ease-out" | "ease-in-out" | [number, number, number, number] | "linear" | Applied identically on both paths: as `animation-timing-function` natively, and through a bundled cubic-bézier solver in the fallback. Custom control points are accepted as a 4-tuple. |
effect | "fade" | "fade-up" | "zoom" | "reveal" | "parallax" | "progress-bar" | "none" | "fade-up" | Built-in visual, expressed as plain CSS over `var(--st-progress)`. Use `"none"` to get the raw property and write your own — that is the primitive use. |
forceFallback | boolean | false | Ignore the native API and use the rAF path. Exists so the fallback is verifiable: render two instances side by side, set this on one, and they must stay locked at every scroll position. |
...props | React.ComponentProps<"div"> | — | Forwarded to the wrapper. `data-driver` reports which path is live — `native`, `fallback`, or `reduced`. |