Web

Astro Deep-Link Highlight: Search Results That Jump to the Word

By Francesco Di Donato
May 5, 2026
9 minutes reading
Astro Deep-Link Highlight: Search Results That Jump to the Word

TL;DR: Astro deep-link highlight in about 75 lines. Outbound: append ?_h=<token> to the destination URL when navigating from the search palette. Inbound: a tiny astro:page-load script walks the article, wraps the first match in a <mark>, scrolls to it, and replaceStates the _h away. CSS keyframe flashes highlighter yellow then fades to invisible.

Yesterday’s Astro + FlexSearch palette does the heavy lifting: type, get matches, see a snippet around the hit. Fine. But the next move is obvious: when I click a result, the post should open scrolled to the actual matched word, with that word highlighted, so I can see why this post showed up.

The web has a built-in primitive for exactly this. It’s called URL Text Fragments. It looks like a two-line patch.

Reader, it was not a two-line patch.

The plan that didn’t work

URL Text Fragments (the W3C spec, shipped in Chrome 80+, Safari 16.1+, Firefox 131+) let you point a URL at a piece of text on the destination page:

https://didof.dev/en/blog/foo#:~:text=arduino

The browser does a substring search on the rendered DOM, scrolls the match into view, and applies the ::target-text pseudo-element so you can style the highlight. Native, no JS, free.

So the plan was simple. In the search palette’s navigate handler:

window.location.href = `${url}#:~:text=${encodeURIComponent(token)}`;

Add a CSS rule:

::target-text {
  background: color-mix(in oklch, var(--primary) 22%, transparent);
  color: var(--primary);
}

Two lines. Ship it.

What I got: when I clicked a search result, the URL bar showed ?...#:~:text=arduino for one frame, then the fragment vanished. No scroll. No highlight. The page did navigate to the right post. Just nothing else.

What was actually happening

The site has <ClientRouter /> in the layout for view transitions, which is great for the cross-page UX but turns out to be doing something specific here.

In Chromium, <ClientRouter /> uses the Navigation API to intercept most same-origin navigations, including programmatic location.href writes in the cases that actually matter for a search palette. When it intercepts, it does a soft transition: it pushStates the new URL and swaps the page content without a real cross-document load.

Browsers only run text-fragment activation on cross-document navigations. They do not run it on pushState. So the sequence was:

  1. I write a URL with #:~:text=arduino.
  2. ClientRouter intercepts the navigation, does a soft transition.
  3. The router writes the new URL to history. By the time it hits history, the fragment is stripped (since it never got processed).
  4. The browser’s text-fragment scroll-and-highlight pipeline never fires, because no cross-document load happened.

The half-blink of the fragment in the URL bar was the brief moment between my assignment and the router taking over.

The escape hatch that should have worked

Astro documents an opt-out: data-astro-reload on an <a> element forces a real navigation. The repo already uses it for external links in mdx-link.astro, so it’s the established pattern. The natural fix is to synthesize a hidden anchor with that attribute and click it programmatically:

const a = document.createElement("a");
a.href = finalUrl;
a.setAttribute("data-astro-reload", "");
a.style.display = "none";
document.body.appendChild(a);
a.click();

Tried it. Same result. The fragment still got stripped, no scroll, no highlight.

I’m not entirely sure why this didn’t work in my exact setup. The candidate causes:

  • The dialog wrapping the search palette unmounts on setIsOpen(false) and the synthesized anchor was being created during teardown.
  • Chromium’s text-fragment activation has user-activation requirements that programmatic clicks can fail when the gesture is consumed elsewhere.
  • The Navigation API still gets first refusal on programmatic clicks in some Chromium versions, attribute or not.

I spent a while trying to make this work with various delays, requestAnimationFrame wrappers, and dispatched MouseEvents. None of it was reliable. At some point you stop fighting the platform and just write the thing yourself.

Two pieces.

Outbound, in the search palette: instead of a fragment, append a query parameter the destination page will read.

function withSearchHighlight(url: string, query: string): string {
  const trimmed = query.trim();
  if (!trimmed) return url;
  const first = trimmed.split(/\s+/)[0];
  if (!first) return url;
  const [pathAndQuery, hash] = url.split("#");
  const sep = pathAndQuery.includes("?") ? "&" : "?";
  const withParam = `${pathAndQuery}${sep}_h=${encodeURIComponent(first)}`;
  return hash ? `${withParam}#${hash}` : withParam;
}

Query params survive the soft transition. ClientRouter doesn’t strip them. The destination page sees ?_h=arduino cleanly.

Inbound, in the layout: a small <script> block at the bottom of main.astro that listens for astro:page-load, finds the first matching text node in the article, wraps it in a <mark>, and scrolls to it.

astro:page-load is Astro’s “page is ready” event. It fires on the initial hard load and on every subsequent view-transition swap. So one listener handles both fresh visits and palette-driven jumps from one post to another.

function applySearchHighlight() {
  const url = new URL(window.location.href);
  const token = url.searchParams.get("_h");
  if (!token) return;

  const root =
    document.querySelector("article.prose") ||
    document.querySelector("article") ||
    document.querySelector("main") ||
    document.body;
  if (!root) return;

  const needle = token.toLowerCase();

  const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
    acceptNode(node) {
      const parent = (node as Text).parentElement;
      if (!parent) return NodeFilter.FILTER_REJECT;
      const tag = parent.tagName;
      if (tag === "SCRIPT" || tag === "STYLE" || tag === "NOSCRIPT") {
        return NodeFilter.FILTER_REJECT;
      }
      const value = (node as Text).nodeValue;
      return value && value.toLowerCase().includes(needle)
        ? NodeFilter.FILTER_ACCEPT
        : NodeFilter.FILTER_REJECT;
    },
  });

  const node = walker.nextNode() as Text | null;
  if (!node) return cleanUrl(url);

  // The full splitting + wrapping + scroll logic is shown in the next snippet.
}

document.addEventListener("astro:page-load", applySearchHighlight);

A few things worth pointing out:

  • TreeWalker is the right tool for this. It walks text nodes specifically, lets me filter at the node level, and skips entire subtrees when I return FILTER_REJECT. No flat string of innerText to do an indexOf against, which would lose all the position information.
  • Skipping <script>, <style>, <noscript> matters. Otherwise a token like “function” matches inside a code block and now I’m highlighting in the middle of a <pre>.
  • The fallback chain (article.prosearticlemainbody) is paranoid. article.prose is what the blog template renders, but the script lives in main.astro and runs everywhere, so I want it to gracefully no-op on non-article pages.

The actual splitting and wrapping is straightforward DOM:

const text = node.nodeValue || "";
const idx = text.toLowerCase().indexOf(needle);
const before = text.slice(0, idx);
const match = text.slice(idx, idx + token.length);
const after = text.slice(idx + token.length);

const mark = document.createElement("mark");
mark.className = "search-target";
mark.textContent = match;

const parent = node.parentNode!;
if (before) parent.insertBefore(document.createTextNode(before), node);
parent.insertBefore(mark, node);
if (after) parent.insertBefore(document.createTextNode(after), node);
parent.removeChild(node);

requestAnimationFrame(() => {
  mark.scrollIntoView({ behavior: "smooth", block: "center" });
});

The requestAnimationFrame matters. After a view-transition swap the layout has not fully settled when astro:page-load fires. Scrolling immediately can land at the wrong position. One frame later, the new DOM is laid out and the scroll lands accurately.

After the highlight is in place, I rewrite the URL with replaceState:

function cleanUrl(url: URL) {
  url.searchParams.delete("_h");
  const clean =
    url.pathname +
    (url.search ? url.search : "") +
    (url.hash ? url.hash : "");
  history.replaceState(history.state, "", clean);
}

So if a reader copies the URL out of the address bar to share, the _h param doesn’t tag along. The history entry is also clean: hitting back doesn’t replay the highlight.

The flash animation

The original ask from a user was “have it flash yellow and go back to normal after a few seconds.” Two principles for the keyframe:

  1. No layout shift while the animation runs. Animating padding would push the surrounding text around mid-flash. Bad. The “puffed up” look comes from box-shadow instead, which doesn’t affect layout.
  2. End state is invisible. Browser default <mark> is highlighter yellow. If the keyframe ends and the rule resets, the mark goes back to that default and the prose looks weird forever. So the resting style is background: transparent; color: inherit; and animation-fill-mode: forwards keeps the end state locked.
.search-target {
  background: transparent;
  color: inherit;
  border-radius: 3px;
  animation: search-target-flash 2.6s ease-out 1 forwards;
}

@keyframes search-target-flash {
  0%,
  18% {
    background: oklch(0.94 0.18 95);
    color: oklch(0.22 0.04 80);
    box-shadow: 0 0 0 3px oklch(0.94 0.18 95 / 0.5);
  }
  100% {
    background: transparent;
    color: inherit;
    box-shadow: 0 0 0 0 transparent;
  }
}

Yellow flash for the first ~470ms (0-18% of 2.6s), then a 2.1s fade back to invisible. The eye lands on the spot, then the prose reads normally a moment later.

Why this is actually nicer than text fragments

I went into this annoyed at the platform and came out preferring the custom version, which is not the usual story.

A few wins I would have given up if text fragments had worked:

  • Animation control. ::target-text is a static style; you can’t run a keyframe on it because it’s a pseudo-element scoped to the browser’s text fragment activation, not a real DOM node. With a real <mark>, I can flash, fade, pulse, whatever.
  • Scroll behavior. Browsers scroll text fragments to the top of the viewport by default. I prefer block: 'center'. Mine, my call.
  • Works the same in soft and hard navigation. Click a search result, hit back, click another search result: the highlight fires every time because astro:page-load fires every time. Text fragments would have only worked on the first hard load.
  • Clean shareable URLs. After the highlight, _h is gone. With text fragments the URL strips itself too, but the timing is browser-controlled and finicky.

The sum total of the custom path is withSearchHighlight() (about 10 lines), the layout script (about 50 lines including the URL-clean helper), and the keyframe (about 15 lines of CSS). Roughly 75 lines of mine, full control. Beats the platform’s two-line version that didn’t actually work.

When you’d still reach for text fragments

If you don’t have view transitions enabled and you want the cheapest possible “scroll to a phrase from an external link” feature: text fragments, full stop. They’re a one-line URL change and zero runtime code. Search engines also surface them directly in some cases, which is a real SEO + AI Overview perk you can’t replicate with a custom param.

The conflict in this post is specific to the Astro <ClientRouter /> plus Navigation API combination. If your site is plain MPA with no router intercepting navigations, text fragments will Just Work.

Try it

Open the search palette on this site, type a word that you know appears deep in some other post, hit Enter. Watch for the yellow flash. The URL ends up clean a beat later.

If you’re building something similar and want the source, the three files in play are src/components/shell/SiteSearch.tsx, src/layouts/main.astro, and src/styles/global.css. Same family of “let the client do the heavy lifting” thinking as running SQLite in the browser : when the platform doesn’t quite let you have what you want, sometimes the right move is to do it yourself in 75 lines.