Galleries & Media

Peel Gallery

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.

Install

npx shadcn@latest add @paragon/peel-gallery

peel-gallery.tsx

"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>
    </>
  );
}