GitHub repository

BottomNav

Fixed, thumb-reachable bottom tab bar.

A fixed, bottom-anchored row of equal-width icon+label tabs for mobile/compact app shells: a blurred, elevated `<nav>` + `<ul>/<li>` list with an active highlight, optional per-tab count badges, and safe-area inset padding. Tabs can be real links (`href`) or selection buttons. Ported from a mobile bottom-nav pattern.

Demo

live · storybook Open in Storybook

Demo loads from https://dssoca-storybook.vercel.app/. Run pnpm storybook for it to render locally.

Usage

svelte
<script>
  import { BottomNav } from 'dssoca';

  let active = $state('home');
  const items = [
    { id: 'home',     label: 'Home',     icon: 'grid' },
    { id: 'services', label: 'Services', icon: 'flex' },
    { id: 'activity', label: 'Activity', icon: 'activity', badge: 3 },
    { id: 'account',  label: 'Account',  icon: 'user' },
  ];
</script>

<BottomNav {items} {active} onSelect={(id) => (active = id)} />

Exports the `BottomNavItem` type from `dssoca`. Fixed to the viewport bottom with a blurred/elevated background; the bar height derives from `--ss-bottom-nav-h` (tracks `--ss-shell-top-h`) so it rescales with the size axis. Each tab is a ≥44px hit target (WCAG 2.5.8) and reserves `env(safe-area-inset-bottom)`. A tab renders as `<a href>` when `href` is set, otherwise a `<button>` (a `disabled` tab always renders as a natively disabled `<button>`, even with `href`); count `badge`s fold into the tab's accessible name. The count badge's position/box/type rescale with the size axis via the `--ss-bottom-nav-badge-*` tokens (top, dx, min-w, h, fs, px).

HTML

CSS only — static markup The exact markup the component renders, generated at build time. Use it with dssoca/vanilla.css — see Plain HTML & CSS.

html
<nav class="ss-bottom-nav" aria-label="Primary">
  <ul class="list">
    <li class="row">
      <a class="tab active" href="#home" aria-current="page">
        <span class="glyph">
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter" class="ss-icon" style="width:20px;height:20px" aria-hidden="true" focusable="false">
            <path d="M4 11l8-8 8 8"/>
            <path d="M6 9v12h12V9"/>
            <path d="M10 21v-6h4v6"/>
          </svg>
        </span>
        <span class="label">Home</span>
      </a>
    </li>
    <li class="row">
      <a class="tab" href="#logs" aria-label="Logs, 3">
        <span class="glyph">
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter" class="ss-icon" style="width:20px;height:20px" aria-hidden="true" focusable="false">
            <path d="M4 6h16M4 12h16M4 18h10"/>
          </svg>
          <span class="badge" aria-hidden="true">3</span>
        </span>
        <span class="label">Logs</span>
      </a>
    </li>
  </ul>
</nav>

Static markup: set aria-current="page" on the active tab yourself.

Props

PropTypeDefaultDescription
itemsBottomNavItem[]—Tabs ({ id, label, icon, href?, badge?, disabled? }) rendered left → right in equal-width columns; has a built-in default. A `disabled` tab renders inert: no navigation/`onSelect`, `aria-disabled="true"`, skipped by the tab order.
activestring—Id of the active tab; highlighted and marked `aria-current="page"`.
onSelect(id: string) => void—Fired with the tab id on activation (also fires for `href` tabs as an SPA fallback).
ariaLabelstring'Primary'Accessible name for the wrapping `<nav>` landmark.
size'sm' | 'md' | 'lg'—Per-instance size override; inherits the ancestor `data-size-variant` when unset.
↑↓ navigate · ↵ open · esc close

Keyboard shortcuts

No shortcuts registered

Shortcuts added through the dssoca registry will show up here.

Single-key shortcuts