content that arrives as it scrolls into view, once or tied to the scroll
npx shadcn add @ag/revealAlready installed? Preview an update
npx shadcn add @ag/reveal --dry-runYour installed source stays unchanged until you update it. How updates work
- 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.
also called animate on scroll
shelf motion
the demo above, verbatim
"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
"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: 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%;
}
}
}
}