76 / Motion
Reveal
Moves its children in when they arrive on screen. It changes how something arrives and never whether it is there.
"use client"
import { Reveal } from "mischief-ui/reveal"
const rows = [
"It waits until it is on screen",
"Then it arrives, one after another",
"And it stays where it landed",
]
export function RevealDemo() {
return (
<div className="border-border h-64 w-full max-w-md overflow-y-auto rounded-[var(--radius)] border p-6">
<div className="text-muted-foreground grid h-40 place-items-center text-xs">
Scroll down
</div>
<div className="grid gap-3">
{rows.map((row, index) => (
<Reveal
key={row}
delay={index * 90}
repeat
className="border-border bg-card rounded-[var(--radius)] border p-4 text-sm"
>
{row}
</Reveal>
))}
</div>
<div className="h-24" />
</div>
)
}Installation
Copy the source into your project, or keep it behind a package.
npx shadcn@latest add Tinkerers-Labs/mischief-ui/revealimport { Reveal } from "mischief-ui/reveal"Or paste it in yourself. The source imports the shared cn helper from @/lib/utils, so point that at your own copy.
"use client" import * as React from "react"import { cn } from "@/lib/utils" export type RevealDirection = "up" | "down" | "left" | "right" | "none" export type RevealProps = React.HTMLAttributes<HTMLDivElement> & { /** Which way the content travels in from. */ from?: RevealDirection /** Pixels travelled. */ distance?: number /** Milliseconds before it starts. Give a list an index times a step. */ delay?: numberUsage
export function Features({ items }) {
return items.map((item, index) => (
<Reveal key={item.id} delay={index * 90}>
<Card {...item} />
</Reveal>
))
}The content is never withheld
Scroll entrances are usually built by rendering nothing until an observer fires. That breaks the page for anyone whose browser did not run the observer, hides the text from anything reading the markup, and leaves a blank column if a script fails.
Here the children are always rendered and always in the document. Only opacity and a small translation are animated, and both are removed outright under reduced motion, where the content is simply there from the first paint.
Staggering without another component
There is no group wrapper, because a group wrapper would only be multiplying an index by a number. Do that where you have the index.
{rows.map((row, index) => (
<Reveal key={row.id} delay={index * 90}>{row.label}</Reveal>
))}API
from"up" | "down" | "left" | "right" | "none"Which way the content travels in from. Defaults to "up".distancenumberPixels travelled. Defaults to 16.delaynumberMilliseconds before it starts. An index times a step staggers a list.durationnumberMilliseconds. Defaults to 600.thresholdnumberHow much has to be on screen before it starts. Defaults to 0.15.repeatbooleanPlays again whenever it comes back. Off by default.Accessibility
Content is in the document and in normal order from the first paint, so nothing depends on the animation having run. Reduced motion removes the movement and the fade entirely rather than shortening them. Once played it stays played, unless repeat is on.