Elastic Design Studio

NavPill

Floating pill-shaped navigation, the only global navigation element

stable v4.0.1

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 the pill sits at the bottom, centered, with intrinsic width, and holds only the items. The logo leaves it and becomes a corner mark fixed top-left, with no background. Scroll down to the black band: scroll back up a little and the mark reappears in white over it, then turns black again once past it. Keep going to the footer to see the pill slide out.

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. On mobile it is the corner mark's on-dark variant.
logoOnLightstringOptional (4.0). Logo variant legible on light backgrounds, used only by the mobile corner mark. Without it, logo is used on every background: fine for photographic logos, not for a white-only logo, which would vanish on light pages.
logoAltstringOptional. The <img> alt, default ''. Image SEO and fallback only: the link's aria-label ("Home") is the accessible name.
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.

Mobile logo: the corner mark

Below 768px the logo is not inside the pill. It is a separate home link, fixed top-left, with no background. The 30×30 logo aligns with the text column (--site-padding-x plus the device safe area); the link around it is a transparent 52px square, which is the touch target. Opacity 0.7, full on hover and on the home page.

The mark picks the logo variant from what lies under it:

Under the markVariant
Section with data-nav-tone="dark" (VideoHero ships with it)logo (light)
Section with data-nav-tone="light"logoOnLight (dark)
Nothing marked, light schemelogoOnLight
Nothing marked, dark schemelogo

The closest data-nav-tone ancestor of the element under the mark's centre wins (elementsFromPoint, recomputed on scroll and resize). Before JS loads, CSS already applies the color scheme, so there is no flash. The focus ring follows: white on dark, black on light.

A site that paints dark backgrounds outside VideoHero marks them with data-nav-tone="dark". Under a dark scheme, a light section is marked "light".

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.
Corner mark (<768px)Top, leftFixed, no background, tone from what lies under it. Smart scroll: hides on scroll down (>80px), shows on scroll up. Ignores the footer.

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: the component never recolors it. For the corner mark, the site supplies both variants.
  • On mobile the logo never sits inside the pill, and the corner mark has no background of its own.

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.
Logo as a bare mobile corner mark (4.0)The logo took pill width exactly where width is the constraint, and a home link does not need to be under the thumb. A black tile behind it was tried and dropped: it read as a stray button, not a brand mark. Bare, it needs two variants and knowledge of what is under it. Sections declare their tone instead of the component guessing: mix-blend-mode: difference gave off-brand colors (cyan over red) and inverted photographic logos, and reading background-color fails over images and video.