Contact Sheet

No dependencies
Code format
React + Tailwind
import { useId, type CSSProperties } from 'react';

// Six frames of one roll, printed as two strips on a contact sheet. Each little
// photo is a few flat shapes in the film's own tones.
const FRAMES = [
  { value: '14', label: 'Frame 14, the sea', shapes: [['rect', 0, 0, 70, 30, 'hi2'], ['circle', 48, 18, 7, 'hi'], ['rect', 0, 30, 70, 18, 'lo'], ['rect', 6, 35, 26, 1.4, 'mid'], ['rect', 30, 41, 32, 1.4, 'mid']] },
  { value: '15', label: 'Frame 15, a portrait', shapes: [['rect', 0, 0, 70, 48, 'mid'], ['circle', 35, 20, 9, 'lo'], ['ellipse', 35, 48, 21, 14, 'lo'], ['rect', 50, 0, 20, 48, 'hi2']] },
  { value: '16', label: 'Frame 16, the hills', shapes: [['rect', 0, 0, 70, 48, 'hi'], ['poly', '0,48 0,30 18,16 34,30 46,20 70,36 70,48', 'mid'], ['poly', '0,48 0,38 26,28 50,40 70,34 70,48', 'lo']] },
  { value: '17', label: 'Frame 17, the city', shapes: [['rect', 0, 0, 70, 48, 'hi2'], ['rect', 6, 18, 11, 30, 'lo'], ['rect', 19, 8, 14, 40, 'lo2'], ['rect', 35, 22, 10, 26, 'lo'], ['rect', 47, 13, 17, 35, 'lo2'], ['rect', 22, 12, 2, 2, 'hi'], ['rect', 27, 20, 2, 2, 'hi'], ['rect', 51, 18, 2, 2, 'hi'], ['rect', 57, 26, 2, 2, 'hi']] },
  { value: '18', label: 'Frame 18, a tree', shapes: [['rect', 0, 0, 70, 34, 'hi'], ['rect', 0, 34, 70, 14, 'mid'], ['rect', 33, 22, 4, 16, 'lo'], ['circle', 35, 17, 13, 'lo2']] },
  { value: '19', label: 'Frame 19, the pier', shapes: [['rect', 0, 0, 70, 48, 'lo2'], ['rect', 0, 26, 70, 22, 'lo'], ['poly', '30,48 40,48 37,24 33,24', 'hi2'], ['circle', 35, 12, 3, 'hi']] },
] as const;
const LOOPS = [
  'M10 9C28 2 58 3 79 9C87 21 87 45 79 57C58 64 25 63 9 56C1 43 2 20 10 9C15 5 24 3 34 4',
  'M78 8C58 2 26 3 9 10C2 23 2 45 10 57C31 64 59 63 79 55C86 42 86 20 77 8C72 4 62 3 52 4',
  'M12 11C30 3 60 4 80 11C86 25 85 46 76 58C54 64 24 62 8 53C2 39 3 21 12 11C18 6 28 4 38 4',
  'M9 9C30 4 60 2 78 11C86 24 87 44 80 56C60 63 28 64 10 57C3 44 1 22 9 9C14 5 22 4 32 5',
  'M80 10C60 3 28 3 10 9C3 22 2 44 9 56C29 63 60 64 78 56C86 43 87 22 80 10C75 5 66 3 56 4',
  'M11 10C29 3 59 3 79 9C86 22 86 44 78 57C57 63 26 63 9 55C2 41 3 21 11 10C16 6 25 4 35 5',
];

const css = `
  .radio-contact-sheet { --paper: light-dark(#fbfcfd, #e5e6e9); --film: light-dark(#373a40, #121417); --hi: light-dark(#f3f4f7, #e5e6e9); --hi2: light-dark(#cbced3, #a7abb2); --mid: light-dark(#6d7179, #393c41); --lo2: light-dark(#50535b, #2e3035); --lo: light-dark(#42454c, #232529); --grease: light-dark(#f3f4f7, #f1f2f3); display: grid; justify-items: center; gap: 10px; }
  .radio-contact-sheet__sheet { display: grid; gap: 5px; padding: 7px; background: var(--paper); box-shadow: 0 1px 2px light-dark(rgba(0, 0, 0, 0.06), rgba(0, 0, 0, 0.2)), 0 8px 18px -6px light-dark(rgba(0, 0, 0, 0.14), rgba(0, 0, 0, 0.45)); transform: rotate(-1.5deg); }
  /* A strip: sprocket holes top and bottom, the edge printing, three frames. */
  .radio-contact-sheet__strip { position: relative; display: flex; gap: 6px; padding: 11px 6px 15px; background: var(--film); }
  .radio-contact-sheet__strip::before, .radio-contact-sheet__strip::after { content: ''; position: absolute; left: 3px; right: 3px; height: 4px; background: repeating-linear-gradient(90deg, color-mix(in oklab, var(--paper) 70%, var(--film)) 0 3px, transparent 3px 7px); border-radius: 1px; opacity: 0.55; }
  .radio-contact-sheet__strip::before { top: 3px; }
  .radio-contact-sheet__strip::after { bottom: 3px; }
  .radio-contact-sheet__frame { position: relative; display: block; width: 70px; height: 48px; cursor: pointer; }
  .radio-contact-sheet__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; pointer-events: none; }
  .radio-contact-sheet__photo { display: block; width: 100%; height: 100%; transition: filter 0.25s; }
  .radio-contact-sheet__frame:hover .radio-contact-sheet__photo { filter: brightness(1.18); }
  .radio-contact-sheet__frame:has(input:focus-visible) { outline: 2px solid var(--grease); outline-offset: 2px; }
  .radio-contact-sheet__edge { position: absolute; left: 1px; bottom: -11px; color: color-mix(in oklab, var(--paper) 55%, var(--film)); font: 700 6.5px/1 ui-monospace, 'SF Mono', Menlo, Consolas, monospace; letter-spacing: 0.06em; white-space: nowrap; }
  /* The grease pencil: one waxy loop per frame, drawn on when picked, smudged away when not. */
  .radio-contact-sheet__loop { position: absolute; left: -9px; top: -8px; width: 88px; height: 66px; overflow: visible; fill: none; stroke: var(--grease); stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; pointer-events: none; transform: rotate(var(--tilt)); }
  .radio-contact-sheet__loop path { stroke-dasharray: 1; stroke-dashoffset: 1; opacity: 0; filter: blur(0); transition: opacity 0.35s, filter 0.35s, stroke-dashoffset 0s 0.35s; }
  .radio-contact-sheet__loop path + path { stroke-width: 1.2; stroke: color-mix(in oklab, var(--grease) 55%, transparent); }
  .radio-contact-sheet__frame:has(input:checked) .radio-contact-sheet__loop path { opacity: 1; stroke-dashoffset: 0; transition: opacity 0s, filter 0s, stroke-dashoffset 0.55s cubic-bezier(0.45, 0.05, 0.3, 1); }
  .radio-contact-sheet__frame:has(input:checked) .radio-contact-sheet__loop path + path { transition-delay: 0s, 0s, 0.05s; }
  .radio-contact-sheet__frame:not(:has(input:checked)) .radio-contact-sheet__loop path { filter: blur(2px); }
  .radio-contact-sheet__frame:has(input:checked) { z-index: 1; }
  .radio-contact-sheet__caption { display: grid; color: light-dark(#50535b, #c6c8cd); font: 500 11.5px/1 ui-monospace, 'SF Mono', Menlo, Consolas, monospace; letter-spacing: 0.02em; }
  .radio-contact-sheet__caption > span { grid-area: 1 / 1; text-align: center; opacity: 0; transform: translateY(4px); transition: opacity 0.2s, transform 0.3s; }
  .radio-contact-sheet__caption b { color: light-dark(#373a40, #f1f2f3); font-weight: 700; }
  ${FRAMES.map((f, i) => `.radio-contact-sheet:has(.radio-contact-sheet__frame--${f.value} input:checked) .radio-contact-sheet__caption > :nth-child(${i + 1})`).join(',\n  ')} { opacity: 1; transform: none; transition-delay: 0.15s; }
  @media (prefers-reduced-motion: reduce) {
    .radio-contact-sheet * { transition: none !important; }
  }
`;

function Photo({ shapes }: { shapes: (typeof FRAMES)[number]['shapes'] }) {
  return (
    <svg className="radio-contact-sheet__photo" viewBox="0 0 70 48" aria-hidden="true" preserveAspectRatio="none">
      {shapes.map((s, i) => {
        const fill = `var(--${s[s.length - 1]})`;
        if (s[0] === 'rect') return <rect key={i} x={s[1]} y={s[2]} width={s[3]} height={s[4]} style={{ fill }} />;
        if (s[0] === 'circle') return <circle key={i} cx={s[1]} cy={s[2]} r={s[3]} style={{ fill }} />;
        if (s[0] === 'ellipse') return <ellipse key={i} cx={s[1]} cy={s[2]} rx={s[3]} ry={s[4]} style={{ fill }} />;
        return <polygon key={i} points={s[1] as string} style={{ fill }} />;
      })}
    </svg>
  );
}

export function Preview() {
  const name = useId();
  const strips = [FRAMES.slice(0, 3), FRAMES.slice(3)];
  return (
    <>
      <style href="kit-radios-contact-sheet" precedence="kit">
        {css}
      </style>
      <div className="radio-contact-sheet" role="radiogroup" aria-label="Cover photo">
        <div className="radio-contact-sheet__sheet">
          {strips.map((strip, s) => (
            <div key={s} className="radio-contact-sheet__strip">
              {strip.map((f) => {
                const i = FRAMES.indexOf(f);
                return (
                  <label key={f.value} className={`radio-contact-sheet__frame radio-contact-sheet__frame--${f.value}`} style={{ '--tilt': `${[-2, 1.5, -1, 2, -1.5, 1][i]}deg` } as CSSProperties}>
                    <input className="radio-contact-sheet__input" type="radio" name={name} value={f.value} aria-label={f.label} defaultChecked={i === 1} />
                    <Photo shapes={f.shapes} />
                    <span className="radio-contact-sheet__edge" aria-hidden="true">
                      ▸{f.value}A
                    </span>
                    <svg className="radio-contact-sheet__loop" viewBox="0 0 88 66" aria-hidden="true">
                      <path pathLength={1} d={LOOPS[i]} />
                      <path pathLength={1} d={LOOPS[i]} transform="translate(1.5 1.2)" />
                    </svg>
                  </label>
                );
              })}
            </div>
          ))}
        </div>
        <p className="radio-contact-sheet__caption" aria-hidden="true">
          {FRAMES.map((f) => (
            <span key={f.value}>
              Cover · <b>frame {f.value}A</b>
            </span>
          ))}
        </p>
      </div>
    </>
  );
}

Free to use in any project, commercial included. MIT-0, no attribution required. Licence

Install with shadcn

npx shadcn@latest add @movin/radio-contact-sheet
Source on GitHub

About this component

A cover photo picker on a darkroom contact sheet: two strips of film with sprocket holes and edge numbers, and a grease pencil that loops the frame you pick while the old mark smudges away.

Dependencies
None
Difficulty
Intermediate
Triggers
click, state change, draw, hover
Techniques
stroke dashoffset, css transition, filter, svg
Reduced motion
Respected
Theme
Light and dark (follows your page's color-scheme)

A role="radiogroup" labelled "Cover photo" around native radio inputs sharing one name, so the arrow keys move the choice and Tab enters the group once. Each frame's input is named by what is in it (such as "Frame 15, a portrait"), and focus outlines the frame. The caption is aria-hidden. Under prefers-reduced-motion the loop appears at once.

More radios

See all
Eye Chart
Fairground
Fortune
Glassware