# Pill Nav (Moonarc)

A floating pill of links whose active pill glides to the page you chose. Zero JavaScript: the server marks the current link, the pill carries a view-transition-name, and under ClientRouter the browser slides it across the swap; a faint pill follows hover and focus through anchor positioning.

- Import: `import PillNav from '@moonarc/core/PillNav'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/pill-nav.json`
- Tier A · category ui · trigger click, hover
- Readout: `<PillNav current="/work/">`
- Browser support: newly (Chrome 125 · Firefox 147 · Safari 26); elsewhere: without anchor positioning there is no hover pill; without view transitions, or without ClientRouter, the active pill moves at once; without view-transition-class it slides on the browser's default timing instead of the preset
- Measured cost: 0 B JS (CSS 6.2 kB raw)
- Page: https://moonarc.dev/components/pill-nav/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `items` | `{ href: string; label: string }[]` | none | The links. |
| `current` | `string` | none | href of the active item, marked aria-current="page". Unset: the request path; an exact match is the page, else the longest prefix is the section (aria-current="true"), and a path that matches nothing marks no item. |
| `name` | `string` | `'ma-pillnav'` | Suffix of the view-transition-name; two PillNavs on one page need two names. |
| `color` | `string` | none | Pill colour; default the ink token. |
| `textColor` | `string` | none | Text on the pill; default the panel token. |
| `label` | `string` | `'Primary'` | Accessible name of the nav. |

## Usage

```astro
<PillNav
  items={[
    { href: '/', label: 'Home' },
    { href: '/work/', label: 'Work' },
    { href: '/about/', label: 'About' },
    { href: '/contact/', label: 'Contact' },
  ]}
/>

<!-- with a theme toggle at the end -->
<PillNav items={items} current="/work/"><ThemeToggle /></PillNav>
```

## Reduced motion

The active pill jumps to the new link (the group animation is 0 s); the hover pill appears without the scale.

## With ClientRouter

The pill is a named element, so the router morphs it from the old page to the new one. Without the router each page renders the pill in place.

## Craft

- The server decides the active link, so the HTML is the end state and the pill never flashes from the first link to the right one.
- One view-transition-name on the pill, and a view-transition-class shared by every PillNav, so the slide runs on the preset curve and duration, the same tokens as the Tabs indicator.
- The hover pill is anchor-positioned: the hovered link names the anchor, the pill reads its box. It fades in where the anchor is; between two links it moves at once rather than gliding, because anchor() values are the same computed value on every link.
- aria-current on the active link is both the accessibility and the styling hook; there is no class to keep in sync. It says "page" only for the page itself: inside a section it says "true", and on a page no link matches it is absent, so Home is never announced as the page you are on when it is not.
- The hover pill is the list's last <li>, aria-hidden, so the list stays valid HTML and screen readers count only the links.
- Inside the pill the link isolates its stacking context, so the pill can sit at z-index -1 without falling behind the nav background.

## Replaces

- PillNav (React Bits)
- FloatingNav (Aceternity)
- Tabs with layoutId (Framer Motion)
- NavigationMenu indicator (shadcn / Radix)

## Source

```astro
---
/**
 * PillNav — a floating pill of links whose active pill glides to the page
 * you chose, zero JS.
 *
 * The active pill is decided on the server (`current`, or the request path:
 * an exact match is the page, the longest prefix is the section, and no
 * match marks nothing) and carries a view-transition-name, so under
 * <ClientRouter /> the browser
 * itself slides it from the old link to the new one across the swap — no
 * script measures anything. A second, faint pill sits under whichever link
 * is hovered or focused through anchor positioning (the hovered link names
 * the anchor; the pill follows). Without anchor positioning there is no
 * hover pill; without a router the active pill moves at once.
 */
import type { HTMLAttributes } from 'astro/types';

interface Item {
  href: string;
  label: string;
}

interface Props extends HTMLAttributes<'nav'> {
  items: Item[];
  /** href of the active item. Unset: the request path (exact, then the longest prefix). */
  current?: string;
  /** Suffix for the view-transition-name, unique per page; two PillNavs on one page need two. */
  name?: string;
  /** Pill colour and its text; default ink on panel. */
  color?: string;
  textColor?: string;
  /** Accessible name of the nav. */
  label?: string;
}

const { items, current, name = 'ma-pillnav', color, textColor, label = 'Primary', class: className, style, ...rest } = Astro.props;
const path = Astro.url.pathname;
// the page itself (aria-current="page"), else the section it sits in (aria-current="true"); no match marks nothing, so "/"
// is never claimed as the page you are on when it is not
const exact = current ?? items.find((i) => i.href === path)?.href;
const active = exact ?? items.filter((i) => i.href !== '/' && path.startsWith(i.href)).sort((a, b) => b.href.length - a.href.length)[0]?.href;
const vars = [color && `--ma-pillnav-bg:${color}`, textColor && `--ma-pillnav-fg:${textColor}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---

<nav class:list={['ma-pillnav', className]} aria-label={label} style={vars || undefined} {...rest}>
  <ul class="ma-pillnav__list">
    {
      items.map((it) => (
        <li class="ma-pillnav__item">
          <a href={it.href} class="ma-pillnav__link" aria-current={it.href === active ? (exact ? 'page' : 'true') : undefined}>
            {it.href === active && <span class="ma-pillnav__pill" style={`view-transition-name:${name};view-transition-class:ma-pillnav`} aria-hidden="true" />}
            <span class="ma-pillnav__label">{it.label}</span>
          </a>
        </li>
      ))
    }
    <li class="ma-pillnav__hover" aria-hidden="true"></li>
  </ul>
  <slot />
</nav>

<style is:global>
  @layer components {
    :where(.ma-pillnav) {
      display: inline-flex;
      align-items: center;
      gap: 0.375rem;
      padding: 0.25rem;
      border: 1px solid var(--ma-edge);
      border-radius: 999px;
      background: var(--ma-panel);
      backdrop-filter: blur(12px);
      box-shadow: 0 12px 32px -20px rgb(0 0 0 / 0.45);
    }
    :where(.ma-pillnav__list) {
      position: relative;
      display: flex;
      gap: 0.125rem;
      margin: 0;
      padding: 0;
      list-style: none;
    }
    :where(.ma-pillnav__link) {
      position: relative;
      isolation: isolate;
      display: inline-flex;
      align-items: center;
      padding: 0.45em 1em;
      border-radius: 999px;
      font-size: 0.875rem;
      font-weight: 500;
      line-height: 1.2;
      color: inherit;
      text-decoration: none;
      white-space: nowrap;
      opacity: 0.7;
      transition: opacity var(--ma-duration-fast) var(--ma-ease-out), color var(--ma-duration-fast) var(--ma-ease-out);
    }
    /* the hovered (or focused) link is the anchor; the hover pill reads it */
    :where(.ma-pillnav__link:is(:hover, :focus-visible)) {
      opacity: 1;
      anchor-name: --ma-pillnav-h;
    }
    :where(.ma-pillnav__link:focus-visible) {
      outline: 2px solid currentColor;
      outline-offset: 2px;
    }
    :where(.ma-pillnav__link[aria-current]) {
      opacity: 1;
      color: var(--ma-pillnav-fg, var(--ma-panel));
    }
    /* the active pill: one element with a view-transition-name — the router slides it to the next page's active link */
    :where(.ma-pillnav__pill) {
      position: absolute;
      inset: 0;
      z-index: -1;
      border-radius: inherit;
      background: var(--ma-pillnav-bg, var(--ma-ink));
    }
    :where(.ma-pillnav__label) {
      position: relative;
    }
    /* the hover pill: anchored to whichever link is hovered; it fades where the anchor is and leaves when nothing is */
    :where(.ma-pillnav__hover) {
      position: absolute;
      position-anchor: --ma-pillnav-h;
      top: anchor(top);
      left: anchor(left);
      width: anchor-size(width);
      height: anchor-size(height);
      border-radius: 999px;
      background: color-mix(in srgb, var(--ma-ink) 8%, transparent);
      opacity: 0;
      scale: 0.9;
      pointer-events: none;
      transition:
        opacity var(--ma-duration-fast) var(--ma-ease-out),
        scale var(--ma-duration) var(--ma-ease);
    }
    :where(.ma-pillnav__list:has(.ma-pillnav__link:is(:hover, :focus-visible):not([aria-current])) .ma-pillnav__hover) {
      opacity: 1;
      scale: 1;
    }
    @supports not (anchor-name: --a) {
      :where(.ma-pillnav__hover) {
        display: none;
      }
    }
    /* the slide, on the tokens (view-transition-class: every PillNav on the site shares it) */
    :where(:root)::view-transition-group(.ma-pillnav) {
      animation-duration: var(--ma-duration);
      animation-timing-function: var(--ma-ease);
    }
    :where(:root)::view-transition-old(.ma-pillnav),
    :where(:root)::view-transition-new(.ma-pillnav) {
      animation-duration: var(--ma-duration-fast);
      animation-timing-function: var(--ma-ease-out);
      block-size: 100%;
    }
    @media (prefers-reduced-motion: reduce) {
      :where(:root)::view-transition-group(.ma-pillnav) {
        animation-duration: 0s;
      }
      :where(.ma-pillnav__hover) {
        transition: none;
        scale: 1;
      }
    }
  }
</style>

```
