Rebuilding the saved page
4 min read
I rebuilt this site to be a single page recently, and the saved page got the most interesting treatment out of it. It used to be a normal grid of cards grouped by month. Fine, functional, forgettable. Now it's a full-viewport canvas that scrolls forever in every direction. You can drag it, flick it, scroll it, or steer it with arrow keys, and it never runs out because the cards tile infinitely.
Where the links come from
I didn't want a CMS or an admin panel for bookmarks. I already have a place I dump links: a private Discord channel. So the site just reads from it.
A sync endpoint hits the Discord API with a bot token, pulls messages from that channel, and extracts anything that looks like a URL. Tags come free too, since I can type #tools or #design next to a link in Discord and a regex picks it up:
const URL_REGEX = /https?:\/\/[^\s<>]+/g;
const TAG_REGEX = /#([a-zA-Z0-9_-]+)/g;Anything from the Discord CDN, Tenor, or Giphy gets skipped, because a bookmarks page full of reaction gifs isn't a bookmarks page. For each real link, the server fetches the page's OG metadata (title, description, image) and upserts it into Postgres keyed on the URL so re-syncs don't duplicate anything. The sync also busts the ISR cache on insert, so a link I drop in Discord shows up on the site right away instead of waiting an hour for revalidation.
The workflow is: see something cool, paste it in Discord, done. That's the entire publishing pipeline.
The infinite grid
The grid isn't actually infinite, it just lies convincingly. There's one absolutely positioned viewport and a pan offset. On every render I compute which grid cells are visible for the current offset and only render those, plus one cell of padding on each side. Cell contents wrap modulo the collection:
function itemFor(links: SavedLink[], col: number, row: number) {
const n = links.length;
const idx = ((col + row * 7) % n + n) % n;
return links[idx];
}The row * 7 stride offsets each row by a prime, so neighboring rows don't repeat in lockstep. With 40 links you can pan for a long time before the pattern registers.
Cells are 400x210 because OG images are 1200x630. Matching the aspect ratio means every card shows the full image with zero cropping. I originally had near-square cells and everything looked chopped.
Making it feel physical
Drag-to-pan is pointer events with a 6px threshold before a press counts as a pan, so clicking a card still works. The fun part is release: I track velocity during the drag and let it decay at 5% per frame after you let go, so a flick sends the canvas gliding.
Arrow keys go through the same system. Each press injects a velocity impulse instead of teleporting the canvas:
const KEY_IMPULSE = KEY_STEP * (1 - FRICTION);
p.vx = clamp((dir[0] ? p.vx : 0) + dir[0] * KEY_IMPULSE);Impulses stack, so holding a key builds into a fast glide, capped so it can't run away. Keyboard, drag, and flick all ride one friction curve, which is why they feel like the same object.
There's also a depth-of-field effect at the edges: three stacked backdrop-filter: blur() layers, each masked with a radial gradient so the blur only exists near the frame and strengthens outward. Cards stay sharp in the center and melt at the periphery.
I tried adding actual barrel distortion on top of it, cells warping toward center like a fisheye lens. Built it, shipped it, hated it, removed it within the hour.1 The blur alone reads as depth without making the grid feel drunk.
The details
The cards themselves are just the OG image now. Title, description, domain, and tags live in a hover overlay with a darkening gradient, so the resting state is pure imagery.
Search and tag filters live in a floating pill at the bottom, and both write to the URL with nuqs. /saved?search=react&filter=tools opens pre-filtered, back and forward walk your filter history, and views are shareable.
| Interaction | Behavior |
|---|---|
| Drag + release | Momentum glide, 5% decay per frame |
| Arrow keys | Velocity impulse, stacks while held |
| Scroll / trackpad | Direct 1:1 pan on both axes |
| Reduced motion | Instant steps, no momentum |
None of this was necessary. A list of links works fine. But this is my corner of the internet and the bookmarks page is now the thing people play with the longest, which tells you something about what's worth building.
Footnotes
-
The math was fine —
factor = 1 / (1 + 0.18·d²), cells pulled radially toward center. It just looked like the site had been drinking. ↩