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 usva

layer

core / motion

intensity

guides · leads the eye through a sequence, then clears

composition

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 group

a11y

renders static under prefers-reduced-motion, nothing ever arms · children ship visible before hydrationjest-axe

dependencies

motion
live 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
0.08
usagecopy
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

proptypedefaultnotes
variant"veil" | "cast" | "surface" | "focus" | "tick" | "lean""tick"the reveal every child runs. assign by content role, like Reveal.
staggernumber0.06seconds between children on the shared trigger. the cascade is the effect, so the layout classes go on the group.
asRevealTag"div"the rendered element. ul, section, and friends keep their semantics.
intensitynumberoverrides the ambient RevealConfigProvider scalar for the group.
forcebooleanfalsecascade even when already in view at mount.

get it

npx shadcn add https://usva.build/r/reveal.jsoncopy
source · components/ui/reveal.tsxexactly what this command copiescopy
"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 copiescopy
"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 copiescopy
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 };
}