Back to docs

Dock

The bottom tab bar: how it's built, how it behaves, and the full story of keeping it above Android's gesture bar. First of the docs deep dives.

Anatomy

A floating bottom tab bar with a sliding highlight pill. Generalized from three tabs to N when the AI blog arrived — nothing in the bar knows the count. Two sections, two docks: the main dock (Home, Resume, AI blog) renders from TABS, the docs dock (README, Components, Checklist, Dock) from DOCS_TABS.

  • One source of truth per sectionchrome.js TABS / DOCS_TABS render the section's links and set --tab-count; the grid and the pill width derive from it, never a hardcoded count. site.js reads tab paths back out of the dock DOM, so there is no parallel path list to keep in sync — the SPA works within whichever section's dock is showing.
  • The pillA single live ::before element gliding on a .28s transform transition the instant --active-index moves. It sits on a tab or between tabs — never outside the dock — and every icon and label stays visible.
  • Same element, every page in the sectionTapping a tab never does a document navigation: site.js fetches the page and swaps <main> in place while the tab bar stays mounted as the same element, so the pill glides instead of re-rendering. The URL updates via history.pushState; back and forward go through popstate. Crossing sections (main ⇄ docs) is a full document navigation — the dock's view-transition name is scoped per section (site-nav-main / site-nav-docs) so a 3-tab dock never morphs into a 4-tab one.
  • Positionbottom: calc(10px + env(safe-area-inset-bottom, 0px)) — the inset is load-bearing on Android. See below.

Interaction

Acknowledge on pointerdown, commit on click — the native-app feel. Neither the animation nor the load waits for the other; both start on tap.

  • pointerdownThe pill glides to the pressed tab, the tab dips via .pressed, and the fetch starts early (~100ms head start; in-flight requests are deduped so the click reuses the prefetch). Primary button only.
  • clickCommits the navigation — fetch, swap, pushState — with a 10ms haptic tick on Android (skipped under reduced-motion).
  • Cancelled pressDrag off or scroll takeover glides the pill back via the pressPreview {from,to} state — no navigation, no vibration. The click handler commits the preview before reading from, or the previewed tab confuses from/to.
  • Rapid tapsStale responses never overwrite newer destinations; modified clicks (new tab, etc.) stay native.
  • FailureA failed fetch falls back to a real navigation.
  • RepaintThe drawer's current-page highlight and the dock's no-active state repaint through the same commit path (Drawer.paint / TabBar.paint on every navigateTo and renderPage) — chrome.js only bakes them for the initial load, so a same-document tab switch would otherwise leave the wrong drawer item highlighted, or the pill invisible after leaving a utility page.
Gliding the pill on arrival after a cross-document transition was tried and removed — seeding it under the old tab across documents was fragile and left it stranded on Android.

Android vs. the gesture bar

Chrome on Android shows a dynamic bottom bar over the gesture area that retracts on scroll, growing the layout viewport downward. A bottom-anchored fixed dock follows it and slides under the gesture bar. Every theory about this died on a real device until measured.

  • Dead end: constant offsetbottom: 10px can't move — wrong. The viewport moves underneath it.
  • Dead end: env() alonebottom: calc(10px + env(safe-area-inset-bottom)) changed nothing, because the meta tag lacked viewport-fit=cover — without cover every env() evaluates to 0. (The retracting bar is browser UI, not a system inset, so env() was also the wrong tool for the dynamic part on its own.)
  • Dead end: JS pin, resize-onlyRemembering the viewport height at scroll top and correcting the drift on resize events made the dock visibly dip-then-snap — the viewport had already moved when the correction landed.
  • Dead end: JS pin, scroll-handlerCorrecting synchronously inside the scroll handler still fought the platform — judder on every scroll.
  • Dead end: the resttransition: bottom, hiding the dock until stable (flashed content behind it), hardcoded offsets, dvh − svh math (polluted by the top toolbar hiding too), worker UA stamping.
  • The fixviewport-fit=cover + live env(). The CSS was already written for cover — the meta just never enabled it. chrome.js (parser-blocking, pre-paint) now appends viewport-fit=cover to the viewport meta on Android; with cover, env(safe-area-inset-bottom) is evaluated against the live viewport, so as the toolbar retracts and the viewport grows, the inset grows in step and the dock tracks it with pure CSS — browser-composited, zero judder. The old "cover applies a frame late and jumps the dock" finding turned out moot: viewport growth and inset growth are the same quantity, so they self-cancel. Android-only — an iOS cover experiment broke scrolling under the Dynamic Island and couldn't be diagnosed without a physical iPhone, so iOS keeps the status quo.

Contract

  • Pill placementOn a tab or between tabs. Never outside the dock; icons and labels always visible.
  • No snapshot keyframesNever keyframe transform on the pill's view-transition snapshot — it replaces the pill's base positioning and misplaces it over other tabs.
  • Utility pagesAbout, Contact, Privacy keep full-document navigations with cross-document view transitions. The dock renders .no-active and the pill hides.
  • Motion budget200–300ms, killed under prefers-reduced-motion.
  • chrome.js stays parser-blockingNo defer — the chrome must exist before the cross-document view-transition snapshot is captured, or the transition silently never runs.

Adding a tab

One TABS (or DOCS_TABS, for the docs section) entry in chrome.js — everything else follows: links, --tab-count, grid, pill width, drawer item, active states.

  • Entry{ page, href, label, icon, fillIcon? } — fillIcon is optional and falls back to the outline icon when Tabler has no fill variant (that's why the AI tab reuses its sparkles).
  • DOM interfaceAll tab-bar DOM goes through the TabBar object (nav/current/paint/indexOf/keyForIndex/indexForKey); data through fetchPage/prefetch/renderPage/swapTo with a pageCache and an inflight dedup map; the single commit path is navigateTo(key, to).
  • Don'tDon't add tabs unasked. Main pages get a TABS entry, docs pages a DOCS_TABS entry; utility pages get a drawer link instead.
More deep dives are coming — this page is the first. The docs index stays the hub.