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 usva

layer

core / motion

intensity

guides · leads the eye through a sequence, then clears

composition

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 child

a11y

renders static under prefers-reduced-motion, nothing ever arms · content ships visible before hydration · as keeps the element semanticjest-axe

dependencies

motion
live · 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

proptypedefaultnotes
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.
asRevealTag"div"the rendered element. h2, section, p, li, figure and friends keep their semantics.
intensitynumberoverrides the ambient RevealConfigProvider scalar. 1 is kajo-bold, ~0.45 sisu-quiet, 0 switches the reveal off.
delaynumber0seconds before the enter starts.
amountnumber0.35fraction of the element that must be visible to trigger.
forcebooleanfalsereveal even when already in view at mount. demos and explicit entrances.

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 };
}