NavPill
Floating pill-shaped navigation, the only global navigation element
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
| Prop | Type | Note |
|---|---|---|
logo | string | Required. 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. |
logoOnLight | string | Optional (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. |
logoAlt | string | Optional. 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. |
homeUrl | string | Default '/'. Destination of the symbol. |
Tokens
| Property | Value | Token |
|---|---|---|
| Background | #000000, both color modes (deliberate exception: maximum contrast, theme-invariant) | --nav-bg |
| Text | #FFFFFF | --nav-text |
| Border-radius | 16px, derived from the scale: var(--radius-lg) | --nav-radius |
| Height | 52px | --nav-height |
| Shadow | 0 8px 24px -4px rgba(0,0,0,0.15) | --nav-shadow |
| Typography | Georama 14px / 600 | --text-nav |
| Item padding | 8px 12px (desktop) / 6px 8px (mobile) | --nav-item-padding (desktop) |
| Gap between items | space-sm (desktop) / space-xs (mobile) | n/a |
| Item radius | 8px, 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.
| State | Treatment |
|---|---|
| Inactive | White text, no background. Full opacity. |
| Hover / focus | The indicator slides under the pointed item. |
| Active (at rest) | The indicator rests under the item with aria-current. |
| No active item | Indicator 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 mark | Variant |
|---|---|
Section with data-nav-tone="dark" (VideoHero ships with it) | logo (light) |
Section with data-nav-tone="light" | logoOnLight (dark) |
| Nothing marked, light scheme | logoOnLight |
| Nothing marked, dark scheme | logo |
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
| Viewport | Position | Behavior |
|---|---|---|
| Desktop (≥768px) | Top, left | Smart scroll: hides when scrolling down (>80px), shows when scrolling up. translateY, 0.3s ease-out. |
| Mobile (<768px) | Bottom, centered | Fixed, 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, left | Fixed, 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
| Decision | Why |
|---|---|
| Left-aligned on desktop | With 2-3 items, centering leaves dead space |
| Hides on scroll down (smart scroll) | Narrative site: a fixed nav steals reading space |
| Bottom center on mobile | Within thumb reach. Auto width avoids stretching |
| 16px radius, not a full pill | Softer, less app-like |
| No opacity dimming | Items at 0.5 opacity looked broken. A background fill is cleaner. |
| Hover bg > active bg | Three 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. |