Hovering a tile peels its corner up like a sticker — a pure CSS curl of clip-path, layered gradients, transform, and shadow — revealing a back face.
npx shadcn@latest add @paragon/peel-gallery"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
/**
* PeelGallery — hovering a tile peels its bottom-right corner up like a sticker,
* revealing a back face beneath. The curl is pure CSS: a triangular clip-path
* hides the peeled corner of the front face, a mirrored triangle (the flipped
* underside, with a highlight→shadow gradient) folds up over it, and a soft
* drop-shadow sells the lift. No three.js — layered gradients + transform +
* clip-path only.
*
* Pass `items` (fronts) and optionally `backs`; otherwise deterministic
* gradient tiles render with a labeled back. Peel is gated to fine pointers;
* reduced motion keeps a still (un-peeled) tile.
*/
export interface PeelGalleryProps
extends Omit<React.ComponentProps<"div">, "children"> {
/** Front faces. Falls back to generated gradient tiles. */
items?: React.ReactNode[];
/** Optional back faces (revealed under the peel). */
backs?: React.ReactNode[];
/** Number of generated tiles when `items` is omitted. */
count?: number;
/** Grid columns. */
columns?: number;
/** Peel size as a fraction of the tile (0.15–0.6). */
peelAmount?: number;
/** Back-face color under the peel (any CSS color). */
backColor?: string;
}
const TILE_GRADIENTS = [
"linear-gradient(135deg, oklch(0.72 0.16 250), oklch(0.52 0.2 285))",
"linear-gradient(135deg, oklch(0.78 0.15 165), oklch(0.57 0.16 200))",
"linear-gradient(135deg, oklch(0.82 0.16 70), oklch(0.66 0.19 40))",
"linear-gradient(135deg, oklch(0.75 0.17 20), oklch(0.57 0.18 350))",
"linear-gradient(135deg, oklch(0.76 0.13 300), oklch(0.54 0.16 264))",
"linear-gradient(135deg, oklch(0.8 0.14 200), oklch(0.61 0.14 236))",
];
function GeneratedFront({ i }: { i: number }) {
return (
<div
className="flex size-full items-end justify-start p-3"
style={{ background: TILE_GRADIENTS[i % TILE_GRADIENTS.length] }}
>
<span className="text-sm font-semibold tabular-nums text-white/90 drop-shadow-[0_1px_3px_rgba(0,0,0,0.35)]">
{String(i + 1).padStart(2, "0")}
</span>
</div>
);
}
function GeneratedBack({ i }: { i: number }) {
return (
<div className="flex size-full flex-col items-center justify-center gap-1 p-2 text-center">
<span className="text-xs font-medium text-foreground">Preset</span>
<span className="text-[10px] text-muted-foreground">
#{String(i + 1).padStart(3, "0")}
</span>
</div>
);
}
const STYLES = `
@media (prefers-reduced-motion: reduce) {
.paragon-peel-tile { transition: none !important; }
.paragon-peel-tile .paragon-peel-front,
.paragon-peel-tile .paragon-peel-fold { transition: none !important; }
}
`;
export function PeelGallery({
items,
backs,
count = 6,
columns = 3,
peelAmount = 0.32,
backColor,
className,
style,
...props
}: PeelGalleryProps) {
const fronts = React.useMemo<React.ReactNode[]>(() => {
if (items && items.length) return items;
return Array.from({ length: count }, (_, i) => <GeneratedFront key={i} i={i} />);
}, [items, count]);
const n = fronts.length;
const p = Math.max(0.15, Math.min(0.6, peelAmount)) * 100;
return (
<>
<style href="paragon-peel-gallery" precedence="paragon">
{STYLES}
</style>
<div
className={cn("grid gap-3", className)}
style={{
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
...style,
}}
{...props}
>
{fronts.map((front, i) => {
const back = backs?.[i] ?? <GeneratedBack i={i} />;
return (
<div
key={i}
tabIndex={0}
className="paragon-peel-tile group relative aspect-square overflow-hidden rounded-xl shadow-border outline-none transition-[box-shadow] duration-200 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
style={{ ["--peel" as string]: `${p}%` }}
>
{/* Back face, revealed under the peeled corner. */}
<div
className="absolute inset-0"
style={{
background: backColor ?? "var(--color-card)",
color: "var(--color-card-foreground)",
}}
>
{back}
</div>
{/* Front face — clipped so the bottom-right corner lifts away. */}
<div
className="paragon-peel-front absolute inset-0 transition-[clip-path] duration-300 [clip-path:polygon(0_0,100%_0,100%_100%,0_100%)] [transition-timing-function:var(--ease-out)] group-hover:[clip-path:polygon(0_0,100%_0,100%_calc(100%-var(--peel)),calc(100%-var(--peel))_100%,0_100%)] group-focus-visible:[clip-path:polygon(0_0,100%_0,100%_calc(100%-var(--peel)),calc(100%-var(--peel))_100%,0_100%)]"
>
{front}
</div>
{/* The curling underside — a mirrored triangle folding up from
the corner. Scales + fades in on hover/focus. */}
<div
aria-hidden
className="paragon-peel-fold pointer-events-none absolute bottom-0 right-0 origin-bottom-right opacity-0 transition-[transform,opacity] duration-300 [transform:scale(0.15)] [transition-timing-function:var(--ease-out)] group-hover:scale-100 group-hover:opacity-100 group-focus-visible:scale-100 group-focus-visible:opacity-100"
style={{
width: `var(--peel)`,
height: `var(--peel)`,
clipPath: "polygon(100% 0, 100% 100%, 0 100%)",
background:
"linear-gradient(315deg, rgba(255,255,255,0.9), rgba(205,205,215,0.58) 45%, rgba(110,110,120,0.55))",
boxShadow: "-7px -7px 14px -4px rgba(0,0,0,0.4)",
}}
/>
</div>
);
})}
</div>
</>
);
}