Facet
A pointer-tracked 3D tilt card with spring-damped rotation and a hard-edged amber-to-violet glare streak that sweeps the surface like oil on hot steel. Tilt disables on touch and under reduced motion, leaving a static subtle glare.
motionfree
"use client";
import { motion, useMotionValue, useSpring, 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 FacetProps extends Omit<HTMLMotionProps<"div">, "children" | "ref"> {
/** Content to render on the tilting surface. */
children: React.ReactNode;
/** Maximum rotation, in degrees, on either axis at the card's edge. @default 14 */
maxTilt?: number;
/** Container perspective distance in px — lower feels more extreme. @default 900 */
perspective?: number;
/** Peak opacity of the glare streak while hovered. @default 0.7 */
glareOpacity?: number;
/** Scale applied while hovered/tilting. @default 1.02 */
hoverScale?: number;
}
const SPRING = { stiffness: 300, damping: 28, mass: 1 };
/**
* Facet — a card that tilts in 3D toward the pointer with spring-damped
* rotation, while a hard-edged amber-to-violet glare streak sweeps the
* surface like oil on hot steel. Disables tilt for touch input, and under
* `prefers-reduced-motion` falls back to a flat card with a static, subtle
* glare instead of disappearing.
*/
export function Facet({
children,
className,
style,
maxTilt = 14,
perspective = 900,
glareOpacity = 0.7,
hoverScale = 1.02,
...props
}: FacetProps) {
const reducedMotion = useReducedMotion();
const cardRef = React.useRef<HTMLDivElement>(null);
const [hovering, setHovering] = React.useState(false);
const rawRotateX = useMotionValue(0);
const rawRotateY = useMotionValue(0);
const rotateX = useSpring(rawRotateX, SPRING);
const rotateY = useSpring(rawRotateY, SPRING);
const scale = useSpring(1, SPRING);
const handlePointerMove = React.useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (reducedMotion || event.pointerType === "touch") return;
const node = cardRef.current;
if (!node) return;
const rect = node.getBoundingClientRect();
const px = (event.clientX - rect.left) / rect.width;
const py = (event.clientY - rect.top) / rect.height;
const cx = px - 0.5;
const cy = py - 0.5;
rawRotateX.set(-cy * 2 * maxTilt);
rawRotateY.set(cx * 2 * maxTilt);
node.style.setProperty("--facet-gx", `${(px * 100).toFixed(2)}%`);
node.style.setProperty("--facet-gy", `${(py * 100).toFixed(2)}%`);
},
[reducedMotion, maxTilt, rawRotateX, rawRotateY]
);
const handlePointerEnter = React.useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (reducedMotion || event.pointerType === "touch") return;
setHovering(true);
scale.set(hoverScale);
},
[reducedMotion, hoverScale, scale]
);
const handlePointerLeave = React.useCallback(() => {
setHovering(false);
rawRotateX.set(0);
rawRotateY.set(0);
scale.set(1);
}, [rawRotateX, rawRotateY, scale]);
return (
<motion.div
ref={cardRef}
data-crucible="facet"
onPointerMove={handlePointerMove}
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
className={cn(
"relative isolate overflow-hidden rounded-2xl border border-white/10 bg-neutral-900",
className
)}
style={{
transformStyle: "preserve-3d",
perspective: reducedMotion ? undefined : perspective,
rotateX: reducedMotion ? 0 : rotateX,
rotateY: reducedMotion ? 0 : rotateY,
scale: reducedMotion ? 1 : scale,
["--facet-gx" as string]: "50%",
["--facet-gy" as string]: "30%",
...style,
}}
{...props}
>
<div className="relative z-10">{children}</div>
<div
aria-hidden
className="pointer-events-none absolute inset-0 rounded-[inherit] transition-opacity duration-300"
style={{
opacity: reducedMotion ? 0.12 : hovering ? glareOpacity : 0,
mixBlendMode: "screen",
background: [
"radial-gradient(360px circle at var(--facet-gx) var(--facet-gy), rgba(255,177,102,0.55), transparent 40%)",
"linear-gradient(115deg, transparent 40%, rgba(255,107,53,0.55) 47%, rgba(193,18,31,0.4) 50%, rgba(90,24,154,0.45) 53%, transparent 60%)",
].join(", "),
backgroundSize: "auto, 220% 220%",
backgroundPosition: "var(--facet-gx) var(--facet-gy)",
}}
/>
</motion.div>
);
}
Installation
CLI
npx shadcn@latest add @crucible/facetManual — install dependencies, then copy the source
npm install motionHonors prefers-reduced-motion with a designed static fallback, and passes className through.