unscripted/ui

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-['']), set before:absolute and pushed behind the links with before:-z-10. isolate on 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] and has-[a:focus-visible]:[anchor-name:--nav]. Only one link is ever hovered or focused, so --nav has 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, and before:transition-[inset,opacity] turns the recompute into a slide when the owner changes. The <ul> reads the anchor via supports-[anchor-name:--a]:before:[position-anchor:--nav] and supports-[anchor-name:--a]:before:[inset:anchor(inside)].
  • The supports-[anchor-name:--a]: guard also runs the fade: the pill is before:opacity-0 until 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 ::before on the same anchor track as the basic demo, just filled before:bg-foreground instead of before:bg-accent for 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.
  • isolate on 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-path can’t read the anchor() position, and an overflow window 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-foreground is the base color, so a browser without mix-blend-mode — or without anchor(), 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 ::before with 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.

FeatureChromeEdgeFirefoxSafariWhen missing
anchor() function positioningLimited availability12512514726An 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.