Bevel
A glass-refraction surface card: backdrop-blurred glass with a multi-layer bevel edge, a slow glacial-cyan caustic drifting along the top rim, and a refracted offset highlight that slides with a subtle perspective tilt on hover. Pure CSS with eased custom-property transitions; static caustics and no tilt under reduced motion.
cssfree
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/registry/default/hooks/use-reduced-motion/use-reduced-motion";
export interface BevelProps extends React.ComponentPropsWithoutRef<"div"> {
/** Card content. */
children: React.ReactNode;
/** Maximum tilt, in degrees, on either axis at the card's edge. Subtle by design. @default 6 */
tilt?: number;
/** Color of the caustic light on the bevel edges and the internal refracted highlight. @default glacial cyan */
causticColor?: string;
/** Backdrop blur in px — how strongly the glass frosts whatever sits behind it. @default 16 */
blur?: number;
}
/**
* Bevel — a glass-refraction surface card. A glassmorphic panel (backdrop
* blur + saturation over whatever sits behind it) with a multi-layer edge:
* an outer hairline, a soft white inner bevel, and a slow glacial-cyan
* caustic that drifts along the top edge. On hover the panel tilts gently in
* perspective and a refracted offset highlight — plus a thin caustic line on
* the lower-inside bevel — slides with the pointer, like light cast through
* thick glass. Tilt eases via registered custom-property transitions (pure
* CSS, no animation library) and disables for touch input. Reduced motion:
* static caustics at a handsome phase, no tilt, no drift.
*/
export function Bevel({
children,
tilt = 6,
causticColor = "#7dd3fc",
blur = 16,
className,
style,
...props
}: BevelProps) {
const reducedMotion = useReducedMotion();
const nodeRef = React.useRef<HTMLDivElement>(null);
const handlePointerMove = React.useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (reducedMotion || event.pointerType === "touch") return;
const node = nodeRef.current;
if (!node) return;
const rect = node.getBoundingClientRect();
const px = (event.clientX - rect.left) / rect.width;
const py = (event.clientY - rect.top) / rect.height;
node.style.setProperty("--bevel-rx", `${(-(py - 0.5) * 2 * tilt).toFixed(3)}deg`);
node.style.setProperty("--bevel-ry", `${((px - 0.5) * 2 * tilt).toFixed(3)}deg`);
node.style.setProperty("--bevel-px", `${(px * 100).toFixed(2)}%`);
node.style.setProperty("--bevel-py", `${(py * 100).toFixed(2)}%`);
},
[reducedMotion, tilt]
);
const handlePointerEnter = React.useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (reducedMotion || event.pointerType === "touch") return;
nodeRef.current?.setAttribute("data-hover", "true");
},
[reducedMotion]
);
const handlePointerLeave = React.useCallback(() => {
const node = nodeRef.current;
if (!node) return;
node.setAttribute("data-hover", "false");
node.style.setProperty("--bevel-rx", "0deg");
node.style.setProperty("--bevel-ry", "0deg");
node.style.setProperty("--bevel-px", "50%");
node.style.setProperty("--bevel-py", "40%");
}, []);
return (
<div
ref={nodeRef}
data-crucible="bevel"
data-hover="false"
data-reduced-motion={reducedMotion}
onPointerMove={handlePointerMove}
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
className={cn(
"relative overflow-hidden rounded-2xl border border-white/10",
"focus-within:border-white/25",
className
)}
style={{
["--bevel-rx" as string]: "0deg",
["--bevel-ry" as string]: "0deg",
["--bevel-px" as string]: "50%",
["--bevel-py" as string]: "40%",
["--bevel-caustic" as string]: causticColor,
backgroundColor: "rgba(18, 21, 27, 0.32)",
backdropFilter: `blur(${blur}px) saturate(150%)`,
WebkitBackdropFilter: `blur(${blur}px) saturate(150%)`,
...style,
}}
{...props}
>
<style>{`
@property --bevel-rx {
syntax: "<angle>";
inherits: true;
initial-value: 0deg;
}
@property --bevel-ry {
syntax: "<angle>";
inherits: true;
initial-value: 0deg;
}
@property --bevel-px {
syntax: "<percentage>";
inherits: true;
initial-value: 50%;
}
@property --bevel-py {
syntax: "<percentage>";
inherits: true;
initial-value: 40%;
}
[data-crucible="bevel"] {
isolation: isolate;
transform: perspective(1100px) rotateX(var(--bevel-rx)) rotateY(var(--bevel-ry));
/* Registered custom properties interpolate, so the tilt and the
refracted light trail the pointer with a heavy, glassy lag
instead of snapping. Unregistered fallback follows instantly. */
transition:
--bevel-rx 260ms cubic-bezier(0.22, 1, 0.36, 1),
--bevel-ry 260ms cubic-bezier(0.22, 1, 0.36, 1),
--bevel-px 260ms cubic-bezier(0.22, 1, 0.36, 1),
--bevel-py 260ms cubic-bezier(0.22, 1, 0.36, 1);
}
[data-crucible="bevel"] .bevel-layer {
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
}
/* Inner bevel: soft white top edge, faint bottom return, hairline
inner ring — the three-line stack that reads as ground glass. */
[data-crucible="bevel"] .bevel-edge {
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.22),
inset 0 2px 6px -2px rgba(255, 255, 255, 0.1),
inset 0 -1px 0 rgba(255, 255, 255, 0.05),
inset 0 0 0 1px rgba(255, 255, 255, 0.04);
}
/* Caustic sheen drifting along the top bevel. */
[data-crucible="bevel"] .bevel-caustic-top {
inset: 0 0 auto 0;
height: 2px;
border-radius: 0;
background: linear-gradient(
90deg,
transparent,
color-mix(in oklab, var(--bevel-caustic) 55%, white) 45%,
color-mix(in oklab, var(--bevel-caustic) 85%, transparent) 55%,
transparent
);
background-size: 55% 100%;
background-repeat: no-repeat;
background-position: 35% 0;
filter: drop-shadow(0 0 6px color-mix(in oklab, var(--bevel-caustic) 60%, transparent));
opacity: 0.7;
animation: bevel-drift 9s ease-in-out infinite alternate;
transition: opacity 400ms ease;
}
[data-crucible="bevel"][data-hover="true"] .bevel-caustic-top {
opacity: 1;
}
/* The refracted line on the lower-inside bevel — slides with the
pointer, like a caustic cast through the pane onto its far edge. */
[data-crucible="bevel"] .bevel-caustic-low {
inset: auto 0 0 0;
height: 1.5px;
border-radius: 0;
background: linear-gradient(
90deg,
transparent,
color-mix(in oklab, var(--bevel-caustic) 70%, transparent),
transparent
);
background-size: 45% 100%;
background-repeat: no-repeat;
background-position: var(--bevel-px) 0;
opacity: 0;
transition: opacity 400ms ease;
}
[data-crucible="bevel"][data-hover="true"] .bevel-caustic-low {
opacity: 0.8;
}
/* Internal refracted highlight: offset from the pointer (light bends
through the glass, so it never sits directly under the cursor). */
[data-crucible="bevel"] .bevel-refract {
background: radial-gradient(
130% 90% at calc(var(--bevel-px) + 14%) calc(var(--bevel-py) - 22%),
color-mix(in oklab, var(--bevel-caustic) 12%, transparent),
color-mix(in oklab, var(--bevel-caustic) 5%, transparent) 40%,
transparent 68%
);
mix-blend-mode: screen;
opacity: 0.55;
transition: opacity 400ms ease;
}
[data-crucible="bevel"][data-hover="true"] .bevel-refract {
opacity: 0.95;
}
@keyframes bevel-drift {
from {
background-position: -12% 0;
}
to {
background-position: 112% 0;
}
}
[data-crucible="bevel"][data-reduced-motion="true"] {
transform: none;
transition: none;
}
[data-crucible="bevel"][data-reduced-motion="true"] .bevel-caustic-top {
animation: none;
background-position: 35% 0;
opacity: 0.7;
}
[data-crucible="bevel"][data-reduced-motion="true"] .bevel-caustic-low {
opacity: 0.5;
}
[data-crucible="bevel"][data-reduced-motion="true"] .bevel-refract {
opacity: 0.45;
transition: none;
}
@media (prefers-reduced-motion: reduce) {
[data-crucible="bevel"] .bevel-caustic-top {
animation: none;
}
}
`}</style>
<div className="relative z-10">{children}</div>
<div className="bevel-layer bevel-refract" aria-hidden />
<div className="bevel-layer bevel-edge" aria-hidden />
<div className="bevel-layer bevel-caustic-top" aria-hidden />
<div className="bevel-layer bevel-caustic-low" aria-hidden />
</div>
);
}
Installation
CLI
npx shadcn@latest add @crucible/bevelProps
| Prop | Type | Default | Description |
|---|---|---|---|
| children | React.ReactNode | Card content. | |
| tilt | number | 6 | Maximum tilt, in degrees, on either axis at the card's edge. Subtle by design. |
| causticColor | string | glacial cyan | Color of the caustic light on the bevel edges and the internal refracted highlight. |
| blur | number | 16 | Backdrop blur in px — how strongly the glass frosts whatever sits behind it. |
Also accepts all props of React.ComponentPropsWithoutRef<"div"> — they pass through to the underlying element. | |||
Honors prefers-reduced-motion with a designed static fallback, and passes className through.