I wanted /notes to feel less like paginated blog archives and more like opening an Almanack and moving through it continuously.

That creates a conflict.

A single page containing 50 notes is harmless. A page containing 10,000 rich note cards, share controls, templates, and modal content is not. Even if the initial HTML somehow arrived quickly, the browser would still have thousands of elements to style, lay out, paint, observe, and keep around.

So the page is continuous visually, but windowed structurally.

The archive is split into batches

The server still exposes ordinary paginated routes. Each page contains 24 notes.

/notes             → batch 1
/notes/page/2      → batch 2
/notes/page/3      → batch 3
...

JavaScript turns those pages into an infinite archive in the browser.

When the lower sentinel approaches the viewport, the next batch is fetched and appended. When the upper sentinel approaches, the previous batch can be inserted above the current one.

The key is that fetching more does not mean keeping everything forever.

Only five real batches stay mounted

The page keeps at most five complete batches in the DOM.

With 24 notes per batch, that means the expensive part of the archive is normally bounded at roughly:

5 × 24 = 120 note cards

After a sixth batch appears, the script ranks the rendered batches by distance from the viewport and removes the ones that are farthest away.

It does not simply delete their space. Each removed batch becomes an empty placeholder with the same measured height.

real batch 4
real batch 5
real batch 6
placeholder for batch 7
placeholder for batch 8

Those placeholders are why the scrollbar remains stable. The document keeps approximately the same physical height even though the actual note elements are gone.

Scrolling back does not require keeping old notes hidden

An IntersectionObserver watches the placeholders with a generous margin around the viewport.

When one approaches, the corresponding paginated route is fetched again and its real batch replaces the placeholder.

This can look impossibly fast after you have already visited that area. That does not mean the old note cards were secretly left in the DOM. The browser can satisfy a repeated request immediately from its HTTP cache, parse the small page fragment, and put it back before you notice.

That is exactly the experience I want: bounded rendering cost without making the archive feel paginated.

During development I use:

/notes?debug-notes=1

to show the current number of live notes, rendered batches, placeholders, and fetched page numbers. The more useful independent check in DevTools is:

document.querySelectorAll('[data-note-item]').length

Even after travelling through hundreds of test notes, that number should settle around the size of the rendering window rather than the size of the archive.

Search uses a different path

The normal archive should not download the text of every note on first load just because search exists.

The search index is therefore requested only when somebody actually starts searching. The index is lightweight text and metadata; the full note archive remains paginated and windowed.

The search control itself becomes sticky once you pass it. It stays available because filtering a long archive should not require a trip back to the top.

A note has a URL even though it opens in a modal

Clicking a note opens a reading modal instead of navigating away from the archive.

But the URL still changes to:

/notes/the-note-slug

That matters for three reasons:

  1. the note can be copied and shared as a normal URL;
  2. browser history still makes sense;
  3. somebody opening that URL directly lands on the Notes archive with the matching note already open.

Closing it reveals the archive underneath instead of sending the reader to a separate isolated article page.

The URL is therefore a permalink; the modal is just the reading experience.

The background follows modal navigation

The interesting edge case is moving between notes while the modal is open.

If I open note A, scroll through the modal to note F, and close it, showing note A in the archive would feel broken. So modal navigation also aligns the windowed archive behind the dialog with the current note.

The background page itself is scroll-locked while the dialog is open, so trackpad or wheel gestures belong to the note reader rather than accidentally moving the archive.

For short notes, a vertical wheel or swipe can move to the adjacent note. For a longer note, the content scrolls first; only after reaching its boundary does the same gesture advance through the archive.

Previous and next are temporal, not arbitrary

The modal navigation follows the chronology of the archive:

  • left / up → newer note;
  • right / down → older note.

The newest note has no newer control. The oldest has no older control. The UI hides the entire unavailable control rather than leaving an orphaned arrow behind.

Share controls stay in the same footer, and the permalink changes whenever the current note changes.

Why not a virtualization library?

I could use a general-purpose virtual list package, but the archive has some characteristics that make a small custom window useful:

  • note heights vary significantly;
  • each note has semantic HTML and share controls;
  • routes should still work without JavaScript;
  • a direct note permalink must open the right modal;
  • browser-native pagination remains a useful fallback;
  • the number of mounted batches is small enough that the policy is easy to understand.

The implementation is not “render only the 12 pixels currently visible.” It is deliberately less aggressive: keep several pages around the viewport, then evict only distant pages. That gives fast scrolling plenty of runway while still keeping the DOM bounded.

The UX principle behind it

The technical goal is not infinite scroll.

The goal is to let the interface behave as if the archive were one long physical object without asking the browser to carry the whole object at once.

That is the pattern I keep coming back to elsewhere too: preserve the experience, remove the unnecessary cost.