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.jsonJSstaggered
measure
theme
draw
settle
"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
- ✓Reduced motion paints one composed still frame — never a blank box
- ✓Pauses off-screen and in hidden tabs
- ✓Re-reads design tokens when the theme changes
- ✓SSR-safe: no hydration mismatch, no layout shift
- ✓Decorative layers are aria-hidden and pointer-events-none
- ✓Device pixel ratio clamped
- ✓Zero runtime dependencies beyond React