docs / core / reveal
Reveal
six scroll reveals under one idea: things resolve out of mist as they come into view. assign the variant by content role, so a page does not replay one identical entrance.
intensity · guidesrsc · client
provenance
authored in usvalayer
core / motionintensity
guides · leads the eye through a sequence, then clearscomposition
sections, cards and media on marketing and content pagesstagger lives inside one RevealGroup, never across sectionsnot in dense task UI. a dashboard someone works in reveals nothingnever a Reveal inside a RevealGroup, the group already animates each childa11y
renders static underprefers-reduced-motion, nothing ever arms · content ships visible before hydration · as keeps the element semanticjest-axedependencies
motionlive · six variants
each reveal is assigned by content role, not position.
cast
headings · titles · eyebrows
veil
prose · sections · footers (default)
surface
cards · panels · CTAs · hero
focus
images · media frames
tick
stats · tables (grouped)
lean
quotes · asides · callouts
props
| prop | type | default | notes |
|---|---|---|---|
| variant | "veil" | "cast" | "surface" | "focus" | "tick" | "lean" | "veil" | assign by content role, never by position. cast for headings, veil for prose, surface for cards and CTAs, focus for media, tick for data, lean for quotes. |
| as | RevealTag | "div" | the rendered element. h2, section, p, li, figure and friends keep their semantics. |
| intensity | number | — | overrides the ambient RevealConfigProvider scalar. 1 is kajo-bold, ~0.45 sisu-quiet, 0 switches the reveal off. |
| delay | number | 0 | seconds before the enter starts. |
| amount | number | 0.35 | fraction of the element that must be visible to trigger. |
| force | boolean | false | reveal even when already in view at mount. demos and explicit entrances. |
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 };
}