← ui

reveal

ui

content that arrives as it scrolls into view, once or tied to the scroll

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

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

Latest changes · 2026-10-07
  • New: at, the moment (ms after the page's navigation) before which something on screen at load may not arrive. It runs on the document's clock, so several pieces on a page keep their order however long each takes to hydrate. Past that moment, or for what scrolls into view later, it arrives at once.
  • Fix: anything on screen when the page loads, even by a sliver, arrives at once. It waited for 20% of itself to be inside the window minus its bottom tenth, so a row at the fold stayed blank until the reader scrolled.
Updated 2026-10-07 · release notes

also called animate on scroll

shelf motion

the demo above, verbatim

reveal.demo.tsx
"use client"

import * as React from "react"
import { Sample } from "@/app/samples"
import { Button } from "@/registry/base-nova/ui/button"
import { Reveal } from "@/registry/base-nova/ui/reveal"

// #region again
/** One staggered arrival, and the way to see it again: a new key mounts a new Reveal,
 *  which arrives as the first one did. */
function Again() {
  const [round, setRound] = React.useState(0)
  return (
    <div className="space-y-3">
      <Reveal
        key={round}
        as="ul"
        stagger={80}
        className="grid grid-cols-3 gap-3"
      >
        {[0, 1, 2].map((k) => (
          <li
            key={k}
            className="aspect-video rounded-xl border border-border bg-sidebar"
          />
        ))}
      </Reveal>
      <Button
        size="sm"
        variant="secondary"
        onClick={() => setRound((r) => r + 1)}
      >
        again
      </Button>
    </div>
  )
}
// #endregion

export default function Demo() {
  return (
    <div className="space-y-32">
      <Sample
        name="arrive"
        title="arrives once, stays"
        description="three sections, each arriving as it enters the viewport, its cards staggered"
        presentation
      >
        <div className="space-y-32">
          {["a section", "another", "a third"].map((title, i) => (
            <Reveal key={title} as="section" className="space-y-4">
              <div className="font-mono text-xs text-muted-foreground">
                0{i + 1}
              </div>
              <h3 className="text-2xl font-bold tracking-tight">{title}</h3>
              <p className="max-w-prose text-[0.9375rem] leading-relaxed text-foreground/80">
                Wrap a region and it arrives as it enters the viewport. Once in,
                it stays: scrolling back never hides it. Under
                prefers-reduced-motion nothing moves.
              </p>
              <Reveal as="ul" stagger={80} className="grid grid-cols-3 gap-4">
                {[0, 1, 2].map((k) => (
                  <li
                    key={k}
                    className="aspect-video rounded-xl border border-border bg-sidebar"
                  />
                ))}
              </Reveal>
            </Reveal>
          ))}
        </div>
      </Sample>
      <Sample
        name="stagger"
        glance
        title="three cards, staggered"
        description="they arrive as they come into view, one after another; again mounts them anew"
        with="again"
      >
        <Again />
      </Sample>
      <Sample name="scroll" title='mode="scroll"' presentation>
        <Reveal
          mode="scroll"
          className="rounded-2xl border border-border bg-sidebar p-8"
        >
          <p className="font-mono text-xs text-muted-foreground">
            mode="scroll"
          </p>
          <p className="mt-2 text-[0.9375rem]">
            Tied to the scroll position, reversible, pure CSS.
          </p>
        </Reveal>
      </Sample>
    </div>
  )
}

the source, every file an install writes

reveal.tsx
"use client"

// Progressive arrival for sections, headings, media, lists. Wrap a region and it
// fades up as it enters the viewport (or at once, if any of it is on screen when the
// page loads); `stagger` lets its direct children take turns.
// The default mode is one-shot (data-in via IntersectionObserver, then the observer
// is gone); mode="scroll" is pure CSS and reversible. Reduced motion: inert.

import * as React from "react"
import "./reveal.css"

type Tag =
  | "div"
  | "section"
  | "article"
  | "header"
  | "ul"
  | "ol"
  | "figure"
  | "li"

export interface RevealProps extends React.HTMLAttributes<HTMLElement> {
  as?: Tag
  /** "once": arrive and stay (default). "scroll": tied to the scroll position. */
  mode?: "once" | "scroll"
  /** "up": fade + 16px rise (default). "fade": opacity only. */
  from?: "up" | "fade"
  /** Direct children arrive one after another, this many ms apart. Once mode only. */
  stagger?: number
  /** Fraction of the element that must be visible before it arrives. Default 0.2. */
  threshold?: number
  /** Ms after the page's navigation before something on screen at load may
   *  arrive: its moment in the page's arrival, on the document's clock, so
   *  several pieces keep their order however long each took to hydrate. Past
   *  that moment, or for what scrolls in later, it arrives at once. */
  at?: number
}

export function Reveal({
  as = "div",
  mode = "once",
  from = "up",
  stagger,
  threshold = 0.2,
  at,
  className,
  style,
  children,
  ...rest
}: RevealProps) {
  const ref = React.useRef<HTMLElement>(null)
  React.useEffect(() => {
    if (mode !== "once") return
    const el = ref.current
    if (!el) return
    // Already on screen when the page arrives, even by a sliver: it arrives now.
    // Reveal is for what scrolls INTO view; holding back what the reader can
    // already see leaves a blank where a card should be until they scroll.
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
      el.dataset.in = ""
      return
    }
    if (el.getBoundingClientRect().top < window.innerHeight) {
      const now = Number(document.timeline.currentTime ?? performance.now())
      const wait = Math.max(0, (at ?? 0) - now)
      if (wait === 0) {
        el.dataset.in = ""
        return
      }
      const timer = setTimeout(() => {
        el.dataset.in = ""
      }, wait)
      return () => clearTimeout(timer)
    }
    const io = new IntersectionObserver(
      ([entry]) => {
        if (!entry?.isIntersecting) return
        el.dataset.in = ""
        io.disconnect()
      },
      // Tall elements may never reach a 20% ratio on a short viewport; a top-edge
      // root margin lets them arrive once their head is well inside.
      { threshold, rootMargin: "0px 0px -10% 0px" },
    )
    io.observe(el)
    return () => io.disconnect()
  }, [mode, threshold, at])
  return React.createElement(
    as,
    {
      ref,
      className: `ag-reveal${className ? ` ${className}` : ""}`,
      "data-mode": mode,
      "data-from": from,
      "data-stagger": stagger !== undefined ? "" : undefined,
      style:
        stagger !== undefined
          ? ({ "--stagger": `${stagger}ms`, ...style } as React.CSSProperties)
          : style,
      ...rest,
    },
    children,
  )
}
reveal.css
/* reveal: content that arrives as it enters the viewport, so no stretch of page is a
   scroll where nothing happens. Two modes. "once" (default): IntersectionObserver
   sets data-in, a transition plays, and that is that; scrolling back never hides it.
   "scroll": a CSS view() timeline, reversible, no JavaScript at all. Both are inert
   under prefers-reduced-motion, and the hidden start state only applies when a script
   can lift it (scripting: enabled), so content is never trapped invisible. */

@layer components {
  .ag-reveal {
    --reveal-y: 16px;
    --stagger: 80ms;
  }
  .ag-reveal[data-from="fade"] {
    --reveal-y: 0px;
  }

  @keyframes ag-reveal {
    from {
      opacity: 0;
      transform: translateY(var(--reveal-y));
    }
    to {
      opacity: 1;
      transform: none;
    }
  }

  @media (prefers-reduced-motion: no-preference) and (scripting: enabled) {
    .ag-reveal[data-mode="once"]:not([data-stagger]) {
      opacity: 0;
      transform: translateY(var(--reveal-y));
      transition:
        opacity var(--dur-3) var(--ease-out),
        transform var(--dur-3) var(--ease-out);
    }
    .ag-reveal[data-mode="once"]:not([data-stagger])[data-in] {
      opacity: 1;
      transform: none;
    }

    /* Staggered: the wrapper stays put, its direct children take turns. */
    .ag-reveal[data-mode="once"][data-stagger] > * {
      opacity: 0;
      transform: translateY(var(--reveal-y));
      transition:
        opacity var(--dur-3) var(--ease-out),
        transform var(--dur-3) var(--ease-out);
      transition-delay: calc(var(--i, 0) * var(--stagger));
    }
    .ag-reveal[data-mode="once"][data-stagger][data-in] > * {
      opacity: 1;
      transform: none;
    }
    .ag-reveal[data-stagger] > :nth-child(1) {
      --i: 0;
    }
    .ag-reveal[data-stagger] > :nth-child(2) {
      --i: 1;
    }
    .ag-reveal[data-stagger] > :nth-child(3) {
      --i: 2;
    }
    .ag-reveal[data-stagger] > :nth-child(4) {
      --i: 3;
    }
    .ag-reveal[data-stagger] > :nth-child(5) {
      --i: 4;
    }
    .ag-reveal[data-stagger] > :nth-child(6) {
      --i: 5;
    }
    .ag-reveal[data-stagger] > :nth-child(7) {
      --i: 6;
    }
    .ag-reveal[data-stagger] > :nth-child(8) {
      --i: 7;
    }
    .ag-reveal[data-stagger] > :nth-child(9) {
      --i: 8;
    }
    .ag-reveal[data-stagger] > :nth-child(10) {
      --i: 9;
    }
    .ag-reveal[data-stagger] > :nth-child(11) {
      --i: 10;
    }
    .ag-reveal[data-stagger] > :nth-child(n + 12) {
      --i: 11;
    }
  }

  @media (prefers-reduced-motion: no-preference) {
    @supports (animation-timeline: view()) {
      .ag-reveal[data-mode="scroll"] {
        animation: ag-reveal linear both;
        animation-timeline: view();
        animation-range: entry 0% entry 40%;
      }
    }
  }
}