NavPill

Floating pill-shaped navigation, the only global navigation element

stable v3.2.0

Preview, desktop

The real component in an iframe (it is position:fixed; mounted loose it would cover this page). Top left, sliding indicator on hover/focus, smart-hide when you scroll inside the frame.

Preview, mobile

Below 768px: bottom, centered, always visible, intrinsic width.

Props

PropTypeNote
logostringRequired. Path to the symbol; <img> in a fixed 30×30 box (avoids reflow). The asset arrives ready: it must be legible on --nav-bg as-is; the component never recolors it.
items{ key, href, label, icon?, isActive }[]Min 2; max: as many as fit at 375px with labels visible. icon is an optional inline SVG.
locale'es' | 'en'Default 'es'. Accessibility labels.
homeUrlstringDefault '/'. Destination of the symbol.

Tokens

PropertyValueToken
Background#000000, both color modes (deliberate exception: maximum contrast, theme-invariant)--nav-bg
Text#FFFFFF--nav-text
Border-radius16px, derived from the scale: var(--radius-lg)--nav-radius
Height52px--nav-height
Shadow0 8px 24px -4px rgba(0,0,0,0.15)--nav-shadow
TypographyGeorama 14px / 600--text-nav
Item padding8px 12px (desktop) / 6px 8px (mobile)--nav-item-padding (desktop)
Gap between itemsspace-sm (desktop) / space-xs (mobile)n/a
Item radius8px, derived from the scale: var(--radius-md)--nav-item-radius
Active background (indicator at rest)rgba(255,255,255,0.12)--nav-active-bg
Hover background (indicator)rgba(255,255,255,0.2)--nav-hover-bg

States, sliding indicator

A single element (.nav-pill__indicator) acts as the highlight: items have no background of their own. It follows the hovered or focused item and rests on the active one (aria-current="page"). With no active item, it hides. All items always at full opacity.

StateTreatment
InactiveWhite text, no background. Full opacity.
Hover / focusThe indicator slides under the pointed item.
Active (at rest)The indicator rests under the item with aria-current.
No active itemIndicator hidden (opacity 0).

The indicator is positioned with getBoundingClientRect; it jumps without transition on first paint and under prefers-reduced-motion, and is recalculated on document.fonts.ready and on resize. When pointer and focus leave the nav, it returns to the active item.

Position

ViewportPositionBehavior
Desktop (≥768px)Top, leftSmart scroll: hides when scrolling down (>80px), shows when scrolling up. translateY, 0.3s ease-out.
Mobile (<768px)Bottom, centeredFixed, intrinsic width. Slides out while the <footer> is on screen (IntersectionObserver) so it doesn't cover its links; with no footer on the page, it never hides.

Constraints

  • Minimum 2 items; maximum, as many as fit at 375px with every label visible (in practice 3–4 short ones). The invariant is the fit, not the count.
  • Labels always visible. Never icons only. No truncation, no scroll, no overflow menu.
  • No dropdowns.
  • No language selector (the Footer handles it).
  • No theme toggle. No badges or counters.
  • The logo arrives ready for the pill: the component never recolors it.

Decisions

DecisionWhy
Left-aligned on desktopWith 2-3 items, centering leaves dead space
Hides on scroll down (smart scroll)Narrative site: a fixed nav steals reading space
Bottom center on mobileWithin thumb reach. Auto width avoids stretching
16px radius, not a full pillSofter, less app-like
No opacity dimmingItems at 0.5 opacity looked broken. A background fill is cleaner.
Hover bg > active bgThree clear states without touching text opacity
Hide over the footer (mobile)The pill fixed at the bottom covered the footer links right when the reader reached them (promoted from elastic)
Maximum by fit, not by count"Max 3" was the initial estimate; elastic and teresa proved that 4 short labels fit. The real invariant is fitting at 375px.
No logo recoloring (v3)The brightness/invert filter assumed dark monochrome icons and destroyed photographic logos (teresa needed an !important fork). The site provides the asset ready.