Skip to content

Components / UI

Pill Nav

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.

Live demo

click · the pill slides on a view transition

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

Measured

JavaScript of its own

0 B

CSS 6.2 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.

Browser support

Baseline · newly available

Chrome 125 · Firefox 147 · Safari 26; needs view-transitions, vt-class, anchor, has. 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.

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
import PillNav from '@moonarc/core/PillNav';
---
<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>
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/pill-nav.json

The CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.

Source

The whole component. Self-contained styles in a cascade layer so your classes always win; it imports nothing.

PillNav.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>

Props

PropTypeDefaultDescription
items{ href: string; label: string }[]noneThe links.
currentstringnonehref 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.
namestring'ma-pillnav'Suffix of the view-transition-name; two PillNavs on one page need two names.
colorstringnonePill colour; default the ink token.
textColorstringnoneText on the pill; default the panel token.
labelstring'Primary'Accessible name of the nav.

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.

Why it is built this way

Replaces: PillNav (React Bits) · FloatingNav (Aceternity) · Tabs with layoutId (Framer Motion) · NavigationMenu indicator (shadcn / Radix). See the migration table.

A macOS-style dock: the tile under the pointer swells, its neighbours swell less, and the row pushes apart to make room.

every browserhover
npx astro add moonarc
pnpm astro add moonarc
npx shadcn@latest add …/r/tabs.json

Tabs with an indicator that slides to the selected one and panels that fade in.

every browserclick

click · the page changes from the button
ThemeScript · 473 B raw, inline in <head>

Cycles system → light → dark, and the page changes theme through a circle that grows from the click, on a same-document view transition.

newly · Chrome 111 · Firefox 144 · Safari 18click

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown