← ui

scroll-stage

ui

a stage pinned while the page scrolls past it, played act by act in CSS

npx shadcn add @ag/scroll-stage
Already installed? Preview an update
npx shadcn add @ag/scroll-stage --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-10-14
  • Its demo's act and beat pickers are a toggle-group (dots for the acts), where loose buttons stood.
Updated 2026-10-14 · release notes

also called scrollytelling

shelf motion

used by scroll-story

the demo above, verbatim

scroll-stage.demo.tsx
"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>
  )
}
// #endregion

the source, every file an install writes

scroll-stage.tsx
"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.css
/* 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;
    }
  }
}
scroll-stage-timeline.tsx
"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 }
}