Grid Systems

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.

Bring this into your project

Run this command in your project folder. The CLI adds the source and dependencies; then follow the usage example below.

npx webberui@latest add contact-sheet-gallery.en

New project or installation trouble? Start here →

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
Active edited usageDefault props
<ContactSheetGallery/>

The complete example includes the preview layout and data. Install the component first, then paste this file into your project.

Installation

npx webberui@latest add https://webberui.com/r/contact-sheet-gallery.en.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
childrenReactNode—Put ContactSheetFrame children here
columnsnumber4Frames per row
selectedIdstring | null—Controlled id of the selected frame (null for none)
defaultSelectedIdstring | nullnullInitially selected frame in uncontrolled mode
onSelectChange(id: string | null) => void—Fires 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
idstring—Unique key
frameNumberstring | numberautomaticFrame number; numbered by position when unspecified, and values like "24A" are accepted
annotationReactNode—Handwritten-style annotation shown when enlarged
labelstringQuestion N FrameFrame description for assistive technology; falls back to Question N Frame ("frame N") when not supplied — the English source includes this default, so pass this prop to word it in your own language
childrenReactNode—Image 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 Question N Frame, "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.