Bevel

Contact sheet

A photographer's contact sheet: every frame at once on a strip of film, marked with a grease pencil. Circle the keepers, cross out the rest. For reviewing generated images, design variants and screenshots.

Generated variants

Eight variants of one prompt. Circle the keepers, cross out the rest.

1 picked of 0

Click to cycle, or use arrow keys with P, X and Backspace. Keeping: Dusk, low sun.

Installation

Add with the CLI

  1. Once per project, add the Bevel registry to components.json.

    components.json
    {
      "registries": {
        "@bevel": "https://bevel-ui.vercel.app/r/{name}.json"
      }
    }
  2. Add Contact sheet. This brings in the Bevel foundation, installs cn, and merges the styles into your global CSS.

    Terminal
    npx shadcn@latest add @bevel/contact-sheet
Install manually
  1. Install the dependencies.

    Terminal
    pnpm add cn
  2. Add the Bevel foundation, if you haven't already. Light, depth, materials, motion and shape tokens, plus the utilities components use to apply them.

    styles/bevel.css
    /*
     * Bevel foundation. Every component builds on these tokens and utilities;
     * component stylesheets only add their own palettes and shapes.
     *
     * Layers:
     *   1. Light     one source, above and slightly in front of the screen.
     *   2. Depth     named elevations built from the light: recessed, flush,
     *                raised, pressed, object.
     *   3. Material  a surface recipe (leather, plastic, metal) that reads
     *                --bevel-material and --bevel-material-edge.
     *   4. Motion    two curves, a spring, and durations named by purpose.
     *   5. Shape     radii, stitching, and the type scale for amounts.
     */
    
    :root {
      /* 1. Light */
      --bevel-highlight: oklch(1 0 0 / 0.22);
      --bevel-highlight-strong: oklch(1 0 0 / 0.35);
      --bevel-shade: oklch(0 0 0 / 0.32);
      --bevel-shade-strong: oklch(0 0 0 / 0.5);
      --bevel-cast: oklch(0.25 0.03 50 / 0.22);
      /* Stamped into a surface: shaded on the upper inner edge, lit below. */
      --bevel-deboss: 0 -1px 0 oklch(0 0 0 / 0.45), 0 1px 0 oklch(1 0 0 / 0.12);
    
      /* 3. Material defaults; components and variants override these. */
      --bevel-material: oklch(0.47 0.085 52);
      --bevel-material-edge: oklch(0.3 0.06 44);
      --bevel-material-recess: oklch(0.2 0.008 260); /* neutral dark; materials set their own */
      --bevel-grain: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.7' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .7 0 0 0 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23g)'/%3E%3C/svg%3E");
      --bevel-grain-size: 180px;
    
      /* 2. Depth */
      --bevel-depth-recessed:
        inset 0 1px 2px var(--bevel-shade-strong),
        0 1px 0 var(--bevel-highlight);
      --bevel-depth-flush:
        inset 0 1px 0 var(--bevel-highlight),
        inset 0 -1px 0 var(--bevel-shade);
      --bevel-depth-raised:
        inset 0 1px 0 var(--bevel-highlight),
        0 1px 0 var(--bevel-material-edge),
        0 2px 0 var(--bevel-material-edge),
        0 3px 5px var(--bevel-shade);
      --bevel-depth-pressed:
        inset 0 1px 2px var(--bevel-shade),
        0 1px 0 var(--bevel-highlight);
      /* A whole object resting on the page. */
      --bevel-depth-object:
        inset 0 1px 0 var(--bevel-highlight),
        inset 0 -1px 0 var(--bevel-shade),
        0 0 0 1px var(--bevel-material-edge),
        0 1px 1px var(--bevel-cast),
        0 6px 12px -2px var(--bevel-cast),
        0 18px 32px -8px var(--bevel-cast);
      /* The shadow under a loose object, for filter: drop-shadow(). */
      --bevel-depth-cast-sm: 0 4px 6px var(--bevel-cast);
      /* A thin object (a card) lying on another surface. */
      --bevel-depth-sheet:
        inset 0 1px 0 var(--bevel-highlight-strong),
        inset 0 0 0 1px oklch(0 0 0 / 0.12),
        0 -1px 2px oklch(0 0 0 / 0.18);
    
      /* 4. Motion */
      --bevel-ease-out: cubic-bezier(0.23, 1, 0.32, 1);
      --bevel-ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
      /* Underdamped spring, ~8% overshoot. Pure CSS; pair with --bevel-duration-move or longer. */
      --bevel-ease-spring: linear(0, 0.06, 0.202, 0.38, 0.561, 0.723, 0.856, 0.955, 1.023, 1.062, 1.081, 1.083, 1.075, 1.062, 1.047, 1.032, 1.019, 1.009, 1.002, 0.997, 0.994, 0.993, 0.993, 0.994, 1);
      --bevel-duration-press: 100ms; /* a control giving under a finger */
      --bevel-duration-small: 200ms; /* hover lifts, tabs, small state changes */
      --bevel-duration-move: 300ms; /* objects moving: cards, drawers */
      --bevel-duration-ambient: 520ms; /* whole-scene changes: lighting */
      --bevel-stagger: 30ms;
      --bevel-lift: 3px; /* how far an object rises on hover */
      --bevel-perspective: 30rem; /* viewing distance for parts that turn in 3D (covers, flaps) */
      /* Unchosen items in a group sit back in shadow. */
      --bevel-dim: 0.82;
    
      /* 5. Shape */
      --bevel-radius-xs: 0.25rem;
      --bevel-radius-sm: 0.5rem;
      --bevel-radius-md: 0.625rem;
      --bevel-radius-lg: 1rem;
      --bevel-radius-xl: 1.5rem;
      --bevel-stitch-width: 1.5px;
      --bevel-stitch-inset: 0.375rem;
      /* Amounts: the figure, its secondary parts (currency, cents), labels, fine print. */
      --bevel-amount: 2.25rem;
      --bevel-amount-minor: 1.25rem;
      --bevel-label: 0.75rem;
      --bevel-fine: 0.6875rem;
      --bevel-tracking-label: 0.08em; /* stamped labels */
      --bevel-tracking-print: 0.06em; /* printed marks on cards */
    
      /* Paper and ink */
      --bevel-paper: oklch(0.975 0.008 85);
      --bevel-paper-foreground: oklch(0.28 0.02 260);
      --bevel-paper-muted: oklch(0.48 0.015 260);
      --bevel-paper-rule: oklch(0.78 0.04 240 / 0.45);
      --bevel-paper-margin: oklch(0.68 0.14 25 / 0.45);
      --bevel-fibre: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='240' height='240'%3E%3Cfilter id='f'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9 .35' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .18 0 0 0 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23f)'/%3E%3C/svg%3E");
      /* Ink soaks unevenly: mostly solid with pale specks. Used as a mask. */
      --bevel-ink-texture: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='i'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 -2.4 0 0 0 2.1'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23i)'/%3E%3C/svg%3E");
      --bevel-ink-red: oklch(0.52 0.19 27);
      --bevel-ink-blue: oklch(0.44 0.14 262);
      --bevel-ink-green: oklch(0.5 0.12 155);
      --bevel-ink-black: oklch(0.3 0.01 260);
      --bevel-ink-amber: oklch(0.55 0.13 65);
    
      /* Brass and ivory: instrument hardware and dial faces */
      --bevel-brass: oklch(0.76 0.11 82);
      --bevel-brass-edge: oklch(0.52 0.08 70);
      --bevel-ivory: oklch(0.95 0.025 90);
      --bevel-ivory-foreground: oklch(0.25 0.015 60);
      --bevel-needle: oklch(0.55 0.2 27);
    
      /* Desk stationery: legal pads, pens, pencils, exhibit stickers */
      --bevel-pad: oklch(0.95 0.075 98); /* yellow legal pad */
      --bevel-pad-foreground: oklch(0.27 0.03 255);
      --bevel-pad-rule: oklch(0.72 0.06 235 / 0.45);
      --bevel-pad-margin: oklch(0.62 0.16 25 / 0.55);
      --bevel-pen-red: oklch(0.52 0.2 27); /* correcting pen */
      --bevel-pen-blue: oklch(0.42 0.15 262); /* writing pen */
      --bevel-pencil: oklch(0.55 0.01 260); /* not written in ink yet */
      --bevel-sticker: oklch(0.9 0.12 95); /* exhibit sticker */
      --bevel-sticker-foreground: oklch(0.26 0.03 70);
      /* Handwriting. Bring a marker font (e.g. Permanent Marker) for the full effect; these are fallbacks. */
      --bevel-font-marker: "Permanent Marker", "Marker Felt", "Chalkboard SE", ui-rounded, "Arial Rounded MT Bold", system-ui, sans-serif;
    }
    
    .dark {
      --bevel-highlight: oklch(1 0 0 / 0.14);
      /* Paper under dim light, not inverted: it's still paper. */
      --bevel-paper: oklch(0.88 0.008 85);
      --bevel-ivory: oklch(0.86 0.022 90);
      --bevel-pad: oklch(0.86 0.07 98);
      --bevel-cast: oklch(0 0 0 / 0.55);
    }
    
    /* Depth */
    @utility bevel-recessed {
      background-color: var(--bevel-material-recess);
      box-shadow: var(--bevel-depth-recessed);
    }
    @utility bevel-flush {
      box-shadow: var(--bevel-depth-flush);
    }
    @utility bevel-raised {
      background-color: color-mix(in oklch, var(--bevel-material), white 7%);
      background-image: linear-gradient(to bottom, var(--bevel-highlight), transparent 60%);
      box-shadow: var(--bevel-depth-raised);
    }
    @utility bevel-pressed {
      background-color: color-mix(in oklch, var(--bevel-material), black 4%);
      background-image: none;
      box-shadow: var(--bevel-depth-pressed);
    }
    @utility bevel-object {
      box-shadow: var(--bevel-depth-object);
    }
    
    /* Materials */
    @utility bevel-leather {
      background-color: var(--bevel-material);
      background-image:
        var(--bevel-grain),
        linear-gradient(to bottom, var(--bevel-highlight), transparent 28%, transparent 72%, var(--bevel-shade));
      background-size: var(--bevel-grain-size) var(--bevel-grain-size), 100% 100%;
      background-blend-mode: soft-light, normal;
    }
    @utility bevel-plastic {
      background-color: var(--bevel-material);
      background-image:
        linear-gradient(115deg, var(--bevel-highlight-strong), transparent 40%),
        linear-gradient(to bottom, transparent 25%, oklch(0 0 0 / 0.45));
      box-shadow: var(--bevel-depth-sheet);
    }
    @utility bevel-metal {
      background-color: var(--bevel-material);
      background-image:
        linear-gradient(to bottom, var(--bevel-highlight-strong), transparent 45%, var(--bevel-shade) 100%),
        repeating-linear-gradient(90deg, oklch(1 0 0 / 0.06) 0 1px, transparent 1px 3px);
      box-shadow:
        inset 0 0 0 1px oklch(0 0 0 / 0.25),
        inset 0 1px 0 var(--bevel-highlight-strong);
    }
    
    /* Details */
    @utility bevel-deboss {
      text-shadow: var(--bevel-deboss);
    }
    @utility bevel-stitch {
      border-color: var(--bevel-stitch, oklch(0.88 0.05 82 / 0.8));
      border-style: dashed;
      border-width: var(--bevel-stitch-width);
      filter: drop-shadow(0 1px 0 var(--bevel-shade));
    }
    /* A lit fill sitting in a recess (meters, progress). Colour from --bevel-fill. */
    @utility bevel-fill {
      background-color: var(--bevel-fill);
      background-image: linear-gradient(to bottom, oklch(1 0 0 / 0.55), transparent 55%, oklch(0 0 0 / 0.18));
      box-shadow: 0 0 6px -1px var(--bevel-fill);
    }
    /* A small indicator light. Colour from --bevel-led. */
    @utility bevel-led {
      background-color: var(--bevel-led);
      background-image: radial-gradient(circle at 35% 30%, oklch(1 0 0 / 0.85), transparent 45%);
      box-shadow:
        0 0 6px var(--bevel-led),
        inset 0 -1px 1px oklch(0 0 0 / 0.35),
        0 1px 0 var(--bevel-highlight);
    }
    
    /* Paper: a sheet resting on the surface, with fibre in it. */
    @utility bevel-paper {
      background-color: var(--bevel-paper);
      background-image: var(--bevel-fibre);
      background-size: 240px 240px;
      color: var(--bevel-paper-foreground);
      box-shadow:
        0 1px 1px var(--bevel-cast),
        0 6px 14px -4px var(--bevel-cast);
    }
    /* Ink on paper: multiplies into the surface and soaks unevenly. Colour from --bevel-ink. */
    @utility bevel-ink {
      color: var(--bevel-ink, var(--bevel-ink-red));
      border-color: currentColor;
      mix-blend-mode: multiply;
      mask-image: var(--bevel-ink-texture);
      mask-size: 160px 160px;
    }
    /* Glass over an instrument window: one reflection from the light above. Put on an overlay element. */
    @utility bevel-glass {
      pointer-events: none;
      background-image: linear-gradient(165deg, var(--bevel-highlight-strong) 0%, transparent 38%, transparent 70%, var(--bevel-shade) 100%);
    }
    
    /* Material families: one per screen. Set on the screen's container. */
    [data-bevel-family="leather"] {
      --bevel-cast: oklch(0.25 0.03 50 / 0.22);
    }
    [data-bevel-family="paper"] {
      --bevel-cast: oklch(0.3 0.01 250 / 0.16);
    }
    .dark :is([data-bevel-family="leather"], [data-bevel-family="paper"]) {
      --bevel-cast: oklch(0 0 0 / 0.55);
    }
    
    /*
     * Ruled paper: rules every --bevel-rule-height and a margin line at
     * --bevel-rule-margin. Set --bevel-sheet for the paper colour. The ruling
     * scrolls with the content.
     */
    @utility bevel-ruled {
      background-color: var(--bevel-sheet, var(--bevel-paper));
      background-image:
        linear-gradient(
          to right,
          transparent calc(var(--bevel-rule-margin) - 1px),
          var(--bevel-sheet-margin, var(--bevel-paper-margin)) calc(var(--bevel-rule-margin) - 1px) calc(var(--bevel-rule-margin) + 1px),
          transparent calc(var(--bevel-rule-margin) + 1px)
        ),
        repeating-linear-gradient(
          to bottom,
          transparent 0 calc(var(--bevel-rule-height) - 1px),
          var(--bevel-sheet-rule, var(--bevel-paper-rule)) calc(var(--bevel-rule-height) - 1px) var(--bevel-rule-height)
        ),
        var(--bevel-fibre);
      background-size: 100% 100%, 100% 100%, 240px 240px;
      background-attachment: local;
      color: var(--bevel-paper-foreground);
      box-shadow:
        0 1px 1px var(--bevel-cast),
        0 6px 14px -4px var(--bevel-cast);
    }
  3. Copy contact-sheet.tsx into components/ui.

    components/contact-sheet.tsx
    "use client"
    
    import * as React from "react"
    import { cn } from "cn"
    
    type Mark = "pick" | "reject"
    
    type ContactSheetContextValue = {
      ids: string[]
      register: (id: string) => () => void
      marks: Record<string, Mark>
      setMark: (id: string, mark: Mark | null) => void
      focused: string | undefined
      setFocused: (id: string) => void
      move: (from: string, key: string) => void
    }
    
    const ContactSheetContext = React.createContext<ContactSheetContextValue | null>(null)
    
    function useContactSheet() {
      const context = React.useContext(ContactSheetContext)
      if (!context) throw new Error("ContactSheet parts must be inside <ContactSheet>")
      return context
    }
    
    type ContactSheetProps = Omit<React.ComponentProps<"div">, "defaultValue"> & {
      /** Marks by frame id. Unmarked frames are left out. */
      value?: Record<string, Mark>
      defaultValue?: Record<string, Mark>
      onValueChange?: (value: Record<string, Mark>) => void
      /** Frames per row. Arrow keys move by row with it. */
      columns?: number
    }
    
    /**
     * A contact sheet: every frame at once, marked with a grease pencil.
     * Circle the keepers, cross out the rest. Arrow keys move between frames;
     * P picks, X rejects, Backspace clears, Enter cycles. Give it an aria-label.
     */
    function ContactSheet({
      className,
      value,
      defaultValue = {},
      onValueChange,
      columns = 4,
      style,
      children,
      ...props
    }: ContactSheetProps) {
      const [ids, setIds] = React.useState<string[]>([])
      const [uncontrolled, setUncontrolled] = React.useState(defaultValue)
      const [focused, setFocused] = React.useState<string>()
      const marks = value ?? uncontrolled
      const frames = React.useRef<HTMLDivElement>(null)
    
      const register = React.useCallback((id: string) => {
        setIds((all) => (all.includes(id) ? all : [...all, id]))
        return () => setIds((all) => all.filter((x) => x !== id))
      }, [])
      const setMark = React.useCallback(
        (id: string, mark: Mark | null) => {
          const next = { ...marks }
          if (mark) next[id] = mark
          else delete next[id]
          setUncontrolled(next)
          onValueChange?.(next)
        },
        [marks, onValueChange]
      )
      const move = React.useCallback(
        (from: string, key: string) => {
          const i = ids.indexOf(from)
          const steps: Record<string, number> = { ArrowRight: 1, ArrowLeft: -1, ArrowDown: columns, ArrowUp: -columns }
          const to = key === "Home" ? 0 : key === "End" ? ids.length - 1 : i + (steps[key] ?? 0)
          const id = ids[to]
          if (to < 0 || !id) return
          setFocused(id)
          frames.current?.querySelector<HTMLElement>(`[data-frame-id="${CSS.escape(id)}"]`)?.focus()
        },
        [ids, columns]
      )
    
      const context = React.useMemo(
        () => ({ ids, register, marks, setMark, focused: focused ?? ids[0], setFocused, move }),
        [ids, register, marks, setMark, focused, move]
      )
      const picked = Object.values(marks).filter((m) => m === "pick").length
      return (
        <ContactSheetContext value={context}>
          <div
            role="group"
            data-slot="contact-sheet"
            className={cn("bevel-paper flex w-full flex-col gap-2 rounded-(--bevel-radius-xs) p-3", className)}
            style={{ "--contact-sheet-columns": columns, ...style } as React.CSSProperties}
            {...props}
          >
            <div
              ref={frames}
              className="grid grid-cols-[repeat(var(--contact-sheet-columns),minmax(0,1fr))] gap-y-2 bg-(--contact-sheet-film) px-1 py-4 shadow-(--bevel-depth-sheet)"
            >
              {children}
            </div>
            <p role="status" className="text-xs text-(--bevel-paper-muted)">
              {picked} picked of {ids.length}
            </p>
          </div>
        </ContactSheetContext>
      )
    }
    
    type ContactSheetFrameProps = Omit<React.ComponentProps<"button">, "id"> & {
      id: string
      /** What's in the frame, for its name: “Dunes at dusk”. */
      label: string
    }
    
    /** One frame. Its picture is the children. */
    function ContactSheetFrame({ className, id, label, children, onKeyDown, onFocus, ...props }: ContactSheetFrameProps) {
      const { ids, register, marks, setMark, focused, setFocused, move } = useContactSheet()
      React.useEffect(() => register(id), [id, register])
      const mark = marks[id]
      const number = ids.indexOf(id) + 1
      const cycle: Record<string, Mark | null> = { none: "pick", pick: "reject", reject: null }
      return (
        <button
          type="button"
          data-slot="contact-sheet-frame"
          data-frame-id={id}
          data-mark={mark}
          tabIndex={focused === id ? 0 : -1}
          aria-keyshortcuts="P X Backspace"
          onFocus={(event) => {
            onFocus?.(event)
            setFocused(id)
          }}
          onClick={() => setMark(id, cycle[mark ?? "none"]!)}
          onKeyDown={(event) => {
            onKeyDown?.(event)
            const key = event.key.toLowerCase()
            if (key === "p") setMark(id, mark === "pick" ? null : "pick")
            else if (key === "x") setMark(id, mark === "reject" ? null : "reject")
            else if (event.key === "Backspace" || event.key === "Delete") setMark(id, null)
            else if (["ArrowRight", "ArrowLeft", "ArrowUp", "ArrowDown", "Home", "End"].includes(event.key)) move(id, event.key)
            else return
            event.preventDefault()
          }}
          className={cn(
            "group/frame relative flex flex-col gap-1 px-1 text-left outline-none",
            "focus-visible:ring-2 focus-visible:ring-(--contact-sheet-sprocket)",
            className
          )}
          {...props}
        >
          {/* Sprocket holes above and below, as on 35mm film. */}
          <span aria-hidden className="absolute inset-x-1 -top-3 flex justify-around">
            {Array.from({ length: 4 }, (_, i) => (
              <span key={i} className="h-1.5 w-1 rounded-(--bevel-radius-xs) bg-(--contact-sheet-sprocket) opacity-80" />
            ))}
          </span>
          <span className="relative block aspect-[3/2] overflow-hidden bg-(--bevel-shade-strong) transition-opacity duration-(--bevel-duration-small) group-data-[mark=reject]/frame:opacity-45 motion-reduce:transition-none">
            {children}
          </span>
          {/* The edge-printed frame number starts the name: “3. Dusk, low sun, picked”. */}
          <span aria-hidden className="font-mono text-(length:--bevel-fine) text-(--contact-sheet-edge-print)">
            {number}
          </span>
          <span className="sr-only">
            {`${number}. ${label}${mark === "pick" ? ", picked" : mark === "reject" ? ", rejected" : ""}`}
          </span>
          {/* Grease pencil: a loose circle to keep, a cross to drop. */}
          <svg
            aria-hidden
            viewBox="0 0 100 70"
            preserveAspectRatio="none"
            className="pointer-events-none absolute inset-x-0 -top-1 aspect-[3/2] w-full overflow-visible"
          >
            <path
              d="M14 38 C10 14 40 4 62 7 C88 10 96 30 88 50 C80 66 40 70 22 60 C10 53 8 40 18 30"
              fill="none"
              stroke="var(--contact-sheet-pick)"
              strokeWidth="4"
              strokeLinecap="round"
              className="origin-center scale-90 opacity-0 transition-[opacity,scale] duration-(--bevel-duration-small) ease-(--bevel-ease-out) group-data-[mark=pick]/frame:scale-100 group-data-[mark=pick]/frame:opacity-100 motion-reduce:transition-none"
            />
            <path
              d="M18 12 L84 60 M82 10 L20 62"
              fill="none"
              stroke="var(--contact-sheet-reject)"
              strokeWidth="4"
              strokeLinecap="round"
              className="origin-center scale-90 opacity-0 transition-[opacity,scale] duration-(--bevel-duration-small) ease-(--bevel-ease-out) group-data-[mark=reject]/frame:scale-100 group-data-[mark=reject]/frame:opacity-90 motion-reduce:transition-none"
            />
          </svg>
        </button>
      )
    }
    
    export { ContactSheet, ContactSheetFrame }
    export type { ContactSheetProps, ContactSheetFrameProps }
  4. Copy contact-sheet.css into your styles.

    styles/contact-sheet.css
    /*
     * Contact sheet: a strip of film printed frame by frame, marked up with a
     * grease pencil: circled to keep, crossed out to drop.
     * Builds on bevel.css (paper, motion).
     */
    
    :root {
      --contact-sheet-film: oklch(0.17 0.01 60);
      --contact-sheet-edge-print: oklch(0.72 0.12 70); /* the orange edge printing */
      --contact-sheet-sprocket: oklch(0.93 0.01 90);
      --contact-sheet-pick: oklch(0.6 0.22 27); /* red grease pencil */
      --contact-sheet-reject: oklch(0.96 0.01 90); /* white grease pencil */
      --contact-sheet-columns: 4;
    }
    
    [data-slot="contact-sheet-frame"] {
      --bevel-ink: var(--contact-sheet-pick);
    }
  5. Import the stylesheets after Tailwind in your global CSS, foundation first.

    globals.css
    @import "./bevel.css";
    @import "./contact-sheet.css";

Anatomy

Each part is its own component. Leave out the ones you don't need.

Anatomy
<ContactSheet aria-label="Generated variants" value={marks} onValueChange={setMarks} columns={4}>
  <ContactSheetFrame id="v1" label="Dusk, low sun">
    <img src="/v1.png" alt="" />
  </ContactSheetFrame>
</ContactSheet>

Parts

PartRendersWhat it does
ContactSheetdiv (group)value, defaultValue, onValueChange: marks by frame id, “pick” or “reject”. columns. Counts the picks. Give it an aria-label.
ContactSheetFramebuttonid, label; the picture is its children. Named “3. Dusk, low sun, picked”. Click cycles; arrow keys move by frame and row, P picks, X rejects, Backspace clears.