A real-time, 100% client-side image gallery that streams photos from the Bluesky/ATProto network via Jetstream. Watch images appear as they are posted, in a scrollable feed tuned for live updates.
- Live image feed — WebSocket stream of new posts with image embeds
- Feed grid (default) — Responsive row grid (2 / 4 / 8 columns) with virtual scrolling
- Dense wall — 8-column Masonic layout for a Pinterest-style wall
- Incoming row — New posts land in a fixed top strip (left→right), then merge in batches so the grid shifts down instead of shuffling sideways on every post
- Scroll away — Scroll down to freeze the view; new posts buffer behind a “new posts” pill; jump back to latest
- Search — Filter by keywords in alt text, post text, or handles (debounced)
- Pause — Stop accepting new images without disconnecting
- Modal — Full-size view with keyboard navigation between filtered images
- Responsive — Works on desktop and mobile
- React 18 + Vite 5
- @tanstack/react-virtual — Feed grid virtualization
- masonic — Dense masonry layout
- Jetstream — JSON firehose (no CAR/CBOR decoding in the browser)
- Plain CSS — Layout and theming in
src/styles/main.css
- Node.js 18+ and npm
git clone <your-repo-url>
cd bskygallery
npm install
npm run devOpens at http://localhost:3000 (see vite.config.js).
| Command | Description |
|---|---|
npm run dev |
Dev server with HMR |
npm run build |
Production build to dist/ |
npm run preview |
Serve the production build locally |
Static site — build output is dist/. vite.config.js sets base: './' for subdirectory hosting (e.g. GitHub Pages project sites).
- Enable GitHub Pages for the repo (source: GitHub Actions).
- Push to
main—.github/workflows/deploy.ymlrunsnpm ci,npm run build, and deploysdist/.
No need to commit dist/ or set the Pages branch to /root.
Use the included vercel.json or netlify.toml, or connect the repo in the host dashboard (build: npm run build, output: dist).
bskygallery/
├── index.html
├── package.json
├── vite.config.js
├── CLAUDE.md # Dev notes for contributors / agents
├── src/
│ ├── main.jsx # React entry
│ ├── App.jsx # Shell, layout mode, firehose lifecycle
│ ├── state.js # Images, filters, layoutMode, subscribers
│ ├── firehose.js # Jetstream WebSocket + post parsing
│ ├── constants.js # Column counts, dwell/batch timings
│ ├── react/
│ │ ├── VirtualRowGallery.jsx # Feed grid (default)
│ │ ├── MasonryGallery.jsx # Dense wall
│ │ ├── useIncomingRowFeed.js # Incoming strip + merge (both layouts)
│ │ ├── MasonryCard.jsx
│ │ ├── FilterBar.jsx
│ │ ├── ModalHost.jsx
│ │ ├── NewPostsPill.jsx
│ │ └── galleryLayout.js
│ ├── utils/
│ │ ├── imageUrl.js
│ │ └── filters.js
│ └── styles/
│ └── main.css
└── .github/workflows/
└── deploy.yml
firehose.jsconnects to Jetstream, parsesapp.bsky.feed.postcreates with images, builds stable post ids (did-rkey), callsstate.addImage().state.jskeeps newest-first list (max 200 images; tail evicted in batches of 8). Notifies React viasnapshotVersion.App.jsxpasses filtered items to Feed grid or Dense wall.useIncomingRowFeed(both layouts) holds up to one row of newest posts in an incoming strip, then prepends them to the main list when dwell/thumb-load rules pass. When you scroll away from the top, the visible list freezes and new heads go to a buffer until you jump to latest.
| Mode | UI label | Implementation |
|---|---|---|
feed (default) |
Dense wall toggles away | VirtualRowGallery — row-major virtual rows |
dense |
Feed grid toggles back | MasonryGallery — 8-column Masonic + same incoming strip |
Feed column count follows viewport width (see getFeedColumnCount in src/constants.js).
- Virtualized feed rows (only visible rows mounted)
- Masonic overscan for dense mode
- Lazy-loaded thumbnails in the main grid; eager load in the incoming strip
- Rolling cap of 200 posts in global state
- Debounced search (300ms)
Modern browsers with ES modules, CSS Grid, and WebSocket. Tested on recent Chrome, Firefox, Safari, and Edge.
- Public Jetstream endpoint; high-volume network firehose
- No auth or private feeds
- ~200 posts retained in memory at the live edge (more may remain visible while scrolled away from top until you jump to latest)
- Dense mode still relayouts the masonry wall when a full incoming row merges (batched, not per-post)
MIT