Reading size · HugeReading size · LargeReading size · DefaultReading size · SmallReading size · Tiny
Contact Sheet
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>
</>
);
}Markup where the component goes, CSS in your stylesheet. No JavaScript.
HTML
<div class="radio-contact-sheet" role="radiogroup" aria-label="Cover photo">
<div class="radio-contact-sheet__sheet">
<div class="radio-contact-sheet__strip">
<label class="radio-contact-sheet__frame radio-contact-sheet__frame--14" style="--tilt:-2deg">
<input class="radio-contact-sheet__input" type="radio" aria-label="Frame 14, the sea" name="radio-contact-sheet" value="14"/>
<svg class="radio-contact-sheet__photo" viewBox="0 0 70 48" aria-hidden="true" preserveAspectRatio="none">
<rect x="0" y="0" width="70" height="30" style="fill:var(--hi2)"></rect>
<circle cx="48" cy="18" r="7" style="fill:var(--hi)"></circle>
<rect x="0" y="30" width="70" height="18" style="fill:var(--lo)"></rect>
<rect x="6" y="35" width="26" height="1.4" style="fill:var(--mid)"></rect>
<rect x="30" y="41" width="32" height="1.4" style="fill:var(--mid)"></rect>
</svg>
<span class="radio-contact-sheet__edge" aria-hidden="true">▸14A</span>
<svg class="radio-contact-sheet__loop" viewBox="0 0 88 66" aria-hidden="true">
<path pathLength="1" d="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"></path>
<path pathLength="1" d="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" transform="translate(1.5 1.2)"></path>
</svg>
</label>
<label class="radio-contact-sheet__frame radio-contact-sheet__frame--15" style="--tilt:1.5deg">
<input class="radio-contact-sheet__input" type="radio" aria-label="Frame 15, a portrait" name="radio-contact-sheet" checked="" value="15"/>
<svg class="radio-contact-sheet__photo" viewBox="0 0 70 48" aria-hidden="true" preserveAspectRatio="none">
<rect x="0" y="0" width="70" height="48" style="fill:var(--mid)"></rect>
<circle cx="35" cy="20" r="9" style="fill:var(--lo)"></circle>
<ellipse cx="35" cy="48" rx="21" ry="14" style="fill:var(--lo)"></ellipse>
<rect x="50" y="0" width="20" height="48" style="fill:var(--hi2)"></rect>
</svg>
<span class="radio-contact-sheet__edge" aria-hidden="true">▸15A</span>
<svg class="radio-contact-sheet__loop" viewBox="0 0 88 66" aria-hidden="true">
<path pathLength="1" d="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"></path>
<path pathLength="1" d="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" transform="translate(1.5 1.2)"></path>
</svg>
</label>
<label class="radio-contact-sheet__frame radio-contact-sheet__frame--16" style="--tilt:-1deg">
<input class="radio-contact-sheet__input" type="radio" aria-label="Frame 16, the hills" name="radio-contact-sheet" value="16"/>
<svg class="radio-contact-sheet__photo" viewBox="0 0 70 48" aria-hidden="true" preserveAspectRatio="none">
<rect x="0" y="0" width="70" height="48" style="fill:var(--hi)"></rect>
<polygon points="0,48 0,30 18,16 34,30 46,20 70,36 70,48" style="fill:var(--mid)"></polygon>
<polygon points="0,48 0,38 26,28 50,40 70,34 70,48" style="fill:var(--lo)"></polygon>
</svg>
<span class="radio-contact-sheet__edge" aria-hidden="true">▸16A</span>
<svg class="radio-contact-sheet__loop" viewBox="0 0 88 66" aria-hidden="true">
<path pathLength="1" d="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"></path>
<path pathLength="1" d="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" transform="translate(1.5 1.2)"></path>
</svg>
</label>
</div>
<div class="radio-contact-sheet__strip">
<label class="radio-contact-sheet__frame radio-contact-sheet__frame--17" style="--tilt:2deg">
<input class="radio-contact-sheet__input" type="radio" aria-label="Frame 17, the city" name="radio-contact-sheet" value="17"/>
<svg class="radio-contact-sheet__photo" viewBox="0 0 70 48" aria-hidden="true" preserveAspectRatio="none">
<rect x="0" y="0" width="70" height="48" style="fill:var(--hi2)"></rect>
<rect x="6" y="18" width="11" height="30" style="fill:var(--lo)"></rect>
<rect x="19" y="8" width="14" height="40" style="fill:var(--lo2)"></rect>
<rect x="35" y="22" width="10" height="26" style="fill:var(--lo)"></rect>
<rect x="47" y="13" width="17" height="35" style="fill:var(--lo2)"></rect>
<rect x="22" y="12" width="2" height="2" style="fill:var(--hi)"></rect>
<rect x="27" y="20" width="2" height="2" style="fill:var(--hi)"></rect>
<rect x="51" y="18" width="2" height="2" style="fill:var(--hi)"></rect>
<rect x="57" y="26" width="2" height="2" style="fill:var(--hi)"></rect>
</svg>
<span class="radio-contact-sheet__edge" aria-hidden="true">▸17A</span>
<svg class="radio-contact-sheet__loop" viewBox="0 0 88 66" aria-hidden="true">
<path pathLength="1" d="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"></path>
<path pathLength="1" d="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" transform="translate(1.5 1.2)"></path>
</svg>
</label>
<label class="radio-contact-sheet__frame radio-contact-sheet__frame--18" style="--tilt:-1.5deg">
<input class="radio-contact-sheet__input" type="radio" aria-label="Frame 18, a tree" name="radio-contact-sheet" value="18"/>
<svg class="radio-contact-sheet__photo" viewBox="0 0 70 48" aria-hidden="true" preserveAspectRatio="none">
<rect x="0" y="0" width="70" height="34" style="fill:var(--hi)"></rect>
<rect x="0" y="34" width="70" height="14" style="fill:var(--mid)"></rect>
<rect x="33" y="22" width="4" height="16" style="fill:var(--lo)"></rect>
<circle cx="35" cy="17" r="13" style="fill:var(--lo2)"></circle>
</svg>
<span class="radio-contact-sheet__edge" aria-hidden="true">▸18A</span>
<svg class="radio-contact-sheet__loop" viewBox="0 0 88 66" aria-hidden="true">
<path pathLength="1" d="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"></path>
<path pathLength="1" d="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" transform="translate(1.5 1.2)"></path>
</svg>
</label>
<label class="radio-contact-sheet__frame radio-contact-sheet__frame--19" style="--tilt:1deg">
<input class="radio-contact-sheet__input" type="radio" aria-label="Frame 19, the pier" name="radio-contact-sheet" value="19"/>
<svg class="radio-contact-sheet__photo" viewBox="0 0 70 48" aria-hidden="true" preserveAspectRatio="none">
<rect x="0" y="0" width="70" height="48" style="fill:var(--lo2)"></rect>
<rect x="0" y="26" width="70" height="22" style="fill:var(--lo)"></rect>
<polygon points="30,48 40,48 37,24 33,24" style="fill:var(--hi2)"></polygon>
<circle cx="35" cy="12" r="3" style="fill:var(--hi)"></circle>
</svg>
<span class="radio-contact-sheet__edge" aria-hidden="true">▸19A</span>
<svg class="radio-contact-sheet__loop" viewBox="0 0 88 66" aria-hidden="true">
<path pathLength="1" d="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"></path>
<path pathLength="1" d="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" transform="translate(1.5 1.2)"></path>
</svg>
</label>
</div>
</div>
<p class="radio-contact-sheet__caption" aria-hidden="true">
<span>
Cover ·
<b>frame 14A</b>
</span>
<span>
Cover ·
<b>frame 15A</b>
</span>
<span>
Cover ·
<b>frame 16A</b>
</span>
<span>
Cover ·
<b>frame 17A</b>
</span>
<span>
Cover ·
<b>frame 18A</b>
</span>
<span>
Cover ·
<b>frame 19A</b>
</span>
</p>
</div>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; }
.radio-contact-sheet:has(.radio-contact-sheet__frame--14 input:checked) .radio-contact-sheet__caption > :nth-child(1),
.radio-contact-sheet:has(.radio-contact-sheet__frame--15 input:checked) .radio-contact-sheet__caption > :nth-child(2),
.radio-contact-sheet:has(.radio-contact-sheet__frame--16 input:checked) .radio-contact-sheet__caption > :nth-child(3),
.radio-contact-sheet:has(.radio-contact-sheet__frame--17 input:checked) .radio-contact-sheet__caption > :nth-child(4),
.radio-contact-sheet:has(.radio-contact-sheet__frame--18 input:checked) .radio-contact-sheet__caption > :nth-child(5),
.radio-contact-sheet:has(.radio-contact-sheet__frame--19 input:checked) .radio-contact-sheet__caption > :nth-child(6) { opacity: 1; transform: none; transition-delay: 0.15s; }
@media (prefers-reduced-motion: reduce) {
.radio-contact-sheet * { transition: none !important; }
}Goal: Create a "Contact Sheet" radio.
Visual behavior: 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.
Technique:
- Native radio inputs sharing one name inside a labelled role="radiogroup", visually hidden but focusable; each option is a <label> wrapping its input, so the arrow keys and clicks both work.
- The scene reacts with :has() on the root, one selector per option (such as :has(.opt:nth-child(2) input:checked)), so no script is needed.
- Each frame is a small SVG photo built from a few flat shapes in the film's own tones, set per theme so the prints look the same in light and dark.
- The grease pencil is a hand-drawn loop with a thinner second pass beside it, drawn in by its dash offset. When a frame loses the pick its loop blurs and fades first and only then resets its dash, so the old mark smudges instead of undrawing.
Accessibility: 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.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Contact Sheet" radio.
Visual behavior: 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.
Technique:
- Native radio inputs sharing one name inside a labelled role="radiogroup", visually hidden but focusable; each option is a <label> wrapping its input, so the arrow keys and clicks both work.
- The scene reacts with :has() on the root, one selector per option (such as :has(.opt:nth-child(2) input:checked)), so no script is needed.
- Each frame is a small SVG photo built from a few flat shapes in the film's own tones, set per theme so the prints look the same in light and dark.
- The grease pencil is a hand-drawn loop with a thinner second pass beside it, drawn in by its dash offset. When a frame loses the pick its loop blurs and fades first and only then resets its dash, so the old mark smudges instead of undrawing.
Accessibility: 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.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
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.