Quench
A click ripple that expands from the exact point of contact, like hot metal hitting water: a thin shockwave, a briefly desaturated cooled core, and ejected spark particles. Keyboard activation ripples from center.
motionfree
"use client";
import { AnimatePresence, motion, type HTMLMotionProps } 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 QuenchProps extends Omit<HTMLMotionProps<"button">, "onClick" | "children"> {
/** Shockwave color, hot metal hitting water. Any CSS color. */
color?: string;
/** Seconds the ripple takes to fully dissipate. */
duration?: number;
/** Show 2-3 spark particles ejected from the click point. */
sparks?: boolean;
/** Fires after the ripple's originating click/keyboard-activation. */
onClick?: React.MouseEventHandler<HTMLButtonElement>;
/** Button label / content. */
children?: React.ReactNode;
}
interface Ripple {
id: number;
x: number;
y: number;
size: number;
/** True when triggered by keyboard activation (no pointer coordinates available). */
keyboard: boolean;
}
/**
* Quench — a click ripple that expands from the exact point of contact, like
* hot metal hitting water: a thin amber shockwave, a briefly desaturated
* "cooled" interior, and a scatter of spark particles. Keyboard activation
* (Enter/Space) ripples from the button's center. Falls back to a plain
* opacity pulse under prefers-reduced-motion.
*/
export function Quench({
color = "#ff6b35",
duration = 0.7,
sparks = true,
className,
style,
onClick,
children,
...rest
}: QuenchProps) {
const reducedMotion = useReducedMotion();
const buttonRef = React.useRef<HTMLButtonElement>(null);
const [ripples, setRipples] = React.useState<Ripple[]>([]);
const nextId = React.useRef(0);
const handleClick: React.MouseEventHandler<HTMLButtonElement> = (event) => {
const el = buttonRef.current;
if (el) {
const rect = el.getBoundingClientRect();
// A keyboard-activated click carries no real pointer position (browsers
// report clientX/clientY as 0, and detail is 0 for non-pointer clicks).
const isKeyboard = event.detail === 0;
const x = isKeyboard ? rect.width / 2 : event.clientX - rect.left;
const y = isKeyboard ? rect.height / 2 : event.clientY - rect.top;
const size = Math.hypot(Math.max(x, rect.width - x), Math.max(y, rect.height - y)) * 2.2;
const id = nextId.current++;
setRipples((prev) => [...prev, { id, x, y, size, keyboard: isKeyboard }]);
// The shockwave ring lives slightly past `duration` (1.2x) so it reads
// fully before unmount.
window.setTimeout(() => {
setRipples((prev) => prev.filter((r) => r.id !== id));
}, duration * 1200);
}
onClick?.(event);
};
return (
<motion.button
ref={buttonRef}
data-crucible="quench"
whileTap={reducedMotion ? undefined : { scale: 0.97 }}
className={cn(
"relative isolate inline-flex items-center justify-center overflow-hidden rounded-lg",
"border border-neutral-800 bg-neutral-900 px-6 py-3 text-sm font-medium text-white",
"select-none transition-colors duration-200",
"hover:border-[color:var(--quench-hover-border)] hover:bg-neutral-800",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-950",
"focus-visible:ring-[color:var(--quench-color)]",
"disabled:cursor-not-allowed disabled:opacity-50",
className
)}
style={
{
"--quench-color": color,
"--quench-hover-border": `color-mix(in oklab, ${color} 45%, #262626)`,
...style,
} as React.CSSProperties
}
onClick={handleClick}
{...rest}
>
<span aria-hidden className="pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]">
<AnimatePresence>
{ripples.map((ripple) =>
reducedMotion ? (
<motion.span
key={ripple.id}
className="absolute rounded-full"
style={{
left: ripple.x,
top: ripple.y,
translateX: "-50%",
translateY: "-50%",
width: ripple.size * 0.4,
height: ripple.size * 0.4,
background: `color-mix(in oklab, ${color} 40%, transparent)`,
}}
initial={{ opacity: 0.5 }}
animate={{ opacity: 0 }}
transition={{ duration: 0.4 }}
/>
) : (
<React.Fragment key={ripple.id}>
{/* Shockwave ring */}
<motion.span
className="absolute rounded-full"
style={{
left: ripple.x,
top: ripple.y,
translateX: "-50%",
translateY: "-50%",
border: `2.5px solid color-mix(in oklab, ${color} 85%, white)`,
boxShadow: `0 0 10px 1px color-mix(in oklab, ${color} 55%, transparent), inset 0 0 6px 0 color-mix(in oklab, ${color} 40%, transparent)`,
}}
initial={{ width: 0, height: 0, opacity: 1 }}
animate={{ width: ripple.size, height: ripple.size, opacity: 0 }}
transition={{ duration: duration * 1.15, ease: [0.15, 0.65, 0.3, 1] }}
/>
{/* Cooled interior: a darker, briefly desaturated core */}
<motion.span
className="absolute rounded-full"
style={{
left: ripple.x,
top: ripple.y,
translateX: "-50%",
translateY: "-50%",
background:
"radial-gradient(circle, rgba(10,10,12,0.55) 0%, rgba(10,10,12,0) 70%)",
}}
initial={{ width: 0, height: 0, opacity: 1 }}
animate={{ width: ripple.size * 0.75, height: ripple.size * 0.75, opacity: 0 }}
transition={{ duration: duration * 0.8, ease: "easeOut" }}
/>
{/* Spark particles */}
{sparks &&
[0, 1, 2].map((i) => {
const angle = ripple.keyboard
? (i / 3) * Math.PI * 2 + Math.PI / 6
: (i / 3) * Math.PI * 2 + (ripple.id % 7);
const dist = ripple.size * 0.22;
return (
<motion.span
key={i}
className="absolute h-[3px] w-[3px] rounded-full"
style={{
left: ripple.x,
top: ripple.y,
background: color,
boxShadow: `0 0 4px 1px ${color}`,
}}
initial={{ x: 0, y: 0, opacity: 1, scale: 1 }}
animate={{
x: Math.cos(angle) * dist,
y: Math.sin(angle) * dist,
opacity: 0,
scale: 0.3,
}}
transition={{ duration: duration * 0.65, ease: "easeOut" }}
/>
);
})}
</React.Fragment>
)
)}
</AnimatePresence>
</span>
<span className="relative z-10">{children}</span>
</motion.button>
);
}
Installation
CLI
npx shadcn@latest add @crucible/quenchManual — install dependencies, then copy the source
npm install motionHonors prefers-reduced-motion with a designed static fallback, and passes className through.