blurFade
A Effects block/component from Magic UI — clean-room reimplemented for Domphy (see methodology). Call blurFade() with no arguments for a working demo, or edit the code below live.
Props
| Prop | Type | Description |
|---|---|---|
children | DomphyElement | DomphyElement[] | Content to reveal. A single element or a list — passed through unchanged inside the animated wrapper. Defaults to a small demo block. |
direction | BlurFadeDirection | Direction the content travels as it reveals — "down" (the default) starts the content offset above its final position and it slides down into place; "up" starts below and slides up; "left" starts to the right and slides left; "right" starts to the left and slides right. |
offset | number | How far the content starts offset, in px. Defaults to 6. |
blur | number | Starting blur radius, in px. Defaults to 6. |
duration | number | Reveal duration in ms. Defaults to 400. |
delay | number | Delay before the reveal starts, in ms (once triggered). Defaults to 0. |
trigger | "mount" | "view" | "mount" (default) plays shortly after the wrapper mounts; "view" waits until the wrapper first scrolls into the viewport, then plays once and never reverses. |
viewMargin | string | IntersectionObserver rootMargin used when trigger is "view". Defaults to "-50px" (fires slightly before the element is fully visible). Only used when trigger === "view". |
keyframes | BlurFadeKeyframePair | Fully custom hidden/visible keyframes, overriding direction/offset/blur entirely. |
inline | boolean | Renders the wrapper as inline-block instead of the default block, for revealing inline content without breaking its flow. |
style | StyleObject | — |
Implementation notes
Full clean-room reimplementation built on @domphy/ui's own motion() WAAPI patch (no Framer Motion, no React), mirroring the same State-driven hidden->visible keyframe pattern already used by this package's terminal() block. Supports direction (up/down/left/right, default 'down' = starts offset below and slides up into place, matching the spec's stated semantics), offset (px, default 6), blur (px, default 6), duration (ms, default 400) and delay (ms). trigger 'mount' (default) reveals shortly after mount; trigger 'view' uses a real IntersectionObserver with a configurable viewMargin (default '-50px', matching the spec's researchNote) and fires the reveal exactly once, never reversing — the observer disconnects itself after first intersection. Falls open (reveals immediately) if IntersectionObserver is unavailable. keyframes prop lets callers fully override the hidden/visible frames for advanced cases; inline switches the wrapper to inline-block; style passes through onto the wrapper. Children are rendered completely unchanged inside a single plain div wrapper, per the spec's domSketch. Verifying this component's node.remove() path (no exit frame => motion()'s _onBeforeRemove completes synchronously) surfaced and led to fixing the @domphy/core removal-hook bug described in the animatedThemeToggler entry above, which this component also relies on for crash-free root-level removal. Direct-source-diff fix (2026-07-05): The offset-per-direction math was inverted for all four directions (e.g. the default "down" direction was sliding up instead of down) — fixed all four offsets to match upstream's actual travel direction.
Status: ported · Reference: Magic UI original
// Magic UI "Blur Fade" — clean-room reimplementation.
//
// A transparent wrapper that reveals arbitrary content with a combined
// blur-to-sharp, fade-in, and short slide-in — either shortly after mount or
// the first time it scrolls into view. Implemented on top of this package's
// `motion()` patch (Web Animations API): a `hidden` keyframe (offset
// position, `opacity: 0`, `filter: blur(...)`) animates once to a `visible`
// keyframe (no offset, full opacity, no blur) driven by a `State`, exactly
// like `terminal()`'s own fade-in lines in this package — no Framer Motion
// or React `useInView` dependency of any kind.
//
// Implemented purely from the block's public functional/visual spec — no
// upstream Magic UI source was viewed or copied.
import type { DomphyElement, ElementNode, StyleObject } from "@domphy/core";
import { toState } from "@domphy/core";
import { themeSpacing } from "@domphy/theme";
import { heading, type MotionKeyframe, motion, paragraph } from "@domphy/ui";
export type BlurFadeDirection = "up" | "down" | "left" | "right";
// Upstream applies `delay: 0.04 + delay` (seconds) to every reveal — a constant
// 40ms baseline on top of the caller's delay. Mirror it here so the port starts
// on the same beat instead of 40ms early.
const REVEAL_BASELINE_DELAY = 40;
export interface BlurFadeKeyframePair {
/** Starting keyframe, before the reveal plays. */
hidden: MotionKeyframe;
/** Target keyframe the content settles into. */
visible: MotionKeyframe;
}
export interface BlurFadeProps {
/** Content to reveal. A single element or a list — passed through
* unchanged inside the animated wrapper. Defaults to a small demo block. */
children?: DomphyElement | DomphyElement[];
/** Direction the content *travels* as it reveals — `"down"` (the default)
* starts the content offset above its final position and it slides down into
* place; `"up"` starts below and slides up; `"left"` starts to the right and
* slides left; `"right"` starts to the left and slides right. */
direction?: BlurFadeDirection;
/** How far the content starts offset, in px. Defaults to `6`. */
offset?: number;
/** Starting blur radius, in px. Defaults to `6`. */
blur?: number;
/** Reveal duration in ms. Defaults to `400`. */
duration?: number;
/** Delay before the reveal starts, in ms (once triggered). Defaults to `0`. */
delay?: number;
/** `"mount"` (default) plays shortly after the wrapper mounts; `"view"`
* waits until the wrapper first scrolls into the viewport, then plays once
* and never reverses. */
trigger?: "mount" | "view";
/** `IntersectionObserver` `rootMargin` used when `trigger` is `"view"`.
* Defaults to `"-50px"` (fires slightly before the element is fully
* visible). Only used when `trigger === "view"`. */
viewMargin?: string;
/** Fully custom `hidden`/`visible` keyframes, overriding
* `direction`/`offset`/`blur` entirely. */
keyframes?: BlurFadeKeyframePair;
/** Renders the wrapper as `inline-block` instead of the default `block`,
* for revealing inline content without breaking its flow. */
inline?: boolean;
style?: StyleObject;
}
function offsetForDirection(
direction: BlurFadeDirection,
offset: number,
): { x: number; y: number } {
// `direction` is the direction the content *travels* as it reveals, matching
// upstream: "down" starts above (y = -offset) and slides down into place,
// "up" starts below and slides up, etc. The hidden offset is therefore the
// negation of the travel direction.
switch (direction) {
case "up":
return { x: 0, y: offset };
case "down":
return { x: 0, y: -offset };
case "left":
return { x: offset, y: 0 };
case "right":
return { x: -offset, y: 0 };
}
}
/** Self-contained (no network fetch) gradient thumbnail — used so the default demo has
* actual image/grid content to fade in, rather than bare text. */
function defaultThumbnail(index: number): DomphyElement {
// Neutral grayscale tiles (upstream demos use muted photo placeholders, not
// saturated color blocks); a slight per-tile lightness step keeps the grid
// from reading as three identical gray boxes.
const lightness = 88 - index * 5;
const markup =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 140">' +
`<rect width="200" height="140" rx="12" fill="hsl(0, 0%, ${lightness}%)"/>` +
'<circle cx="150" cy="40" r="18" fill="#ffffff" opacity="0.35"/>' +
"</svg>";
return {
img: null,
src: `data:image/svg+xml,${encodeURIComponent(markup)}`,
alt: `Gallery thumbnail ${index + 1}`,
// A purely decorative gradient tile with no text of its own — exempt from the
// missing-color contract, same idiom as `parallaxScroll.ts`'s photo tiles.
_doctorDisable: "missing-color",
style: {
display: "block",
width: "100%",
height: "auto",
borderRadius: themeSpacing(3),
},
} as DomphyElement;
}
function defaultChildren(): DomphyElement[] {
return [
{ h3: "Blur Fade", $: [heading()] },
{
p: "Content settles into place with a blur-to-sharp, fade, and short slide as it mounts or scrolls into view.",
$: [paragraph({ color: "neutral" })],
},
{
div: [0, 1, 2].map((index) => defaultThumbnail(index)),
style: {
display: "grid",
gridTemplateColumns: "1fr",
gap: themeSpacing(2),
marginBlockStart: themeSpacing(2),
// Stack the demo thumbnails on narrow screens; 3-up from `sm` up.
"@media (min-width: 40em)": {
gridTemplateColumns: "repeat(3, 1fr)",
},
} as StyleObject,
},
];
}
/**
* Transparent wrapper that reveals its children with a combined
* blur-to-sharp, fade-in, and short slide-in — either shortly after mount
* (default) or the first time it scrolls into view. Call with no arguments
* for a working demo block that fades in on mount.
*/
function blurFade(props: BlurFadeProps = {}): DomphyElement<"div"> {
const {
children = defaultChildren(),
direction = "down",
offset = 6,
blur = 6,
duration = 400,
delay = 0,
trigger = "mount",
viewMargin = "-50px",
keyframes,
inline = false,
} = props;
const childElements = Array.isArray(children) ? children : [children];
const { x: offsetX, y: offsetY } = offsetForDirection(direction, offset);
const hiddenFrame: MotionKeyframe = keyframes?.hidden ?? {
opacity: 0,
x: offsetX,
y: offsetY,
filter: `blur(${blur}px)`,
};
const visibleFrame: MotionKeyframe = keyframes?.visible ?? {
opacity: 1,
x: 0,
y: 0,
filter: "blur(0)",
};
const frame = toState<MotionKeyframe>(hiddenFrame);
return {
div: childElements,
$: [
motion({
initial: hiddenFrame,
animate: frame,
transition: { duration, easing: "ease-out" },
}),
],
_onMount: (node: ElementNode) => {
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
let observer: IntersectionObserver | null = null;
const reveal = () => {
timeoutHandle = setTimeout(
() => frame.set(visibleFrame),
REVEAL_BASELINE_DELAY + delay,
);
};
if (trigger === "view") {
if (typeof IntersectionObserver !== "function") {
// No IntersectionObserver support (e.g. non-browser test runtime)
// — fail open and reveal immediately rather than never playing.
reveal();
} else {
const element = node.domElement as Element;
observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
reveal();
observer?.disconnect();
observer = null;
}
},
{ rootMargin: viewMargin },
);
observer.observe(element);
}
} else {
reveal();
}
node.addHook("Remove", () => {
if (timeoutHandle) clearTimeout(timeoutHandle);
observer?.disconnect();
observer = null;
});
},
style: {
display: inline ? "inline-block" : "block",
...(props.style ?? {}),
},
};
}
export { blurFade };