An infinite canvas photo gallery — drag in any direction, scroll or pinch to zoom.
The site stays framework-free and static:
index.htmlholds the document shell and loads the assetsstyles.csscontains the gallery stylingjs/app.jsbootstraps the galleryjs/camera.jsowns pan, zoom, and inertia statejs/interactions.jsbinds pointer, wheel, and touch inputjs/gallery.jsloadsphotos.json, renders the photos, and computes the initial center pointjs/loader.jscontrols the branded loading overlay usingloading.webmjs/label-token.jsdrives the small Charmera camera token in the top-left pill from live camera motionjs/metadata-dock.jsmanages the bottom metadata pill shown on hover or long-pressjs/debug-panel.jsandjs/tuning.jspower the?debugtuning panelloading.webmis a VP9 video with alpha, encoded from thePNG_Sequence/frames for the loaderPNG_Sequence/source frames used by the label token at runtime and as the source forloading.webmphotos.jsonis the source of truth for gallery layout data
photos.json must export an array of photo objects. Each object uses this shape:
{
"src": "photos/my-new-shot.jpg",
"x": 600,
"y": -200,
"width": 380,
"height": 570,
"title": "My New Shot",
"date": "2026-05-03",
"alt": "Optional description"
}src— required relative path to the image assetx— required horizontal world position in pixelsy— required vertical world position in pixelswidth— required rendered width in layout units (typically480)height— required rendered height in layout units; drives masonry grid row height. Computed by the sync script from EXIF-aware display dimensions (landscape:360, portrait:640)title— optional metadata title shown in the bottom dockdate— optional ISO date shown in the bottom dockalt— optional image description; defaults to an empty string when omitted
At runtime, js/gallery.js reads this file, creates one .photo element per entry, and js/app.js centers the initial camera view from the spread of those coordinates.
1. Add the image file
photos/my-new-shot.jpg
Resize/compress before committing. Aim for ≤300KB per image — WebP at ~80% quality is ideal. Squoosh works well for this.
2. Sync photos.json
python3 scripts/sync_photos.py- Preserves existing
photos.jsonentries and only generates defaults for new files - Applies any manual entries from
photos.overrides.jsonafter the generated pass - Places new photos on the current golden-angle spiral so the canvas stays balanced
- Derives
titlefrom the filename anddatefromkMDItemContentCreationDateon macOS, with file modified time as a fallback - Uses
width: 480for landscape images andwidth: 360for portrait images
3. Curate if needed
Use photos.overrides.json for anything that should survive future sync runs: hand-placed x/y coordinates, editorial titles or dates, or a rename that should inherit an existing placement.
Example:
{
"AaronEnjoyingPizza.jpg": {
"rename_from": "SPI00.jpg"
},
"Katie30thBirthday.jpg": {
"x": -420,
"y": 180,
"title": "Katie 30th Birthday"
}
}rename_from tells the sync script to keep the previous photo's placement and sizing when a file has just been renamed. Plain fields like x, y, title, date, width, and alt override the generated output after sync.
4. Commit and push
git add photos/my-new-shot.jpg photos.json photos.overrides.json scripts/sync_photos.py
git commit -m "add: new shot"
git push
GitHub Actions will deploy automatically.
The coordinate system is centred around (0, 0). The camera starts at the centroid of all photos, so spreading them evenly around the origin keeps the initial view balanced.
A rough layout guide:
- Leave ~80–120px gaps between photo edges so they breathe
- Mix portrait and landscape orientations
- Vary widths between ~280px and ~520px for visual rhythm
- Scatter in all four directions — don't just grow the grid downward
For a visual rebalance pass, keep the spiral as the background structure and only override the photos you want to cluster. Group related shots into loose local neighborhoods, check that the overall canvas still has weight in all four quadrants, and avoid moving too many large images onto the same arc at once.
Because photos.json is loaded via fetch(), you need a local server (not just opening index.html directly in a browser):
npx serve .
# or
python3 -m http.serverThen open http://localhost:3000 (or whichever port).
The repo is deployed via GitHub Pages. Any push to main triggers a deploy.
To set up on a new repo:
- Go to Settings → Pages
- Set source to GitHub Actions
- Use the workflow in
.github/workflows/deploy.yml