174

Contact Sheet Gallery

£7
or the whole Vault, £99
Open in new tab

A photo gallery as contact sheets on an endless light table: drag, fling and pinch across roll after roll, zoom from an overview of a dozen sheets to one frame filling the stage, and tick off every frame you have seen.

advanced
10 more details
draggablemomentuminfinite-loopzoomtouch-gestureslightboxkeyboard-navigationfocus-managementreduced-motionprogressive-enhancement

Released

About this effect

Your set sits on a light table as a stack of contact sheets, the way a photographer reviews a shoot: 48 frames to a roll, each in a thin black rebate with its number printed underneath. Grab the table and drag it, and it keeps gliding when you let go. Scroll with a trackpad or wheel to pan, and pinch or ctrl + scroll to zoom right where the pointer is, from an overview of a dozen rolls with the frames no bigger than stamps down to a single photograph filling the stage.

Read the full effect overview

The table never runs out. Past the edge of the first sheet is roll 2, numbered on from 49, then roll 3 from 97, and every roll lays the photographs out in its own order, so neighbouring sheets never look the same and the repeat reads as more film rather than a loop. Zoom is the viewer: as a frame grows, its full-size photograph fades in over the thumbnail and its caption appears under it. Double-click or double-tap a frame and the camera flies in to fit it; Fit, the 0 key or Escape takes you back to the whole roll. Every frame you looked at gets a red grease-pencil tick, so you can see where you have been. Prefer one sheet? Switch the table to Single sheet and its edges give a little and spring back instead of carrying on.

Contact Sheet Gallery - GSAP animation effect preview

What's included

14 items
  • Endless light table of contact sheets: drag with momentum, trackpad and wheel panning, in every direction
  • Each repeat is a new roll with its own number, continuing frame numbers and its own order of photographs
  • Zoom with pinch, ctrl or cmd + scroll, + and − buttons or keys, always anchored where you point
  • Overview of a dozen rolls down to one frame filling the stage, with smooth, eased zoom and pan
  • Close up, the full-size photograph loads in and the caption appears; only frames that are big and on screen load, a few at a time
  • Double-click, double-tap or Enter flies the camera to fit a frame; Fit, 0 or Escape returns to the whole roll
  • Grease-pencil ticks on every frame you have looked at, kept as you travel
  • Single sheet option with soft, elastic edges, and a ready-made control row with zoom, Fit and an Infinite / Single sheet toggle
  • Frame numbers and roll labels stay readable at every zoom
  • Keyboard cursor that walks frame to frame and across rolls, with a live region announcing each frame
  • Phones: one-finger pan, two-finger pinch about the fingers, double-tap to focus; the page itself never scrolls or zooms
  • Plays well inside a longer page: an option keeps the wheel with the page until the visitor engages the table
  • Reduced motion: every move lands at once, no momentum or flights; no JavaScript: a grid of links to the full images
  • Small API (focus, fit, zoom, goTo, markViewed, setInfinite) and focus, fit and mode events

Perfect for

4 use cases
  • Photographer portfolios and shoot archives with dozens of frames per set
  • Travel and architecture galleries where scanning the whole set matters
  • Event and wedding proofing pages where visitors review every frame
  • Press and media kits that offer a full image library at a glance

How it works

2 sections

The gallery is an ordinary ordered list of links: each link points at the full-size photograph and wraps a small thumbnail whose alt text doubles as the caption. Without JavaScript that list is the whole gallery, a grid of links to the full images, and with JavaScript it stays in the page for screen readers while the light table is drawn over it.

The table is one layer that GSAP moves and scales from a single animation loop, easing every pan and zoom towards where you asked it to go and carrying a flick on as momentum. However far you travel, it is drawn from the same fixed set of cells, which are handed the next frames as they come into view, so a long session costs no more than the first second. Camera flights to a frame and back to the whole roll are GSAP tweens that zoom and pan together, the ticks draw themselves in, and full-size files are fetched only for the frames that are large on screen. Everything the script adds, from the table to the cursor and the loads in flight, is removed again on revert().

Difficulty Advanced
Includes HTML + JS + CSS source, documentation, AI setup prompt, lifetime updates

Lighthouse, as measured

Google Lighthouse on this effect's demo, from the latest scan. A measurement of the demo as shipped, not a promise for your page.

Accessibility
100
Best practices
100

No performance score, on purpose. That figure depends on how you deploy: your server's compression and caching, your CDN, the connection and the device doing the test, none of which the code controls. The same page can score very differently on two consecutive runs, so measure it where it will live.

The Vault opens this source and the whole library.

Each product gives you the HTML, CSS and JavaScript source, the full documentation and an AI setup prompt, licensed for unlimited personal and commercial projects. Every paid product is tested for desktop and mobile layouts, reduced-motion handling, animation cleanup and keyboard controls wherever there is something to operate.

One payment, over 350 products, and future additions to the library.

Documentation

Quick Start

1. Add to your HTML <head>:

In your downloadThe working code for this step, in index.html.

2. Add before the closing </body> tag:

In your downloadThe working code for this step, in index.html.

3. Add the gallery where you want it. One <li> per photograph: the link points at the full-size file, the image is the thumbnail. The control row is optional.

In your downloadThe working code for this step, in index.html.

The section fills its parent's height by default. To give it its own height, set --sheet-height on it (for example style="--sheet-height: 80vh").

Using It With Your Own Design

What the effect needs from your markup: a [data-contact-sheet] element containing .contact-sheet__grid with one .contact-sheet__frame link per photograph (inside .contact-sheet__scroller). Each link's href is the full-size image shown close up; its <img> is the thumbnail used on the table. Give every thumbnail real width and height attributes in the photograph's proportions: the script reads them to shape each print (portrait frames sit centred in a landscape slot). The caption is the link's data-caption, falling back to the image's alt. The table, its sheets, frame numbers, captions, ticks and keyboard cursor are generated by the script, so there is nothing else to write.

The list order is roll 1. Later rolls reuse the same photographs in a different, fixed order for each roll, so the same table always looks the same.

What is only the demo's styling: Mona Sans, the grey table, the paper colour of each sheet, the black frame rebate, the red tick, the roll labels and the control row styling are decoration. Change the custom properties at the top of style.css (--page, --table, --ink, --ink-faint, --film, --pencil) to reskin it. The control row and its hint are optional: delete them, or put data-sheet-action on your own buttons anywhere inside the section (zoom-in, zoom-out, fit, infinite, single).

CSS the effect depends on:

  • .contact-sheet must be position: relative with a real height and overflow: hidden: the table is drawn inside it and the camera fits the sheets to its size.
  • The sheet geometry is fixed in the script (150 × 100 slots with a 16px number line, 14px and 10px gaps, 40px gutters between sheets), and the CSS sizes match it: .contact-sheet__cell-v is 116px tall, .contact-sheet__print is 100px tall and .contact-sheet__meta sits at 102px. Change those together with the constants at the top of script.js.
  • .contact-sheet__table has touch-action: none, which is what lets one finger pan and two fingers pinch without scrolling or zooming the page. Nothing outside the stage is affected.
  • .contact-sheet__world is positioned at the top left with transform-origin: 0 0. Labels inside it use --s (the current zoom, set by the script) to keep a readable size: --k scales frame numbers between 8px and 12.5px on screen.
  • --inset-top, --inset-bottom and --inset-side on .contact-sheet keep the centre of the view, and fitted frames, clear of the control row and anything pinned at the foot of the stage.

Options

Set these as data- attributes on the [data-contact-sheet] element.

Attribute Values Default Description
data-infinite true, false true false shows one sheet with soft, elastic edges instead of an endless table
data-cols 1 to 24 8 (6 on tall stages) Frames per row on each sheet
data-min-zoom 0.1 to 1 0.45 Furthest zoom out, as a fraction of the Fit view (one whole roll). It never goes so far that the table would need more than about 460 cells
data-max-zoom 0.5 to 4 1.25 Closest zoom in, as a multiple of one frame filling the stage
data-full-at Pixels 360 On-screen width at which a frame swaps its thumbnail for the full-size file; it swaps back below about 70% of this
data-loads 1 to 8 3 Full-size files loading at once, nearest the centre first
data-duration Seconds 0.8 Base length of a camera flight; long or deep flights take a little longer
data-wheel auto, pan, page auto What a plain vertical wheel does. pan always pans the table. page always scrolls the page. auto pans when the table fills the window, and otherwise only once the visitor has pressed, dragged, zoomed or focused the table, so a gallery in a long page never traps the scroll. Horizontal scroll and ctrl + scroll always belong to the table

CSS custom properties on .contact-sheet:

Property Default Description
--sheet-height 100% Height of the gallery stage
--inset-top 60px (108px on phones) Space kept clear above the centre of the view
--inset-bottom 20px (64px on phones) Space kept clear below it
--inset-side 24px (14px on phones) Space kept clear either side when fitting

Accessibility

  • Keyboard: the table is one tab stop with a visible focus outline. Arrow keys move a ringed cursor from frame to frame, across into the next roll at a sheet's edge, and the view follows it. Enter or Space flies in to fit the cursor's frame; while you are zoomed in on a frame the arrows step to the neighbouring frame and fly to it, like a viewer. Shift + arrows pan by a quarter of the view. + and - zoom (about the cursor when it is on screen), and 0 or Escape fits the whole roll. The control row buttons are ordinary buttons with labels and pressed states.
  • Screen readers: the list of links stays in the page, visually hidden, so the photographs, their captions and links to the full-size files remain readable and in order. The table is labelled as a light table and described by the hint line; a polite live region announces the cursor's frame number, roll, caption and whether it has been viewed. The drawn table itself is hidden from assistive technology.
  • Touch: one finger pans with momentum, two fingers pinch about their midpoint, and a double-tap flies to a frame (double-tap it again to step back out). Only the stage blocks the browser's own gestures; the rest of the page scrolls and zooms normally.
  • Wheel in a page: see data-wheel. Inside a full-window demo the wheel pans; in a page section the page keeps the wheel until the visitor engages the table.
  • Reduced motion: no momentum, no easing and no flights. Every pan, zoom, focus and fit lands at once, captions and full-size images appear without a fade, and ticks appear without being drawn. Changing the preference while the page is open rebuilds the gallery and keeps the view and the ticks.
  • No JavaScript: the gallery is the full grid, and every frame is an ordinary link to its full-size image.

Photographs

The demo ships 48 photographs as baked WebP derivatives in assets/img/: fNN.webp thumbnails (360px on the long edge) and fNN-full.webp close-up images (1600px on the long edge), cropped to 3:2 or 2:3 with a slight, even saturation trim so the set reads as one roll. They are from Pexels and Unsplash and used under the Pexels License and the Unsplash License. Replace them with your own photographs for production; the gallery works with any number.

To regenerate or change them, edit assets/img-manifest.json (each entry names a source, an output name, a size and an optional grade) and run bun scripts/build-template-assets.ts contact-sheet-gallery from a GSAP Vault checkout, or export your own thumbnails and full images at similar sizes.

Frame Subject Source Original
1 Viewpoint at dusk, the bridge on the horizon Pexels https://www.pexels.com/photo/17929440/
2 Tram rail curving through grey cobbles Pexels https://www.pexels.com/photo/32063429/
3 Brick terrace, a red door and a green one Pexels https://www.pexels.com/photo/2937800/
4 Steps up to a brick entrance with metal numerals Pexels https://www.pexels.com/photo/3964854/
5 Rusted depot doors standing open Pexels https://www.pexels.com/photo/38467749/
6 Bicycle against a red corrugated wall Pexels https://www.pexels.com/photo/28216172/
7 Upper deck of the ferry, crossing the river Pexels https://www.pexels.com/photo/30942322/
8 Harbour crane and bridge reflected at sunset Pexels https://www.pexels.com/photo/19881642/
9 Orange facade rolling against a teal sky Pexels https://www.pexels.com/photo/13981699/
10 Tiled corner cutting across a blue sky Pexels https://www.pexels.com/photo/5504428/
11 Triangulated panels curving like a wave Pexels https://www.pexels.com/photo/16812375/
12 Spiral balconies under a dark overhang Pexels https://www.pexels.com/photo/29099985/
13 Rusted steel curves against flat blue Unsplash https://images.unsplash.com/photo-1486718448742-163732cd1544
14 Scaled teal facade sweeping round Unsplash https://images.unsplash.com/photo-1493397212122-2b85dda8106b
15 Brutalist block against a teal sky Pexels https://www.pexels.com/photo/4328661/
16 Angular glass museum in bright sun Unsplash https://images.unsplash.com/photo-1487958449943-2429e8be8625
17 Faceted glass roof edge, empty sky Pexels https://www.pexels.com/photo/3137055/
18 White overhang with a glass underside Pexels https://www.pexels.com/photo/3137038/
19 Tall white fins rising into a pale sky Pexels https://www.pexels.com/photo/31732696/
20 Stepped white facade above a glass ground floor Pexels https://www.pexels.com/photo/17852632/
21 White modernist villa in hard sun Unsplash https://images.unsplash.com/photo-1523217582562-09d0def993a6
22 Spiral gallery ramps under a glass dome Unsplash https://images.unsplash.com/photo-1439337153520-7082a56a81f4
23 Sea pool from the railing, swimmers below Pexels https://www.pexels.com/photo/6775404/
24 Seaside lido at sunset, beach huts beyond Pexels https://www.pexels.com/photo/19847806/
25 Moored boats, the town on the hill Pexels https://www.pexels.com/photo/18722573/
26 A small boat drawing its wake across the firth Pexels https://www.pexels.com/photo/7519087/
27 Calm sea meeting a straight beach at dusk Pexels https://www.pexels.com/photo/5851478/
28 Dune coast in low gold light Pexels https://www.pexels.com/photo/5851473/
29 Green dunes under a pink dawn Pexels https://www.pexels.com/photo/5851475/
30 Sand path through dune grass, from above Pexels https://www.pexels.com/photo/5851474/
31 White sand bay seen through the pines Pexels https://www.pexels.com/photo/19456041/
32 Rocky cliffs and turquoise surf Pexels https://www.pexels.com/photo/18729882/
33 Two figures walking towards the boulders Pexels https://www.pexels.com/photo/8204443/
34 Granite slab among the boulders and surf Pexels https://www.pexels.com/photo/8204444/
35 Small on the shore, the sun low Pexels https://www.pexels.com/photo/8204487/
36 Silhouettes on the rock under a red sky Pexels https://www.pexels.com/photo/8204501/
37 Surf breaking on a dark shore Pexels https://www.pexels.com/photo/5851472/
38 Rock coast under a heavy sky Pexels https://www.pexels.com/photo/7112541/
39 Flowering heather above a blue sea Pexels https://www.pexels.com/photo/18720264/
40 Copper stills in a glazed stillhouse Pexels https://www.pexels.com/photo/34635802/
41 Stone house across still water Unsplash https://images.unsplash.com/photo-1464146072230-91cabc968266
42 Grass path between rows of apple trees Pexels https://www.pexels.com/photo/28387811/
43 Lone tree and track in heavy fog Pexels https://www.pexels.com/photo/36344918/
44 Pine trunks receding into fog Pexels https://www.pexels.com/photo/15222306/
45 Misty conifer valley Pexels https://www.pexels.com/photo/10762369/
46 Turquoise lake winding through the forest Pexels https://www.pexels.com/photo/32837501/
47 Ridge rising out of a sea of cloud Pexels https://www.pexels.com/photo/26953466/
48 Glass-partitioned corridor in daylight Unsplash https://images.unsplash.com/photo-1497366754035-f200968a6e72

Dependencies

Required:

  • GSAP 3.12+ (core only). One GSAP ticker drives the camera, the momentum and the elastic edges; GSAP tweens fly the camera and draw the ticks, and GSAP's context and media queries handle the reduced-motion rebuild and teardown. The demo is pinned to GSAP 3.15.0.

No plugins, no Lenis and no other libraries.

Browser Support and Performance

All current browsers. Trackpad pinch arrives as ctrl + wheel in Chrome, Edge and Firefox and as gesture events in Safari; both are handled. The table moves by transforming one layer, so panning costs the same at any distance; cells are only reassigned when a new row or column comes into view, and the pool is capped at about 460 cells (around 1,500 elements in the demo) however far out you zoom. The layer is promoted only while it moves and released at rest, so frames repaint sharp at the zoom you stop at. Keep thumbnails small (around 10 to 25 KB each): they are what the table shows at every distance, and all of them load up front.

Also in your download: the full source, worked examples, the events and programmatic API, and the class reference.

£7