Buttons
ShatterPress
A glass pane with a hairline crack. A press breaks it from the exact point you hit, label and all, then every shard flies back into place.
Download
- React
- Tailwind CSS v4
- motion
components/ui/shatter-press.tsx
"use client";
import { useRef, useState, type ReactNode } from "react";
import { motion, useReducedMotion } from "motion/react";
type ShatterPressProps = {
children: ReactNode;
className?: string;
onClick?: () => void;
/** Seconds from the break until the pane is whole again. */
reform?: number;
/** Cracks from the press point. Each gives two shards. */
spokes?: number;
};
type Shard = { clip: string; x: number; y: number; rotate: number };
type Hit = { id: number; shards: Shard[] };
const pct = (p: number[]) => `${p[0].toFixed(1)}% ${p[1].toFixed(1)}%`;
function edgePoint(t: number) {
const s = ((t % 1) + 1) % 1;
const side = Math.floor(s * 4);
const f = (s * 4 - side) * 100;
return [[f, 0], [100, f], [100 - f, 100], [0, 100 - f]][side];
}
/* Glass breaks in a fan from the press point: spokes to the edge, with one ring of cracks across them. */
function breakAt(px: number, py: number, spokes: number): Shard[] {
const cuts = Array.from({ length: spokes }, (_, i) => (i + 0.2 + Math.random() * 0.6) / spokes);
const ring = 0.3 + Math.random() * 0.2;
const mix = (e: number[], k: number) => [px + (e[0] - px) * k, py + (e[1] - py) * k];
const out: Shard[] = [];
cuts.forEach((cut, i) => {
const after = i === spokes - 1 ? cuts[0] + 1 : cuts[i + 1];
const a = edgePoint(cut);
const b = edgePoint(after);
// The cuts can straddle a corner. It must stay on the shard or the pane gets a notch.
const corners = [0.25, 0.5, 0.75, 1, 1.25].filter((c) => c > cut && c < after).map(edgePoint);
const near = [[px, py], mix(a, ring), mix(b, ring)];
const far = [mix(a, ring), a, ...corners, b, mix(b, ring)];
[near, far].forEach((poly, n) => {
const cx = poly.reduce((sum, p) => sum + p[0], 0) / poly.length - px;
const cy = poly.reduce((sum, p) => sum + p[1], 0) / poly.length - py;
const len = Math.hypot(cx, cy) || 1;
const push = (n ? 70 : 40) + Math.random() * 70;
out.push({
clip: `polygon(${poly.map(pct).join(", ")})`,
x: (cx / len) * push,
y: (cy / len) * push + 30,
rotate: (Math.random() - 0.5) * 140,
});
});
});
return out;
}
/* ShatterPress: a glass pane with a hairline crack. A press breaks it from the exact point you hit, then the pieces fly back. */
export function ShatterPress({
children,
className = "rounded-lg border border-white/40 bg-[linear-gradient(135deg,rgba(190,225,255,0.3),rgba(190,225,255,0.09)_55%,rgba(190,225,255,0.22))] px-10 py-4 text-[15px] font-medium text-white",
onClick,
reform = 1.2,
spokes = 11,
}: ShatterPressProps) {
const reduce = useReducedMotion();
const [hit, setHit] = useState<Hit | null>(null);
const point = useRef<number[] | null>(null);
const next = useRef(0);
return (
<button
type="button"
onPointerDown={(e) => {
const b = e.currentTarget.getBoundingClientRect();
point.current = [((e.clientX - b.left) / b.width) * 100, ((e.clientY - b.top) / b.height) * 100];
}}
onClick={() => {
// No pointer point = Enter or Space, so it breaks from the centre.
const [px, py] = point.current ?? [50, 50];
point.current = null;
if (!reduce) setHit({ id: next.current++, shards: breakAt(px, py, spokes) });
onClick?.();
}}
className="relative inline-block"
>
<span className={`relative block ${hit ? "opacity-0" : ""} ${className}`}>
{children}
<svg aria-hidden viewBox="0 0 40 30" className="pointer-events-none absolute right-1 top-0.5 h-7 w-9 overflow-visible" fill="none" stroke="rgba(255,255,255,0.7)" strokeWidth={0.7}>
<path d="M38 2 L28 9 L24 7 M28 9 L25 18 L18 22 M25 18 L31 24" />
</svg>
</span>
{hit?.shards.map((s, i) => (
<motion.span
key={`${hit.id}-${i}`}
aria-hidden
className={`pointer-events-none absolute inset-0 block ${className}`}
style={{ clipPath: s.clip }}
initial={{ x: 0, y: 0, rotate: 0 }}
animate={{ x: [0, s.x, s.x, 0], y: [0, s.y, s.y, 0], rotate: [0, s.rotate, s.rotate, 0] }}
transition={{ duration: reform, times: [0, 0.32, 0.5, 1], ease: ["easeOut", "linear", "easeInOut"] }}
onAnimationComplete={() => i === 0 && setHit((h) => (h?.id === hit.id ? null : h))}
>
{children}
</motion.span>
))}
</button>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/shatter-press.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/shatter-press-demo.tsx
import { ShatterPress } from "@/components/ui/shatter-press"; export default function ShatterPressExample() { return ( <div className="grid h-full place-items-center bg-[radial-gradient(circle_at_50%_60%,#14283a,#0d0c14_70%)]"> <ShatterPress>Break the glass</ShatterPress> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| children | ReactNode | — | Button label. |
| onClick | () => void | — | Runs at the moment of the break. |
| reform | number | 1.2 | Seconds from the break until the pane is whole. |
| spokes | number | 11 | Cracks from the press point. Each gives two shards. |
| className | string | glass pane | Replaces the pane look. Shards copy it. |