docs / core / reveal group
Reveal Group
the stagger group for Reveal: one shared viewport trigger cascades its direct children in, one after the next. the cascade is the effect, so the layout goes on the group and one variant covers every child.
intensity · guidesrsc · client
provenance
authored in usvalayer
core / motionintensity
guides · leads the eye through a sequence, then clearscomposition
a grid or row of siblings that enter together: cards, stats, logosone variant for the whole group; children inherit itnever a Reveal inside it, the group already animates each childnot across sections, one trigger per groupa11y
renders static underprefers-reduced-motion, nothing ever arms · children ship visible before hydrationjest-axedependencies
motionlive demo · try it out
Tokens
layer
Themes
layer
Primitives
layer
Patterns
layer
Sula
layer
Atmospheres
layer
customize
variantthe reveal every child runs
staggerseconds between children
usage
import { RevealGroup } from "usva/motion/reveal";
import { Card } from "usva/primitives/card";
<RevealGroup
variant="tick"
stagger={0.08}
className="grid grid-cols-3 gap-3"
>
<Card>Tokens</Card>
<Card>Themes</Card>
<Card>Primitives</Card>
</RevealGroup>props
| prop | type | default | notes |
|---|---|---|---|
| variant | "veil" | "cast" | "surface" | "focus" | "tick" | "lean" | "tick" | the reveal every child runs. assign by content role, like Reveal. |
| stagger | number | 0.06 | seconds between children on the shared trigger. the cascade is the effect, so the layout classes go on the group. |
| as | RevealTag | "div" | the rendered element. ul, section, and friends keep their semantics. |
| intensity | number | — | overrides the ambient RevealConfigProvider scalar for the group. |
| force | boolean | false | cascade even when already in view at mount. |
get it
npx shadcn add https://usva.build/r/reveal.jsonsource · components/ui/reveal.tsxexactly what this command copies
"use client";
import { motion, useReducedMotion } from "motion/react";
import * as React from "react";
import { buildReveal, type RevealVariant } from "./presets";
import { useRevealIntensity } from "./reveal-config";
const MOTION_TAGS = {
div: motion.div,
section: motion.section,
article: motion.article,
h2: motion.h2,
h3: motion.h3,
p: motion.p,
li: motion.li,
ul: motion.ul,
span: motion.span,
figure: motion.figure,
} as const;
export type RevealTag = keyof typeof MOTION_TAGS;
const useIsoLayoutEffect =
typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;
function assignRef<T>(ref: React.Ref<T> | undefined, node: T | null) {
if (typeof ref === "function") ref(node);
else if (ref) (ref as React.MutableRefObject<T | null>).current = node;
}
/**
* Decide, before paint, whether an element should reveal. Elements already in
* view at mount stay static (no hide-flash, no blank on no-JS); only ones below
* the fold arm the enter animation.
*/
function useArmed(amount: number, disabled: boolean, force: boolean) {
const ref = React.useRef<HTMLElement | null>(null);
const [armed, setArmed] = React.useState(force && !disabled);
useIsoLayoutEffect(() => {
if (disabled || force) return;
const el = ref.current;
if (!el) return;
const rect = el.getBoundingClientRect();
if (rect.top > window.innerHeight * (1 - amount)) setArmed(true);
}, [disabled, amount, force]);
return { ref, armed };
}
export interface RevealProps extends React.HTMLAttributes<HTMLElement> {
variant?: RevealVariant;
delay?: number;
/** Override the ambient reveal intensity for this element. */
intensity?: number;
/** Fraction of the element that must be visible to trigger. */
amount?: number;
/** Reveal even when already in view at mount (e.g. demos, explicit entrances). */
force?: boolean;
as?: RevealTag;
}
export const Reveal = React.forwardRef<HTMLElement, RevealProps>(
(
{
variant = "veil",
delay = 0,
intensity,
amount = 0.35,
force = false,
as = "div",
children,
...rest
},
forwardedRef,
) => {
const k = useRevealIntensity(intensity);
const reduced = useReducedMotion() ?? false;
const { ref, armed } = useArmed(amount, reduced || k <= 0, force);
const setRefs = React.useCallback(
(node: HTMLElement | null) => {
ref.current = node;
assignRef(forwardedRef, node);
},
[ref, forwardedRef],
);
const Comp = MOTION_TAGS[as] as React.ElementType;
if (!armed) {
return (
<Comp ref={setRefs} {...rest}>
{children}
</Comp>
);
}
const built = buildReveal(variant, k, reduced);
return (
<Comp
ref={setRefs}
initial={built.initial}
whileInView={built.animate}
viewport={{ once: true, amount }}
transition={{ ...built.transition, delay }}
{...rest}
>
{children}
</Comp>
);
},
);
Reveal.displayName = "Reveal";
export interface RevealGroupProps extends React.HTMLAttributes<HTMLElement> {
variant?: RevealVariant;
/** Delay between each child, seconds. */
stagger?: number;
/** Delay before the first child, seconds. */
delay?: number;
intensity?: number;
amount?: number;
/** Reveal even when already in view at mount (e.g. demos). */
force?: boolean;
as?: RevealTag;
}
/**
* Staggers its direct children in on one shared viewport trigger. The cascade
* IS the effect. Each child becomes an animated box, so put layout classes
* (grid/flex) on the group.
*/
export const RevealGroup = React.forwardRef<HTMLElement, RevealGroupProps>(
(
{
variant = "tick",
stagger = 0.06,
delay = 0,
intensity,
amount = 0.3,
force = false,
as = "div",
children,
...rest
},
forwardedRef,
) => {
const k = useRevealIntensity(intensity);
const reduced = useReducedMotion() ?? false;
const disabled = reduced || k <= 0;
const { ref, armed } = useArmed(amount, disabled, force);
const setRefs = React.useCallback(
(node: HTMLElement | null) => {
ref.current = node;
assignRef(forwardedRef, node);
},
[ref, forwardedRef],
);
const Comp = MOTION_TAGS[as] as React.ElementType;
if (!armed) {
return (
<Comp ref={setRefs} {...rest}>
{children}
</Comp>
);
}
const built = buildReveal(variant, k, reduced);
const childVariants = {
hidden: built.initial,
show: { ...built.animate, transition: built.transition },
};
return (
<Comp
ref={setRefs}
initial="hidden"
whileInView="show"
viewport={{ once: true, amount }}
variants={{
hidden: {},
show: {
transition: { staggerChildren: stagger, delayChildren: delay },
},
}}
{...rest}
>
{React.Children.map(children, (child) => (
<motion.div variants={childVariants}>{child}</motion.div>
))}
</Comp>
);
},
);
RevealGroup.displayName = "RevealGroup";
source · components/ui/reveal-config.tsxexactly what this command copies
"use client";
import * as React from "react";
export interface RevealConfig {
/** Global reveal intensity: kajo = 1 (bold), sisu ≈ 0.45 (quiet), 0 = crossfade. */
intensity: number;
}
const RevealConfigContext = React.createContext<RevealConfig>({ intensity: 1 });
export function RevealConfigProvider({
intensity,
children,
}: {
intensity: number;
children: React.ReactNode;
}) {
const value = React.useMemo(() => ({ intensity }), [intensity]);
return (
<RevealConfigContext.Provider value={value}>
{children}
</RevealConfigContext.Provider>
);
}
export function useRevealIntensity(override?: number): number {
const ctx = React.useContext(RevealConfigContext);
return override ?? ctx.intensity;
}
source · components/ui/presets.tsexactly what this command copies
import { tokens } from "usva-tokens";
export const springs = tokens.motion.spring;
/**
* Legacy variant objects (framer/motion `variants` shape). Kept for consumers
* that drive their own `motion` components; the Reveal system below supersedes
* this for scroll reveals.
*/
export const variants = {
fadeUp: {
hidden: { opacity: 0, y: 8 },
show: { opacity: 1, y: 0, transition: springs.soft },
},
stagger: {
hidden: {},
show: { transition: { staggerChildren: 0.06 } },
},
} as const;
/** Tactile press feedback. Spread onto a `motion` component. Never below 0.96. */
export const press = {
whileTap: { scale: 0.96 },
transition: springs.soft,
} as const;
// ── Reveal system: "resolve from mist" ──────────────────────────────
// Six distinct reveals, one metaphor: things resolve out of fog toward the
// light above. Assign by content role, never by position. One `intensity`
// scalar dials the whole set from kajo-bold (1) to sisu-quiet (~0.45) to a
// plain crossfade (0, also the reduced-motion path).
const EASE = {
quart: [0.25, 1, 0.5, 1],
quint: [0.22, 1, 0.36, 1],
expo: [0.16, 1, 0.3, 1],
} as const;
export type RevealVariant =
| "veil"
| "cast"
| "surface"
| "focus"
| "tick"
| "lean";
type RevealSpec = {
x?: number;
y?: number;
scale?: number;
blur?: number;
transition: Record<string, unknown>;
};
const SPECS: Record<RevealVariant, RevealSpec> = {
// default; mist thinning: prose, generic sections, footers
veil: { y: 12, blur: 3, transition: { duration: 0.5, ease: EASE.quart } },
// light resolving from above: headings, eyebrows, titles (moves DOWN)
cast: { y: -10, blur: 8, transition: { duration: 0.7, ease: EASE.quint } },
// material rising to the light: cards, panels, CTAs, hero (spring, no blur)
surface: { y: 20, scale: 0.97, transition: springs.soft },
// lens finding focus: images, media frames (no travel; clip the scale)
focus: {
scale: 1.04,
blur: 12,
transition: { duration: 0.9, ease: EASE.expo },
},
// instrument reading: stats, tables, mono/tabular (never blurs, grouped)
tick: { y: 6, transition: { duration: 0.28, ease: EASE.expo } },
// aside voice: quotes, testimonials, callouts (the only horizontal move)
lean: { x: -16, transition: { duration: 0.6, ease: EASE.quint } },
};
export interface BuiltReveal {
initial: Record<string, number | string>;
animate: Record<string, number | string>;
transition: Record<string, unknown>;
}
/**
* Compute a variant's motion objects, scaled by intensity `k` (0..1). Blur under
* 2px is dropped rather than rendered, so a low `k` degrades to a plain move.
*/
export function buildReveal(
variant: RevealVariant,
k: number,
reduced: boolean,
): BuiltReveal {
if (reduced || k <= 0) {
return {
initial: { opacity: 0 },
animate: { opacity: 1 },
transition: { duration: variant === "tick" ? 0 : 0.15, ease: "linear" },
};
}
const spec = SPECS[variant];
const initial: Record<string, number | string> = { opacity: 0 };
const animate: Record<string, number | string> = { opacity: 1 };
const x = (spec.x ?? 0) * k;
const y = (spec.y ?? 0) * k;
if (x) {
initial.x = x;
animate.x = 0;
}
if (y) {
initial.y = y;
animate.y = 0;
}
if (spec.scale != null) {
initial.scale = 1 + (spec.scale - 1) * k;
animate.scale = 1;
}
const blur = (spec.blur ?? 0) * k;
if (blur >= 2) {
initial.filter = `blur(${blur.toFixed(1)}px)`;
animate.filter = "blur(0px)";
}
const isSpring = spec.transition.type === "spring";
const transition = isSpring
? spec.transition
: {
...spec.transition,
duration: (spec.transition.duration as number) * (0.7 + 0.3 * k),
};
return { initial, animate, transition };
}