WebberUI

Contact Sheet Gallery

A grid gallery laid out like a photographic contact sheet, complete with sprocket-hole film edges and frame numbers; hover circles a frame in grease pencil, and the selected frame enlarges into a work print with a handwritten annotation.

Loading preview…
npx shadcn@latest add https://webberui.com/r/contact-sheet-gallery.json

Playground

Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.

4
0.05
<ContactSheetGallery />

Installation

npx shadcn@latest add https://webberui.com/r/contact-sheet-gallery.json

Or, once registries are configured in components.json, install it as @webberui/contact-sheet-gallery.

Usage

import {
  ContactSheetGallery,
  ContactSheetFrame,
} from "@/components/ui/contact-sheet-gallery";

<ContactSheetGallery columns={4} filmLabel="WEBBER 400TX">
  <ContactSheetFrame id="a" annotation="This one ✓">
    <img src="/photos/a.jpg" alt="Street scene" />
  </ContactSheetFrame>
  <ContactSheetFrame id="b">
    <img src="/photos/b.jpg" alt="Portrait" />
  </ContactSheetFrame>
</ContactSheetGallery>;

A frame can hold an <img>, a gradient div, or any node; an <img> is automatically object-cover'd to fill the frame. Clicking a frame enlarges it into a work print, and clicking the background, pressing the close button, or hitting Esc all collapse it.

Controlled selection

Take over the selected state with selectedId / onSelectChange to sync from outside or open and close the work print programmatically:

const [selected, setSelected] = React.useState<string | null>(null);

<ContactSheetGallery selectedId={selected} onSelectChange={setSelected}>
  {/* ...ContactSheetFrame */}
</ContactSheetGallery>;

Props

ContactSheetGallery

PropTypeDefaultDescription
childrenReactNodePut ContactSheetFrame children here
columnsnumber4Frames per row
selectedIdstring | nullControlled id of the selected frame (null for none)
defaultSelectedIdstring | nullnullInitially selected frame in uncontrolled mode
onSelectChange(id: string | null) => voidFires when the selected frame changes (including null on close)
filmLabelstring"WEBBER 400"Film-edge lettering printed along the bottom
markColorstring"#e2482f"Color of the grease pencil marks
staggernumber0.05Interval between frames on reveal (seconds)

ContactSheetFrame

PropTypeDefaultDescription
idstringUnique key
frameNumberstring | numberautomaticFrame number; numbered by position when unspecified, and values like "24A" are accepted
annotationReactNodeHandwritten-style annotation shown when enlarged
labelstring第 N 格Frame description for assistive technology; falls back to 第 N 格 ("frame N") when not supplied — the built-in default is Traditional Chinese, so pass this prop to word it in your own language
childrenReactNodeImage content inside the frame

How it works

  • Film layout: the sprocket-hole edge strips are tiled with an SVG pattern, so their width adapts and is independent of the frame count; the bottom edge is printed with the film-stock marking and a direction arrow.
  • Grease pencil circling: on hover or keyboard focus, a crayon ellipse is drawn in a single stroke through SVG pathLength; the mark uses non-scaling-stroke, so it hugs a frame of any proportion while keeping a constant line width.
  • Work print: selecting a frame enlarges it into a work print through a shared layoutId, with the image "pulled out" of its original frame and an empty frame left behind; the annotation fades in in a handwritten face as its underline is drawn.
  • The colors stay on a neutral scale throughout, with markColor as the only accent, adjustable per project.

Accessibility

  • Every frame is a native <button> supporting Enter / Space to select, carrying aria-pressed and an aria-label (falling back to 第 N 格, "frame N", when label is not supplied).
  • The work print is role="dialog" + aria-modal, focusing the close button automatically when it opens and returning focus to the original frame once closed; Esc, clicking the background, and the close button all collapse it.
  • When the user has "reduce motion" enabled at the system level, the reveal, the circling stroke, and the enlargement offset are disabled and it switches straight to the static layout.

On this page