Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Photo album

New

A handheld tablet’s photo album: twelve painted pictures a page, a gliding cyan selection, a large view, and a camera whose shutter flashes before “Save in album?”.

photo-album/photo-album.tsx
Live · interactive

Album

The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.

When to use

  • A page of a game’s pause menu or handheld device where the player looks back over the pictures they took.
  • A collection read as pictures first: a grid of thumbnails, twelve a page, with the name and place only once one is opened.
  • A grid driven by a controller: one frame moved by the arrows, A to open, B to go back, Y for the camera, with button hints at the foot.
  • An in-game camera whose shot is confirmed before it is kept: a shutter, then “Save in album?” with Delete and Save.
  • A screen that sits between neighbouring menu pages, named on L and R shoulder tabs.

When not to use

  • A photo gallery or library in an app or on a site. Use a responsive image grid with a lightbox; this one is a fixed, framed game screen.
  • Items with counts, stats or prices to compare. Use an item grid or inventory, which shows each tile’s numbers.
  • Creatures or entries with long descriptions. Use a compendium or field guide, which keeps a text panel beside the list.
  • Uploading or editing pictures. Use a file picker and an editor; the camera here only takes the next scripted shot.
  • A quick confirmation with no picture. Use a plain confirm dialog; this one is built around the shot it asks about.

You can add this component with the shadcn CLI or by hand:

Into components/photo-album of any React project, with no setup:

Terminal
npx shadcn@latest add https://brunodelorence.com/r/photo-album.json

Either way it needs React 19 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.

Usage

tablet.tsx
import { PhotoAlbum, type AlbumPhoto } from './photo-album';const PHOTOS: AlbumPhoto[] = [  { id: 'a', scene: 'birch-lake', title: 'Birch Shore', place: 'Lantern Lake' },  { id: 'b', scene: 'night-peaks', title: 'Night Over the Peaks', place: 'Crown Range' },];export function Tablet({ onClose }: { onClose: () => void }) {  return (    <PhotoAlbum      photos={PHOTOS}      shots={[{ id: 'shot', scene: 'snow-dawn', title: 'Frost at Dawn', place: 'Frostpine Ridge' }]}      autoplay={false}      onSave={(photo, album) => console.log('Saved', photo.title, album.length)}      onBack={onClose}    />  );}

Each state below is a live instance, locked so you can study it.

The pieces it is built from, each with its measurements read live.

The page of a game’s handheld tablet where the player keeps the pictures taken with its camera. A navy screen with a cyan glow sits in an ornamental bezel; twelve pictures a page, each a small painted landscape at its own time of day, wait in light frames, with page arrows and a peek of the next page. A opens one full size; Y opens the camera, where A fires the shutter and the tablet asks “Save in album?” with Delete and Save. Saving flies the picture into the first slot while the others make room. Use the arrow keys, A, B and Y, or click.

  • The album is a flat list of pictures, newest first, shown twelve a page (4 × 3, or 3 × 4 under 640 px). The page is derived from the selected index, so moving past the grid’s edge, the page arrows or Page Up / Page Down all turn the page; the grid is keyed by page and slides in 300 ms from the side of travel.
  • The selection is one chamfered SVG frame laid over the grid and moved by translate (170 ms, an overshoot curve), with a soft wide stroke under a sharp one and a drop-shadow glow; the selected picture lifts by 3.5 %.
  • Each picture is a 320 × 180 vector painting (scenes.tsx): a sky gradient, a sun or moon with its glow, ridges from a seeded midpoint displacement that fade into an aerial haze, a ground with brushed grass, and a subject (pillars, birches, standing stones, palms, an oak, a fort, a gate) whose crowns are painted in three passes. A shared finish adds light from the upper left, a darker foreground and a vignette. Nothing is a bitmap, so a picture stays sharp from the 168-unit thumbnail to the full screen.
  • Opening a picture is a FLIP: the large view is measured after it mounts and played from the slot’s rectangle (380 ms); B shrinks the slot back from the large view’s rectangle. Left and right swap the picture with a short slide.
  • The camera shows the live view, drifting slowly as a held camera does, under a breathing viewfinder. A fires the shutter: black bands close and open in 60 ms over a white flash that fades in 520 ms, and at 180 ms the “Save in album?” dialog pops in (scale 0.94 to 1 with a settle) over the dimmed view. Only the button A would press is lit: a chamfered bright outline (a clip-path polygon) and a breathing glow.
  • Saving measures the dialog’s picture and every slot, inserts the picture first, and plays the FLIPs: the new slot flies from the dialog with a lift at mid-flight (720 ms) above the others, which shift one slot over after 260 ms with a small overshoot; then a cyan ring pings the slot. Delete drops the picture and returns to the viewfinder.
  • Every length is a multiple of --u, 1 px when the tablet has its 960 px and less on a narrower stage; the bezel’s rail is one vector tile repeated, its four corner medallions one SVG mirrored. The tablet brings its own dark surface, so only its shadow and halo follow the page’s colour scheme.
  • A demo browses, opens a picture, pages, takes a picture and saves it while the album is on screen and untouched; any input hands it to the reader. Looping animations pause offscreen.
PropTypeDefaultDescription
photosAlbumPhoto[]eighteen painted picturesThe album, newest first: { id, scene, title, place?, mirrored? }. scene is one of the fifteen painted scenes (SCENE_IDS); mirrored shows it flipped, so one scene can make two pictures.
shotsAlbumPhoto[]three picturesThe pictures the camera takes, in turn; it loops.
defaultView'album' | 'photo' | 'camera' | 'confirm''album'The screen it opens on: the grid, the selected picture in the large view, the camera, or “Save in album?” with the camera’s first shot.
defaultSelectedstring—The id of the picture selected first (the first if not given).
headingstring'Album'The page’s name, between the shoulder tabs.
tabs[string, string]['Map', 'Field Guide']The neighbouring pages named on the L and R shoulder tabs.
onSelect(photo: AlbumPhoto) => void—Called when the selection moves to a picture.
onOpen(photo: AlbumPhoto) => void—Called when a picture opens in the large view.
onSave(photo: AlbumPhoto, photos: AlbumPhoto[]) => void—Called with the new picture and the album after it, when one is saved.
onDiscard(photo: AlbumPhoto) => void—Called when a picture just taken is deleted instead.
onTab(side: 'left' | 'right') => void—Called on L or R (or a click on a shoulder tab): the neighbouring page to go to.
onBack() => void—Called on B, Escape or the Back hint from the album grid.
autoplaybooleantrueWhile on screen and untouched, a short visit: browse, open a picture, turn the page, take a picture and save it.
colorScheme'light' | 'dark' | 'system'—Forces light or dark for the tablet’s shadow and halo and the focus ring; by default it follows the page. The tablet itself stays dark.
classNamestring—Added to the root.

The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.

photo-album.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import {  useCallback,  useEffect,  useEffectEvent,  useId,  useLayoutEffect,  useRef,  useState,  type CSSProperties,  type KeyboardEvent,} from 'react';import {  AlertDot,  FaceGlyph,  Heart,  KeyCap,  Medallion,  PageArrow,  PhotoBadge,  Reticle,
photos.ts
// Copyright (c) 2026 Bruno Delorence. MIT License.import type { SceneId } from './scenes';/** A picture in the album: which painted scene it shows, its name, and where it was taken. */export type AlbumPhoto = {  id: string;  scene: SceneId;  /** The picture's name, read out and shown in the large view. */  title: string;  /** Where it was taken, shown over the picture in the save dialog and the large view. */  place?: string;  /** Shows the scene mirrored, so one scene can make two different pictures. */  mirrored?: boolean;};export const DEFAULT_PHOTOS: AlbumPhoto[] = [  { id: 'p01', scene: 'colonnade', title: 'Pillar Court', place: 'Old Capital Outskirts' },  { id: 'p02', scene: 'birch-lake', title: 'Birch Shore', place: 'Lantern Lake' },  { id: 'p03', scene: 'standing-stones', title: 'Seven Stones', place: 'Windmeet Plain' },  { id: 'p04', scene: 'palm-cove', title: 'Palm Cove at Dusk', place: 'Amber Coast' },  { id: 'p05', scene: 'canyon-dawn', title: 'Ochre Canyon', place: 'Redfall Gorge' },  { id: 'p06', scene: 'lone-oak', title: 'The Lone Oak', place: 'Greenmoor' },  { id: 'p07', scene: 'deep-wood', title: 'Under the Canopy', place: 'Mossdeep Wood' },
scenes.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.import { useId, type ReactNode } from 'react';/* * The album's pictures: small painted landscapes drawn in SVG, one per scene, at different times of * day. Each is a 320 × 180 drawing in layers (a sky gradient, a sun or a moon with its glow, ridges * that fade into the haze, a ground, and a subject: pillars, birches, standing stones, palms, an * oak, a fort, a gate…). Ridges come from a seeded midpoint displacement, so they are irregular but * the same on every render. Nothing is a bitmap: a picture stays sharp at any size. */export type SceneId =  | 'colonnade'  | 'birch-lake'  | 'standing-stones'  | 'palm-cove'  | 'canyon-dawn'  | 'lone-oak'  | 'deep-wood'  | 'hill-fort'  | 'still-pond'  | 'old-plaza'  | 'stone-gate'
glyphs.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License./* * The album's small drawings: the controller prompts (a round face button, a shoulder keycap), the * page arrow, the photo badge, the heart and the alert dot, the viewfinder's corners, and the * tablet's bezel (its riveted rail and its corner medallions). All are flat vector shapes in * currentColor or the bezel's own tokens, aria-hidden: the words around them carry the meaning. *//** A face button: a filled disc with its letter knocked out in the plate's ink. */export function FaceGlyph({ letter }: { letter: string }) {  return (    <svg viewBox="0 0 32 32" aria-hidden="true" focusable="false">      <circle cx="16" cy="16" r="16" fill="currentColor" />      <text x="16" y="22.4" textAnchor="middle" fontSize="18" fontWeight="700" fill="var(--ink)">        {letter}      </text>    </svg>  );}/** A shoulder button: a keycap with a slanted top edge, its letter knocked out. */export function KeyCap({ letter, side }: { letter: string; side: 'left' | 'right' }) {  const d =
photo-album.module.css
/* Photo album (photo-album.tsx): a handheld tablet's album page, on its own, centred on the lab's   plain stage. The tablet brings its own surface: a dark ornamented bezel around a navy screen   with a cyan glow, so it reads the same on a white page and a black one; only its shadow and halo   on the page follow the colour scheme.   Every length is a multiple of --u: 1 px when the stage gives the tablet its 960 px, less on a   narrower stage. Under 640 px the tablet turns into a 480-unit portrait layout with a 3 × 4 grid,   so the pictures and words stay legible on a phone.   Crispness: the pictures, the bezel's rail and medallions, the glyphs and the screen's pattern are   all vector (inline SVG or SVG data URIs); glows are box- and text-shadows behind sharp shapes.   Motion: the selection frame moves by a transition on translate; page slides, the viewer's swaps,   the dialog's pop, the flash and the ping are keyframes; the FLIPs (open, close, save) are Web   Animations set from the script. Reduced motion turns them all off. */.root {  /* The bezel. */  --band: #1b1a18;  --band-hi: #2a2826;  --relief: #b09872;  --relief-dark: #3a2d20;  --relief-light: #ecdcb4;  /* The screen. */
use-autoplay.ts
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. */export function useAutoplay<T extends HTMLElement>(idle = 6000) {  const ref = useRef<T>(null);  const [visible, setVisible] = useState(false);  const [reduced, setReduced] = useState(true);  const [taken, setTaken] = useState(false);  const timer = useRef(0);  useEffect(() => {    const element = ref.current;    if (!element) return;    let onScreen = false;    const update = () => setVisible(onScreen && !document.hidden);    const observer = new IntersectionObserver(
  • The page is a listbox of options named by title and place (“Birch Shore, Lantern Lake”), labelled with the page number (“Album, page 1 of 2”) and followed with aria-activedescendant; the page arrows, shoulder tabs and button prompts are real buttons with aria-labels or aria-keyshortcuts.
  • Keyboard as a controller: arrows (or W, S) move the selection and turn the page at the grid’s edge, Page Up / Page Down turn it, A, Enter or Space open the picture, B or Escape go back, Y (or C) opens the camera, L / Q and R / E call onTab. In the camera, A takes the picture; in the dialog, left and right choose, A confirms, B deletes.
  • The save prompt is an alertdialog labelled by its question and described by the place; when it opens while the reader is in the album, the focus moves to Save, and left and right move it between the buttons.
  • A polite live region says the page when it turns, the picture opened, “Save in album?”, and what was saved or deleted.
  • The paintings, bezel, medallions, glyphs, flash and viewfinder are aria-hidden; the hearts are one image named “7 hearts”. Every control shows a :focus-visible ring.
  • Reduced motion: no flights, flash, slides, breathing or drift; states change at once and the demo never plays.