BlurFade

The staggered reveal everyone installs first. Filter and transform only, so it never touches layout.

Install

$ npx shadcn@latest add wisp.pouriah.com/r/blur-fade.json
JSstaggered
measure
theme
draw
settle

BlurFadeesc or double-click to exit full screendouble-tap to exit

primitives/blur-fade.tsx
"use client";

import * as React from "react";
import { cx, useMotionTrigger, useReducedMotion } from "@pouriahlabs/wisp-ui";

/**
 * BlurFade — the staggered reveal everyone installs first.
 *
 * Low differentiation, highest install count: people arrive for this and stay
 * for the backdrops. Worth shipping, worth keeping short.
 *
 * Only `opacity`, `filter` and `transform` are animated, so it never touches
 * layout — a reveal that animates `height` or `margin` reflows the page on
 * every frame and is the most common way these components go wrong.
 */

export interface BlurFadeProps extends React.HTMLAttributes<HTMLDivElement> {
  /** Delay before this element's reveal, ms. */
  delay?: number;
  /** Duration, ms. */
  duration?: number;
  /** Blur radius to resolve from, px. */
  blur?: number;
  /** Vertical offset to travel, px. Negative reveals downward. */
  offset?: number;
  /** Reveal once on mount rather than waiting for the viewport. */
  immediate?: boolean;
  as?: React.ElementType;
}

export const BlurFade = React.forwardRef<HTMLElement, BlurFadeProps>(function BlurFade(
  {
    delay = 0,
    duration = 500,
    blur = 7,
    offset = 9,
    immediate = false,
    as: Tag = "div",
    className,
    style,
    children,
    ...props
  },
  forwardedRef,
) {
  const still = useReducedMotion();
  const [shown, setShown] = React.useState(false);

  const ref = useMotionTrigger<HTMLElement>(
    {
      disabled: immediate,
      rootMargin: "0px 0px -8% 0px",
      run: (done) => {
        setShown(true);
        done();
      },
      settle: () => setShown(true),
      cancel: () => {},
    },
    [immediate],
  );

  // Merge the caller's ref with the one the motion trigger observes. The
  // trigger hands back a `RefObject` (read-only `current` in the React types)
  // but backs it with a mutable ref, so writing through it is safe here.
  const setRef = React.useCallback(
    (node: HTMLElement | null) => {
      (ref as React.MutableRefObject<HTMLElement | null>).current = node;
      if (typeof forwardedRef === "function") forwardedRef(node);
      else if (forwardedRef) forwardedRef.current = node;
    },
    [ref, forwardedRef],
  );

  React.useEffect(() => {
    if (immediate) setShown(true);
  }, [immediate]);

  const revealed = shown || still;

  return (
    <Tag
      ref={setRef}
      className={cx("wisp-fade", className)}
      style={{
        opacity: revealed ? 1 : 0,
        filter: revealed ? "blur(0px)" : `blur(${blur}px)`,
        transform: revealed ? "none" : `translateY(${offset}px)`,
        transition: still
          ? "none"
          : `opacity ${duration}ms ease, filter ${duration}ms ease, transform ${duration}ms ease`,
        transitionDelay: still ? undefined : `${delay}ms`,
        willChange: revealed ? undefined : "opacity, filter, transform",
        ...style,
      }}
      {...props}
    >
      {children}
    </Tag>
  );
});

/**
 * Stagger a list of children without threading `delay` through each one.
 */
export function BlurFadeGroup({
  step = 150,
  start = 120,
  children,
  ...props
}: Omit<BlurFadeProps, "delay"> & { step?: number; start?: number }) {
  return (
    <>
      {React.Children.map(children, (child, i) => (
        <BlurFade delay={start + i * step} {...props}>
          {child}
        </BlurFade>
      ))}
    </>
  );
}

Certified against the contract