Dock
UIA macOS-style dock: the tile under the pointer swells, its neighbours swell less, and the row pushes apart to make room.
Components / UI
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.
click · the pill slides on a view transition
Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.
Adjust · props are custom properties, set inline, so you edit the running instance
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
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.
One command adds the integration, the base tokens and every component. Then import what you use.
npx astro add moonarcpnpm astro add moonarcbunx astro add moonarc---
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>Owns the file, no dependency. The registry item also installs the base tokens.
npx shadcn@latest add https://moonarc.dev/r/pill-nav.jsonThe CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.
The whole component. Self-contained styles in a cascade layer so your classes always win; it imports nothing.
---
/**
* 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>| 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. |
The active pill jumps to the new link (the group animation is 0 s); the hover pill appears without the scale.
ClientRouterThe 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.
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.
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.
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.
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown