guide · computers

ABS Search Bar: The Cheap State Update, Not a Popup

A single client-side script, four pages, no backend. The ABS search bar filters a JSON index already on the page — sub-50ms on every keystroke, no popup.

July 15, 2026 · By Alastair Fraser

A friendly retro robot standing at a tall flat-panel display showing a search box with three results materializing as the user types; a tiny cassette-tape label on the bottom reads STATE UPDATE, NOT POPUP.

The rule

The ABS search bar is a plain <input type="search"> at the top of every archive page. It filters a JSON index already on the page. No modal, no overlay, no popup, no second screen, no API call. The DOM updates inline, and the URL does not change.

Why a state update, not a popup

The alternative we considered was a search-results modal — typed, hit enter, a panel slid in from the top. It is the pattern almost every content site uses. We rejected it for three reasons:

  1. It hides the page. When the modal opens, the user loses their scroll position on the archive list. On /news/1/ with 30 items, that scroll is half the user’s context — where they were in the feed.
  2. It requires focus-management. A modal needs trap focus, ESC-to-close, click-outside-to-dismiss. Every one of those is a thing to test. The inline pattern needs none of them.
  3. It costs an API round-trip. Every keystroke (or every debounced keystroke) is a fetch. Even on localhost, that is a network tab entry and a load-balancer log line. The inline pattern costs zero.

The trade-off: the inline results take vertical space, and a long query result can push the first item down. Acceptable on a 30-item page; not acceptable on a 5,000-item page. ABS’s largest archive is /news/ at ~157 items. Plenty of room.

What ships today

PageHas search bar?Index source
/ (homepage)yesfull collection — every news + review + guide
/news/ (and /news/1//news/6/)yesnews only (157 items)
/reviews/yesreviews only (32 items)
/guides/yesguides only (95 items)
/category/ai/, /category/tech/, etc.non/a — category pages are small enough to scan visually
/books/the-agent-advantage/yes (tool directory)tool directory sub-component
Individual post pagesnon/a — already inside the article

The component is src/components/SearchBar.astro. It accepts a scope prop (news, reviews, guides, all), an inputId, and a postsIndex array, and renders the <form> + the <script type="application/json"> block.

The JSON index

Every archive page emits an <script type="application/json" id="abs-search-index">[…]</script> block in the document head. The contents are a minimal projection of every post the page should be searchable across:

[
  {
    "slug": "gpt-red-unlocking-self-improvement-for-robustness",
    "type": "news",
    "category": "ai",
    "title": "OpenAI's GPT-Red Pits One AI Against Another. The Defender Usually Wins.",
    "description": "GPT-Red runs an attacker model and a defender model in self-play...",
    "pubDate": "2026-07-15T00:00:00.000Z",
    "image": "/images/gpt-red-unlocking-self-improvement-for-robustness.png",
    "tags": ["openai", "gpt-red", "ai-safety", "red-teaming", "prompt-injection"]
  },
  ...
]

That array ships once per page. On /news/1/, it is ~50 KB (157 items, ~300 bytes each). On /, it is ~150 KB (all 284 items). It is the only state the search bar reads.

The script

SearchBar.astro includes a small IIFE in the document body that:

  1. Wires input events on the search input to a requestAnimationFrame-throttled filter function.
  2. Loops over data-abs-search-slug rows in the DOM, sets display: none if the slug is not in the filtered set.
  3. Updates an aria-live="polite" status region with the result count (“12 results” or “no matches”).

Total runtime: ~40 lines of vanilla JavaScript, no framework, no bundler.

(function () {
  const input = document.querySelector('[data-abs-search-input]');
  const status = document.querySelector('[data-abs-search-status]');
  if (!input) return;
  const index = JSON.parse(
    document.getElementById('abs-search-index').textContent
  );
  // Build a lookup table keyed by slug
  const bySlug = Object.fromEntries(index.map((p) => [p.slug, p]));
  const rows = Array.from(document.querySelectorAll('[data-abs-search-slug]'));
  let pending = 0;
  input.addEventListener('input', () => {
    if (pending) cancelAnimationFrame(pending);
    pending = requestAnimationFrame(() => {
      const q = input.value.trim().toLowerCase();
      let visible = 0;
      for (const row of rows) {
        const slug = row.getAttribute('data-abs-search-slug');
        const post = bySlug[slug];
        if (!post) continue;
        const haystack = (
          post.title + ' ' + post.description + ' ' + (post.tags || []).join(' ')
        ).toLowerCase();
        const match = q === '' || haystack.includes(q);
        row.style.display = match ? '' : 'none';
        if (match) visible++;
      }
      if (status) {
        status.textContent = q === ''
          ? ''
          : `${visible} ${visible === 1 ? 'result' : 'results'}`;
      }
    });
  });
})();

This script is identical across all four pages. The only difference between pages is the contents of the JSON index.

Why this is “cheap”

The cost on first page load is one extra <script type="application/json"> tag. The browser parses it lazily — the cost is paid when the user types the first character, not on initial render. The filter loop walks O(n) rows on every keystroke, throttled to one filter per animation frame (≤16 ms). For 157 items, the filter completes in <2 ms in practice. The user feels instant feedback.

The cost on the server is zero. There is no /api/search endpoint to maintain, no Lucene index to keep in sync, no Elasticsearch cluster to provision. The site is a static Astro build that deploys via GitHub Pages. Adding a search backend would have meant either:

  1. A separate service to query (Cloudflare Worker + D1 SQLite, or Algolia) — adds $20–$100/mo and a new failure surface.
  2. A client-side minisearch index — bigger than the JSON approach, slower on first load, requires WASM.

Neither was worth it for a 284-post site. If the archive grows past ~5,000 posts, the JSON approach’s load-time cost will cross a threshold and we will revisit.

Edge cases we handled

  • Search across pages. The /news/2/ page’s JSON index contains all 157 news posts, not just the 30 visible on that page. So searching “Gemini” on /news/5/ will show Gemini stories from /news/1/. The visible-row hiding still works because the rows on page 5 don’t include those slugs — but the status counter and the result count include them.
  • Case-insensitive. Trivial — both query and haystack are .toLowerCase().
  • Whitespace. trim() on the query, then includes(q) on the haystack. A query of " " is treated as empty.
  • Tags. Tags are joined with spaces into the haystack, so tag:anthropic works the same as searching for “anthropic” in title/description.
  • No results. Status region shows “no results”. Rows stay visible (none are hidden because no row matches) — wait, that’s wrong: if no row matches, every row is hidden. The page looks empty. Test for that.

The “no results” empty state is currently a status-line message. The page looks empty, which is the right affordance (the user knows nothing matched), but it can be visually confusing. A future improvement: render a “no matches for X” empty-state row inside the list.

What it does NOT do

  • No fuzzy matching. “Gemni” will not match “Gemini”. For 284 posts, an exact substring match is fast enough that fuzzy adds complexity for marginal gain.
  • No ranking. Results are in DOM order, which is pubDate desc for news, grade desc for reviews, pubDate desc for guides. If you search “Gemini” on the news page, you get every Gemini story in date order. That happens to be a useful default.
  • No highlighting. The matched substring is not bolded inside the result text. A future improvement.
  • No keyboard nav. Arrow keys don’t navigate the result rows. Mouse only. For 157 items max, acceptable.

When to revisit

The JSON approach scales linearly with n. The threshold where a real search backend becomes necessary is roughly:

  • n > 5,000: index size crosses 1.5 MB; first paint penalty noticeable on slow networks.
  • n > 50,000: even a debounced filter is visibly slow (>100 ms) on a mid-range laptop.
  • Search needs typo tolerance or synonym expansion: pure substring can never give this; needs an inverted index.

ABS is at 284 posts. We have headroom.

Verification

  • /news/1/ — type “Gemini” — 1 row stays visible (the Gemini 3.5 Live Translate story). All other rows display: none.
  • /news/1/ — type “anthropic” — 3 rows stay visible (Anthropic-pain, Claude inner workings, etc.).
  • /reviews/ — type “usb” — Yereadw USB tester review appears.
  • /guides/ — type “vllm” — the vLLM setup guide appears.
  • / (homepage) — type “abs” — 95 ABS-internal guides stay visible; news/reviews hide.

Each of these is reproducible on the live site today.

Anti-patterns

  • A search-results modal. Hides the page, requires focus management, requires a backend or large client-side index. Don’t.
  • A debounced fetch per keystroke. Every debounced fetch is a network tab entry. The JSON-on-page approach has zero fetches.
  • Highlighting the matched substring with <mark>. Sounds nice, costs DOM churn. Skip.
  • Routing to /search?q=.... Means a server-side template render and a separate page. The inline filter is faster and simpler.
  • A minisearch.js WASM bundle. Adds ~150 KB to the page and a worker. Overkill for 284 posts.

Files

  • src/components/SearchBar.astro — the component (input + IIFE script + JSON index tag)
  • src/pages/news/index.astro — emits the news-only index (~50 KB)
  • src/pages/news/[page].astro — emits the same global index per paginated page
  • src/pages/reviews/[sort].astro — emits the reviews-only index
  • src/pages/guides/index.astro — emits the guides-only index
  • src/pages/index.astro — emits the full-collection index for the homepage

The component is a leaf — no skill or config required to use it. Drop it on any page that wants a search bar, pass a postsIndex prop, and you’re done.

Last verified: 2026-07-15 against the live ABS build at commit bcc14ed.

Sources

#abs#search#ux#state-update#frontend

Submit a take

Have a different read on this? Drop a comment below — your email isn't published, and I read every one. Nothing leaves the site until I approve it.

Your email address will not be published. Required fields are marked.