a stage pinned while the page scrolls past it, played act by act in CSS
npx shadcn add @ag/scroll-stageAlready installed? Preview an update
npx shadcn add @ag/scroll-stage --dry-runYour installed source stays unchanged until you update it. How updates work
- Its demo's act and beat pickers are a
toggle-group(dots for the acts), where loose buttons stood.
the demo above, verbatim
"use client"
import { Sample } from "@/app/samples"
import {
Act,
ScrollStage,
useScrollStage,
} from "@/registry/base-nova/ui/scroll-stage"
import { useScrollStageTimeline } from "@/registry/base-nova/ui/scroll-stage-timeline"
import {
ToggleGroup,
ToggleGroupItem,
} from "@/registry/base-nova/ui/toggle-group"
// #region timeline
const BEATS = [
{ id: "write", span: 1 },
{ id: "read", span: 1 },
{ id: "continue", span: 0.5 },
]
/** The same framework can drive non-CSS renderers, with reversible reading beats. */
export function TimelineDemo() {
return (
<ScrollStage
acts={3}
pace="90svh"
stacked={<p>Write → read → continue.</p>}
>
<TimelineScene />
</ScrollStage>
)
}
function TimelineScene() {
const timeline = useScrollStageTimeline(BEATS, { top: 80, bottom: 80 })
const progress = timeline.progress[timeline.active]
if (progress === undefined)
throw new Error(`no progress for beat ${timeline.active}`)
return (
<div className="w-full space-y-8 p-8">
<h3 className="text-xl font-semibold">write. read. continue.</h3>
<p className="font-mono text-4xl">{Math.round(progress * 100)}%</p>
{/* One beat of several, the one on stage lit: a segmented control. */}
<ToggleGroup
aria-label="timeline beats"
value={String(timeline.active)}
onValueChange={(v) => timeline.seek(Number(v))}
>
{BEATS.map((beat, i) => (
<ToggleGroupItem key={beat.id} value={String(i)}>
{i + 1}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
)
}
// #endregion
// #region acts
const ACTS = [
{
title: "one track",
body: "The section is tall. Its stage is sticky. Scroll drives --stage-p from 0 to 1 across the dwell, in CSS, no React per frame.",
},
{
title: "acts",
body: "Each <Act> gets --act-p (its own window) and --act-on (1 while on stage). Opacity, transform, clip: all CSS.",
},
{
title: "state, rarely",
body: "useAct() changes once per checkpoint. Mount a phone when its act begins; never sample scroll.",
},
]
function Words() {
const { active, seek } = useScrollStage()
return (
<div className="space-y-8">
{ACTS.map((a, i) => (
<div
key={a.title}
className="space-y-2 transition-opacity duration-500"
style={{ opacity: i === active ? 1 : 0.35 }}
>
<div className="font-mono text-xs text-muted-foreground">
0{i + 1}
</div>
<h3 className="text-lg font-semibold tracking-tight">{a.title}</h3>
<p className="text-[0.9375rem] leading-relaxed text-foreground/80">
{a.body}
</p>
</div>
))}
{/* One act of several, the one on stage lit: a dot each, the chosen one
stretched, each said by its act's name. */}
<ToggleGroup
variant="dots"
aria-label="story acts"
value={String(active)}
onValueChange={(v) => seek(Number(v))}
>
{ACTS.map((act, index) => (
<ToggleGroupItem key={act.title} value={String(index)}>
go to {act.title}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
)
}
// #endregion
export default function Demo() {
return (
<div className="space-y-16">
<Sample
name="acts"
title="acts, in css"
description="the stage pins while the section scrolls; each act reads its own progress"
with="acts css"
presentation
>
<CssDemo />
</Sample>
<Sample
name="timeline"
title="a timeline of beats"
description="the same stage driving a renderer that is not CSS, with reversible beats"
with="timeline"
presentation
>
<TimelineDemo />
</Sample>
</div>
)
}
// #region css
function CssDemo() {
return (
<ScrollStage
acts={ACTS.length}
pace="90svh"
stacked={
<div className="space-y-12">
{ACTS.map((a, i) => (
<div key={a.title} className="space-y-2">
<h3 className="text-lg font-semibold tracking-tight">
{a.title}
</h3>
<Stage index={i} />
<p className="text-[0.9375rem] leading-relaxed text-foreground/80">
{a.body}
</p>
</div>
))}
</div>
}
>
<div className="grid w-full items-center gap-10 lg:grid-cols-2">
<div className="relative aspect-[4/3]">
{ACTS.map((a, i) => (
<Act
key={a.title}
index={i}
className="absolute inset-0 transition-none"
style={{
opacity: "var(--act-on)",
transform: "translateY(calc((1 - var(--act-p)) * 24px))",
}}
>
<Stage index={i} />
</Act>
))}
</div>
<Words />
</div>
</ScrollStage>
)
}
function Stage({ index }: { index: number }) {
return (
<div className="flex h-full min-h-48 w-full items-center justify-center rounded-2xl border border-border bg-sidebar font-mono text-6xl text-foreground/30">
{index + 1}
</div>
)
}
// #endregionthe source, every file an install writes
"use client"
// The one pinned-stage primitive. A tall track, a sticky stage, and a number:
// --stage-p, 0..1 over the sticky dwell, driven by CSS (animation-timeline: view())
// so scrolling never touches React. `acts` divides the travel into checkpoints; the
// active act is the only React state, and it changes once per checkpoint, never per
// frame. <Act> exposes --act-p (0..1 within its own window) and --act-on (1 while on
// stage) as CSS, for opacity, transform, clip. State-shaped consumers (mount a phone
// when its act begins) read useAct().
// Renderers needing continuous JS progress opt into useScrollStageTimeline from
// scroll-stage-timeline. Its weighted beats replace these equal checkpoints.
//
// Desktop pinning is the default. `pin="all"` also enables phone-sized scenes;
// short landscape windows and reduced motion use the natural-height alternative.
import * as React from "react"
import "./scroll-stage.css"
const StageContext = React.createContext<{
acts: number
active: number
seek: (index: number) => boolean
track: React.RefObject<HTMLElement | null>
pinnedQuery: string
} | null>(null)
/** Navigate the pinned story using the same act boundaries as useAct().
* Returns false in stacked layouts, where the page provides its own navigation. */
export function useScrollStage() {
const ctx = React.useContext(StageContext)
if (!ctx) throw new Error("useScrollStage() outside <ScrollStage>")
return ctx
}
/** The act on stage (0-based). Only valid inside <ScrollStage>. */
export function useAct(): number {
const ctx = React.useContext(StageContext)
if (!ctx) throw new Error("useAct() outside <ScrollStage>")
return ctx.active
}
export interface ScrollStageProps
extends Omit<React.HTMLAttributes<HTMLElement>, "children"> {
/** Checkpoints along the track. Act i is on stage past i/acts of the travel. */
acts: number
/** Scroll per act, a CSS length. Default 100svh. */
pace?: string
/** Extra travel after the last act, so it dwells. Default 40svh. */
tail?: string
/** Enable phone-sized scenes too; windows shorter than 36rem remain unpinned. */
pin?: "desktop" | "all"
/** Fires once per checkpoint change, never per frame. */
onAct?: (act: number) => void
/** The alternative layout below lg / under reduced motion (the acts stacked). */
stacked?: React.ReactNode
/** The stage: what stays pinned. */
children: React.ReactNode
as?: "section" | "article" | "div"
}
const PINNED = "(min-width: 64rem) and (prefers-reduced-motion: no-preference)"
export function ScrollStage({
acts,
pace,
tail,
pin = "desktop",
onAct,
stacked,
children,
as = "section",
className,
style,
...rest
}: ScrollStageProps) {
if (!Number.isInteger(acts) || acts < 1)
throw new Error(`ScrollStage: acts must be a positive integer, got ${acts}`)
const track = React.useRef<HTMLElement>(null)
const pinnedQuery =
pin === "all"
? "(min-height: 36rem) and (prefers-reduced-motion: no-preference)"
: PINNED
const paceEl = React.useRef<HTMLDivElement>(null)
const [active, setActive] = React.useState(0)
const [engine, setEngine] = React.useState<"css" | "js">("css")
// The server cannot know the viewport, so it renders the stage AND the stacked
// alternative and CSS shows one. Once mounted, only the live one stays mounted: a
// hidden stage's phones, observers and timers cost as much as the visible one's.
const [layout, setLayout] = React.useState<"both" | "pinned" | "stacked">(
"both",
)
const onActRef = React.useRef(onAct)
onActRef.current = onAct
React.useEffect(() => {
const el = track.current
const paceNode = paceEl.current
if (!el || !paceNode) return
const css = CSS.supports("animation-timeline: view()")
setEngine(css ? "css" : "js")
const pinned = window.matchMedia(pinnedQuery)
let frame = 0
let current = -1
const sample = () => {
frame = 0
const rect = el.getBoundingClientRect()
const pacePx = paceNode.offsetHeight
let act: number
if (!pinned.matches || pacePx === 0) {
// Unpinned, or a collapsed track (pace 0): the completed state.
act = acts - 1
if (!css) el.style.removeProperty("--stage-p")
} else {
// ONE RULE for JS and CSS: act i is on stage past i/acts of the TRAVEL (the
// track less one viewport), the same number `--stage-p` reports. Counting
// paces here while the CSS counted travel lit an act's words before the stage
// switched to it whenever a tail lengthened the track.
const travel = rect.height - window.innerHeight
const p = travel > 0 ? -rect.top / travel : rect.top < 0 ? 1 : 0
const clamped = Math.min(1, Math.max(0, p))
act = Math.min(acts - 1, Math.max(0, Math.floor(clamped * acts)))
if (!css) el.style.setProperty("--stage-p", String(clamped))
}
if (act !== current) {
current = act
setActive(act)
onActRef.current?.(act)
}
}
const queue = () => {
if (frame === 0) frame = requestAnimationFrame(sample)
}
let listening = false
const listen = (on: boolean) => {
if (on === listening) return
listening = on
if (on) {
window.addEventListener("scroll", queue, { passive: true })
window.addEventListener("resize", queue, { passive: true })
} else {
window.removeEventListener("scroll", queue)
window.removeEventListener("resize", queue)
}
}
// The listener exists only while the track is on screen.
const io = new IntersectionObserver(([entry]) => {
listen(entry?.isIntersecting ?? false)
queue()
})
io.observe(el)
pinned.addEventListener("change", queue)
const settle = () => setLayout(pinned.matches ? "pinned" : "stacked")
pinned.addEventListener("change", settle)
settle()
sample()
return () => {
io.disconnect()
listen(false)
pinned.removeEventListener("change", queue)
pinned.removeEventListener("change", settle)
cancelAnimationFrame(frame)
}
}, [acts, pinnedQuery])
const seek = React.useCallback(
(index: number) => {
if (!Number.isInteger(index) || index < 0 || index >= acts)
throw new Error(`ScrollStage: act ${index} outside 0..${acts - 1}`)
const el = track.current
if (!el || !window.matchMedia(pinnedQuery).matches) return false
const rect = el.getBoundingClientRect()
const travel = Math.max(0, rect.height - window.innerHeight)
// Aim inside the act, not at a rounding-sensitive boundary. rect + scrollY
// works in positioned wrappers where offsetTop is not a document coordinate.
window.scrollTo({
top: window.scrollY + rect.top + (travel * (index + 0.5)) / acts,
behavior: "smooth",
})
return true
},
[acts, pinnedQuery],
)
const ctx = React.useMemo(
() => ({ acts, active, seek, track, pinnedQuery }),
[acts, active, seek, pinnedQuery],
)
return (
<StageContext.Provider value={ctx}>
{React.createElement(
as,
{
ref: track,
className: `ag-stage-track${className ? ` ${className}` : ""}`,
"data-engine": engine,
"data-pin": pin,
"data-stacked": stacked !== undefined ? "" : undefined,
style: {
"--acts": acts,
...(pace && { "--pace": pace }),
...(tail && { "--tail": tail }),
...style,
} as React.CSSProperties,
...rest,
},
<div ref={paceEl} className="ag-stage-pace" aria-hidden />,
(stacked === undefined || layout !== "stacked") && (
<div className="ag-stage">{children}</div>
),
stacked !== undefined && layout !== "pinned" && (
<div className="ag-stage-stacked">{stacked}</div>
),
)}
</StageContext.Provider>
)
}
export interface ActProps extends React.HTMLAttributes<HTMLDivElement> {
/** 0-based checkpoint this element belongs to. */
index: number
as?: "div" | "section" | "article" | "figure"
}
/** A stage element bound to one act. Exposes two CSS numbers to its subtree:
* --act-p (0..1 across the act's own window of travel) and --act-on (1 while the act
* is on stage, 0 otherwise; the last act stays on through the tail). Drive opacity,
* transform, clip-path from them; do not read them in JS. */
export function Act({
index,
as = "div",
className,
style,
...rest
}: ActProps) {
const ctx = React.useContext(StageContext)
if (!ctx) throw new Error("<Act> outside <ScrollStage>")
if (index < 0 || index >= ctx.acts)
throw new Error(`<Act index={${index}}> outside 0..${ctx.acts - 1}`)
const at = `(var(--stage-p) * var(--acts) - ${index})`
const first = index === 0
const last = index === ctx.acts - 1
// The first act is on from the top of the track (it has no act before it to hand
// over from), the last stays on through the tail; the ones between hold one window.
const on =
first && last
? "1"
: first
? `clamp(0, (1 - ${at}) * 1000, 1)`
: last
? `clamp(0, ${at} * 1000, 1)`
: `min(clamp(0, ${at} * 1000, 1), clamp(0, (1 - ${at}) * 1000, 1))`
return React.createElement(as, {
className,
"data-act": index,
"data-on": ctx.active === index ? "" : undefined,
style: {
"--act-p": `clamp(0, ${at}, 1)`,
"--act-on": on,
...style,
} as React.CSSProperties,
...rest,
})
}/* scroll-stage: a tall TRACK whose sticky STAGE stays in view while the page scrolls
past it. Progress (0..1 over the sticky dwell) is the inherited custom property
--stage-p, set by a CSS scroll-driven animation (view() timeline, contain range =
track top at viewport top → track bottom at viewport bottom). No JavaScript runs per
frame; a browser without animation-timeline gets the same number from a scroll
listener (data-engine="js"). Children read --stage-p (or --act-p via <Act>).
Pinning happens at lg (64rem) and only when motion is welcome. Below that, or under
prefers-reduced-motion, the track is its natural height, --stage-p is 1 (the
completed state is the accessible one), and the `stacked` alternative shows. */
@property --stage-p {
syntax: "<number>";
inherits: true;
initial-value: 0;
}
@layer components {
@keyframes ag-stage-p {
from {
--stage-p: 0;
}
to {
--stage-p: 1;
}
}
.ag-stage-track {
position: relative;
--stage-p: 1;
--pace: 100svh;
--tail: 40svh;
}
/* One unit of pace, measurable: the act clock divides the track's travel by it. */
.ag-stage-pace {
position: absolute;
top: 0;
left: 0;
width: 0;
height: var(--pace);
visibility: hidden;
pointer-events: none;
}
@media (min-width: 64rem) and (prefers-reduced-motion: no-preference) {
.ag-stage-track {
--stage-p: 0;
height: calc(var(--acts) * var(--pace) + var(--tail));
}
.ag-stage-track[data-engine="css"] {
animation: ag-stage-p linear both;
animation-timeline: view();
animation-range: contain 0% contain 100%;
}
.ag-stage {
position: sticky;
top: var(--stage-top, 0px);
display: flex;
min-height: var(--stage-min-height, 100svh);
align-items: center;
}
.ag-stage-track[data-stacked] > .ag-stage-stacked {
display: none;
}
}
@media not ((min-width: 64rem) and (prefers-reduced-motion: no-preference)) {
.ag-stage-track[data-stacked] > .ag-stage {
display: none;
}
}
/* Small-screen pinning is explicit: the consumer must supply a scene that fits. */
@media (width < 64rem) and (min-height: 36rem) and (
prefers-reduced-motion: no-preference
) {
.ag-stage-track[data-pin="all"] {
--stage-p: 0;
height: calc(var(--acts) * var(--pace) + var(--tail));
}
.ag-stage-track[data-pin="all"][data-engine="css"] {
animation: ag-stage-p linear both;
animation-timeline: view();
animation-range: contain 0% contain 100%;
}
.ag-stage-track[data-pin="all"] > .ag-stage {
position: sticky;
top: var(--stage-top, 0px);
display: flex;
min-height: var(--stage-min-height, 100svh);
align-items: center;
}
.ag-stage-track[data-pin="all"] > .ag-stage-stacked {
display: none;
}
}
@media (height < 36rem) {
.ag-stage-track[data-pin="all"] {
height: auto;
animation: none;
--stage-p: 1;
}
.ag-stage-track[data-pin="all"] > .ag-stage {
position: static;
min-height: 0;
}
.ag-stage-track[data-pin="all"][data-stacked] > .ag-stage {
display: none;
}
.ag-stage-track[data-pin="all"] > .ag-stage-stacked {
display: block;
}
}
}"use client"
import * as React from "react"
import { useScrollStage } from "@/registry/base-nova/ui/scroll-stage"
export interface ScrollBeat {
/** Relative scroll distance. A short finale might use .5. */
span: number
/** Fraction at which content finishes. Default .6. */
play?: number
/** Fraction at which the reading beat ends and transition starts. Default .85. */
hold?: number
}
/** Pure choreography: content, reading dwell and movement share one position. */
export function scrollBeatFrame(
position: number,
beats: readonly ScrollBeat[],
) {
let start = 0
let active = beats.length - 1
let offset = active
const progress = beats.map((beat, i) => {
const local = (position - start) / beat.span
if (local >= 0 && local < 1) {
active = i
const hold = beat.hold ?? 0.85
offset = Math.min(
beats.length - 1,
i + Math.max(0, (local - hold) / (1 - hold)),
)
}
start += beat.span
return Math.max(0, Math.min(1, local / (beat.play ?? 0.6)))
})
if (position <= 0) active = offset = 0
return { active, offset, progress }
}
/** Opt-in JS driver for renderers such as transcripts that need exact content
* progress. CSS-only scenes can keep using Act. No autoplay, no timed exit gates.
* Keep beats stable (declare them outside the component). */
export function useScrollStageTimeline(
beats: readonly ScrollBeat[],
{
top = 0,
bottom = 0,
}: { top?: number | string; bottom?: number | string } = {},
) {
if (
!beats.length ||
beats.some(
(b) =>
!(b.span > 0) ||
(b.play ?? 0.6) <= 0 ||
(b.play ?? 0.6) > (b.hold ?? 0.85) ||
(b.hold ?? 0.85) >= 1,
)
)
throw new Error(
"Scroll beats require positive spans and 0 < play <= hold < 1",
)
const { track, pinnedQuery } = useScrollStage()
const span = beats.reduce((sum, beat) => sum + beat.span, 0)
const [position, setPosition] = React.useState(0)
const [ambient, setAmbient] = React.useState(0)
React.useEffect(() => {
const el = track.current
const stage = el?.querySelector<HTMLElement>(".ag-stage")
if (!el || !stage) return
const media = matchMedia(pinnedQuery)
let frame = 0
const bounds = () => {
if (!media.matches) return null
const rect = el.getBoundingClientRect()
return {
start: scrollY + rect.top,
travel: Math.max(1, rect.height - innerHeight),
}
}
const fit = () => {
// Resolve CSS lengths in the scene's own inherited shell, including rem and
// custom properties, instead of making consumers repeat pixel offsets.
el.style.setProperty(
"--stage-clear-top",
typeof top === "number" ? `${top}px` : top,
)
el.style.setProperty(
"--stage-clear-bottom",
typeof bottom === "number" ? `${bottom}px` : bottom,
)
el.style.setProperty("--stage-min-height", "0px")
el.style.setProperty(
"--stage-top",
`max(var(--stage-clear-top), calc((100dvh - ${stage.offsetHeight}px + var(--stage-clear-top) - var(--stage-clear-bottom)) / 2))`,
)
}
const sample = () => {
const range = bounds()
if (!range) {
setPosition(span)
return
}
setPosition(
Math.max(
0,
Math.min(span, ((scrollY - range.start) / range.travel) * span),
),
)
// Keep decoration moving through the scene's complete departure.
const exit = scrollY + stage.getBoundingClientRect().bottom
setAmbient(
Math.max(
0,
((Math.min(scrollY, exit) - range.start) / range.travel) * span,
),
)
}
const schedule = () => {
if (!frame)
frame = requestAnimationFrame(() => {
frame = 0
sample()
})
}
const resize = new ResizeObserver(fit)
resize.observe(stage)
const refresh = () => {
fit()
sample()
}
window.addEventListener("scroll", schedule, { passive: true })
window.addEventListener("resize", refresh)
media.addEventListener("change", refresh)
refresh()
return () => {
cancelAnimationFrame(frame)
resize.disconnect()
window.removeEventListener("scroll", schedule)
window.removeEventListener("resize", refresh)
media.removeEventListener("change", refresh)
el.style.removeProperty("--stage-top")
el.style.removeProperty("--stage-min-height")
el.style.removeProperty("--stage-clear-top")
el.style.removeProperty("--stage-clear-bottom")
}
}, [track, span, top, bottom, pinnedQuery])
const seek = React.useCallback(
(index: number) => {
const beat = beats[index]
if (!beat) throw new Error(`Unknown scroll beat: ${index}`)
const el = track.current
if (!el || !matchMedia(pinnedQuery).matches) return false
const rect = el.getBoundingClientRect()
const start = beats.slice(0, index).reduce((sum, b) => sum + b.span, 0)
const reading = ((beat.play ?? 0.6) + (beat.hold ?? 0.85)) / 2
window.scrollTo({
top:
scrollY +
rect.top +
((rect.height - innerHeight) * (start + beat.span * reading)) / span,
behavior: "smooth",
})
return true
},
[beats, track, span, pinnedQuery],
)
return { ...scrollBeatFrame(position, beats), position, ambient, seek }
}