Navigation Menu
A horizontal nav where a highlight pill slides between links on hover and focus — CSS anchor positioning makes a pseudo-element track whichever link owns the anchor.
anchor()
A row of links with a highlight that glides to whatever you point at. There is no JavaScript measuring element positions — a single pseudo-element reads the active link through CSS anchor positioning.
How it works
- The pill is the
<ul>’s::before(before:content-['']), setbefore:absoluteand pushed behind the links withbefore:-z-10.isolateon the<ul>scopes that negative layer so the pill paints above the bar’s background but under the link text. - Hover or focus hands the link’s
<li>an anchor name —has-[a:hover]:[anchor-name:--nav]andhas-[a:focus-visible]:[anchor-name:--nav]. Only one link is ever hovered or focused, so--navhas exactly one owner at a time. - The pill tracks that owner with
inset: anchor(inside). Each edge resolves to the anchored<li>’s matching edge, so the pill exactly covers it, andbefore:transition-[inset,opacity]turns the recompute into a slide when the owner changes. The<ul>reads the anchor viasupports-[anchor-name:--a]:before:[position-anchor:--nav]andsupports-[anchor-name:--a]:before:[inset:anchor(inside)]. - The
supports-[anchor-name:--a]:guard also runs the fade: the pill isbefore:opacity-0until a link is engaged (supports-[anchor-name:--a]:has-[a:hover]:before:opacity-100), so it is invisible at rest and never flashes as a stray box.
Stronger pill with inverted labels
Same sliding pill, louder finish: a solid before:bg-foreground slab, and the label inverts to
white right where the pill passes under it — the flip happens per glyph as the pill slides, with no
duplicated text and no JavaScript recoloring anything.
- The pill is the same
::beforeon the same anchor track as the basic demo, just filledbefore:bg-foregroundinstead ofbefore:bg-accentfor a high-contrast slab. - The links carry
mix-blend-mode: difference(supports-[mix-blend-mode:difference]:mix-blend-difference) and paint white (supports-[mix-blend-mode:difference]:text-white). Difference subtracts the backdrop from the text: white over the light bar resolves to near-black, white over the dark pill resolves to near-white. So the text reads dark on the bar and inverts to light exactly where the pill sits — and it tracks the slide letter by letter, because the pill is what’s moving under the glyphs. isolateon the<ul>scopes the blend to the bar, so the text blends only against the bar’s own background and the pill, never the page behind it.- Why not clip a duplicate copy of the labels to the pill? A moving clip can’t be done here without
JavaScript:
clip-pathcan’t read theanchor()position, and anoverflowwindow that is anchored becomes the containing block for anything inside it — so a duplicate nested in the pill can no longer anchor itself back to the whole bar to stay aligned. Blending sidesteps the copy entirely. - Fallback:
text-muted-foregroundis the base color, so a browser withoutmix-blend-mode— or withoutanchor(), which hides the pill — still shows readable, navigable links. The nav loses the inversion, not its legibility.
Accessibility
- Real
<a href>links inside a<nav aria-label="Main">landmark: native focus order, keyboard activation, and navigation, none of it re-implemented. - The pill is a decorative empty
::beforewith no text, so it is never announced — the same holds for the inverted variant, where the color change is a blend effect, not extra text.
The fallback story
Without the anchor() function the pill has no anchor to read, so the guard holds its opacity at
zero and it never appears. The links still navigate and still shift from text-muted-foreground to
text-foreground on hover and focus — the nav loses the sliding highlight, not its feedback.
Browser support
Minimum stable version per engine, resolved at build time from MDN's browser-compat-data; Baseline status from the official web-features dataset. Everything degrades gracefully — the “when missing” column is the actual behavior, not a broken page.
| Feature | Chrome | Edge | Firefox | Safari | When missing |
|---|---|---|---|---|---|
| anchor() function positioning† | 125 | 125 | 147 | 26 | An element positioned with the anchor() function falls back to its normal position, so an indicator that tracks another element simply does not move; the underlying content stays fully usable. |
† @supports cannot test the anchor() function inside a value, so the badge checks anchor-name: --a — anchor-name and anchor() shipped together in every engine.