Surface
A composable in-view reveal wrapper with fade/slide/scale/blur presets and staggered children. Default melt preset rises with an amber under-glow, like surfacing from the forge.
motionfree
"use client";
import { motion, type HTMLMotionProps, type TargetAndTransition, type Variants } from "motion/react";
import * as React from "react";
import { useReducedMotion } from "@/registry/default/hooks/use-reduced-motion/use-reduced-motion";
import { cn } from "@/lib/utils";
export interface SurfaceProps
extends Omit<HTMLMotionProps<"div">, "children" | "initial" | "whileInView" | "viewport" | "transition"> {
/** Content to reveal. Each top-level child animates as its own staggered item. */
children: React.ReactNode;
/**
* Named entrance treatment. `melt` is the Crucible signature: rise + blur +
* an amber under-glow, like the element is surfacing from the forge.
* @default "melt"
*/
preset?: "melt" | "fade" | "slide" | "scale" | "blur";
/** Travel direction for `melt`/`slide`. @default "up" */
direction?: "up" | "down" | "left" | "right";
/** Travel distance in px for `melt`/`slide`. @default 12 */
distance?: number;
/** Starting blur radius in px for `melt`/`blur`. @default 8 */
blur?: number;
/** Starting scale for `scale`. @default 0.94 */
scaleFrom?: number;
/** Seconds each item takes to settle. @default 0.6 */
duration?: number;
/** Seconds between each top-level child's reveal. @default 0.08 */
stagger?: number;
/** Seconds before the first item starts. @default 0 */
delay?: number;
/** Re-trigger every time it scrolls into view instead of once. @default false */
repeat?: boolean;
/** Fraction of the element that must be visible to trigger. @default 0.4 */
amount?: number;
/** `viewport.margin`-style rootMargin, e.g. to trigger before entry. @default "0px 0px -10% 0px" */
margin?: NonNullable<HTMLMotionProps<"div">["viewport"]>["margin"];
/** Show the amber under-glow (only rendered for `preset="melt"`). @default true */
ember?: boolean;
/** Glow color for the `melt` ember. @default "#ff6b35" (molten amber) */
emberColor?: string;
}
function buildItemVariants(opts: {
preset: SurfaceProps["preset"];
direction: NonNullable<SurfaceProps["direction"]>;
distance: number;
blur: number;
scaleFrom: number;
duration: number;
reducedMotion: boolean;
}): Variants {
const { preset, direction, distance, blur, scaleFrom, duration, reducedMotion } = opts;
if (reducedMotion) {
return {
hidden: { opacity: 0 },
show: { opacity: 1, transition: { duration: 0.3 } },
};
}
const axis = direction === "left" || direction === "right" ? "x" : "y";
const offset = axis === "y" ? (direction === "up" ? distance : -distance) : direction === "left" ? distance : -distance;
const usesOffset = preset === "melt" || preset === "slide";
const usesBlur = preset === "melt" || preset === "blur";
const usesScale = preset === "scale";
const hidden: TargetAndTransition = {
opacity: 0,
...(usesOffset ? { [axis]: offset } : {}),
...(usesBlur ? { filter: `blur(${blur}px)` } : {}),
...(usesScale ? { scale: scaleFrom } : {}),
};
const show: TargetAndTransition = {
opacity: 1,
...(usesOffset ? { [axis]: 0 } : {}),
...(usesBlur ? { filter: "blur(0px)" } : {}),
...(usesScale ? { scale: 1 } : {}),
transition: { duration, ease: [0.25, 0.4, 0.25, 1] },
};
return { hidden, show };
}
/**
* Surface — a composable in-view reveal wrapper. Wrap any content and it rises
* into view as it crosses the viewport threshold; wrap multiple top-level
* children and they stagger in sequence, each animating via shared variants.
*
* Default `melt` preset reads as surfacing from the forge: children rise 12px
* while a soft amber under-glow brightens then extinguishes beneath them, with
* a 2px starting blur that sharpens like heat-haze clearing. Falls back to a
* plain opacity fade when the user prefers reduced motion.
*/
export function Surface({
children,
preset = "melt",
direction = "up",
distance = 12,
blur = 8,
scaleFrom = 0.94,
duration = 0.6,
stagger = 0.08,
delay = 0,
repeat = false,
amount = 0.4,
margin = "0px 0px -10% 0px",
ember = true,
emberColor = "#ff6b35",
className,
...props
}: SurfaceProps) {
const reducedMotion = useReducedMotion();
const items = React.Children.toArray(children);
const itemVariants = React.useMemo(
() => buildItemVariants({ preset, direction, distance, blur, scaleFrom, duration, reducedMotion }),
[preset, direction, distance, blur, scaleFrom, duration, reducedMotion]
);
const glowVariants: Variants = {
hidden: { opacity: 0 },
show: {
opacity: [0, 0.55, 0],
transition: { duration: duration * 1.4, times: [0, 0.45, 1], ease: "easeOut" },
},
};
const showEmber = ember && preset === "melt" && !reducedMotion;
return (
<motion.div
data-crucible="surface"
className={cn("relative isolate", className)}
initial="hidden"
whileInView="show"
viewport={{ once: !repeat, amount, margin }}
transition={{ staggerChildren: reducedMotion ? 0 : stagger, delayChildren: delay }}
{...props}
>
{showEmber && (
<motion.span
aria-hidden
variants={glowVariants}
className="pointer-events-none absolute inset-x-0 bottom-0 -z-10 h-1/2 blur-2xl"
style={{ background: `radial-gradient(60% 100% at 50% 100%, ${emberColor}55, transparent 70%)` }}
/>
)}
{items.map((child, i) => (
<motion.div
key={i}
variants={itemVariants}
className="will-change-[transform,filter,opacity]"
>
{child}
</motion.div>
))}
</motion.div>
);
}
Installation
CLI
npx shadcn@latest add @crucible/surfaceManual — install dependencies, then copy the source
npm install motionHonors prefers-reduced-motion with a designed static fallback, and passes className through.