Navigation
Components for moving between pages and sections.
Senco caps navigation at two levels (see Design principles), so this set is deliberately small. The components below cover moving between top-level areas, moving through a linear flow, and moving inside a single view — nothing else is needed, and a third level of hierarchy is treated as a grouping problem rather than a navigation one.
Components
| Component | Purpose | Landmark |
|---|---|---|
| Tabs | Switch between content sections in one view | — |
| Bottom tab bar (iOS app mobile) | Main mobile navigation at the bottom of the screen | <nav> |
| Stepper | Show progress through multi-step flows | — |
| Breadcrumbs | Show where the user is in the page hierarchy | <nav> |
| Menubar | Horizontal bar of dropdown menus | — |
| Side navigation | Section-level navigation in a sidebar | <nav> |
| Tree view | Expandable list for nested structures | — |
| Pagination | Move between pages of results | <nav> |
| Sheet | Slide-out panel from any screen edge | — |
Choosing between them
| Need | Use |
|---|---|
| Switch views, same page, same data | Tabs |
| Move between top-level app areas | Bottom tab bar (mobile) / Side navigation (desktop) |
| Advance through an ordered, irreversible flow | Stepper |
| Show position in a hierarchy the user navigated down | Breadcrumbs |
| Page through a long result set | Pagination |
| Show secondary content without leaving the page | Sheet |
Tabs and a stepper look similar and mean opposite things. Tabs are non-linear — any tab, any time. A stepper is linear and its later steps are unreachable until earlier ones complete. Using tabs for the transfer flow would tell users they can confirm before entering an amount.
Tabs
Switch between content sections without changing the page. Full-width layout, underline on the active tab.
- 48px height meets minimum touch target
- Keyboard navigable with arrow keys
- Use
variant="line"andsize="lg"for the underline style shown here
Anatomy
┌──────────┬──────────┬──────────┐
│ Recent │ Sent │ Received │
├══════════┴──────────┴──────────┤ active: 2px --ds-primary underline
│ │
│ panel content │
└────────────────────────────────┘
Variants
| Variant | Dimension | Use |
|---|---|---|
| Line | style | Default — underline on the active tab |
| Scrollable | layout | More than four tabs; the row scrolls, never wraps to two rows |
API
interface TabsProps {
label: string; // accessible name for the tab list
tabs: Array<{ id: string; label: string; disabled?: boolean }>;
value: string;
onValueChange: (id: string) => void;
syncToUrl?: boolean; // default: true — a tab state should be shareable
children: React.ReactNode; // the active panel
}
Tabs render role="tablist", not <nav>. They switch views; they do not navigate.
States
| State | Visual |
|---|---|
| Active | --ds-primary label, weight 600, 2px underline |
| Inactive | --ds-muted label, weight 500, no underline |
| Hover | --ds-heading label, --ds-surface background |
| Focus-visible | Focus ring on the tab, underline unchanged |
| Disabled | --ds-disabled-text, skipped by arrow keys |
Behavior
- Tab content loads on first activation and stays mounted afterwards, so switching back does not re-fetch.
- With more than four tabs the row scrolls horizontally with the active tab scrolled into view. It never wraps to two rows.
- The active tab is reflected in the URL so a tab state can be shared and restored.
Content
Tab labels are one or two words, parallel in form, and name the content rather than the action.
Accessibility
- One Tab stop for the whole tab list; arrow keys move between tabs.
role="tablist"/role="tab"/role="tabpanel", witharia-controlsandaria-selected.- The panel has
tabindex="0"so keyboard users can scroll it after selecting the tab. - Tabs are not wrapped in
<nav>— they switch views, they do not navigate.
| Key | Action |
|---|---|
← → | Previous / next tab (wraps) |
Home / End | First / last tab |
Tab | Move from the tab list into the panel |
Tokens
--ds-primary · --ds-muted · --ds-surface · --ds-border · --ds-focus-ring
Bottom tab bar (iOS app mobile)
Fixed to the bottom of the screen. Always visible while scrolling.
- 4-5 tabs max — more gets too crowded
- Always show icons with labels. Icon-only isn't clear enough.
- Supports notification badges (number or dot)
Anatomy
| Part | Token | Value |
|---|---|---|
| Bar height | --ds-size-bottomnav | 52px |
| Background | --ds-bottomtab-bg | --ds-white |
| Top border | --ds-bottomtab-border | --ds-gray-200 |
| Active icon + label | --ds-bottomtab-active | --ds-accent-600 |
| Inactive icon + label | --ds-bottomtab-inactive | --ds-gray-500 |
| Badge fill / text | --ds-bottomtab-badge-bg / -text | --ds-red-600 / white |
| Icon size | --ds-size-icon | 20px |
| Label size | --ds-text-2xs | 10px |
Variants
| Variant | Dimension | Use |
|---|---|---|
| Four items | layout | Default |
| Five items | layout | Ceiling — a sixth destination means the information architecture is wrong |
API
interface BottomTabBarProps {
items: Array<{
id: string;
label: string; // one word — 10px leaves no room for two
icon: React.ReactNode;
href: string;
badge?: number | "dot";
}>; // 4–5 items
activeId: string;
onActiveReselect?: (id: string) => void; // tapping the active tab scrolls its view to top
}
Badges are folded into the item's accessible name ("Activity, 3 new"), never announced as a loose number.
States
| State | Rendering |
|---|---|
| Inactive | --ds-bottomtab-inactive icon and label |
| Active | --ds-bottomtab-active, aria-current="page" |
| With badge | Count or dot in --ds-bottomtab-badge-bg, folded into the accessible name |
| Active reselected | Scrolls that view to top; no navigation |
Behavior
- The bar sits above
env(safe-area-inset-bottom). Without the inset, the rightmost tab lands under the home indicator and a large share of taps are swallowed by the OS. - The bar is fixed, not sticky. It does not hide on scroll — a user who has scrolled a long transaction list should not have to scroll back up to navigate.
- Tapping the active tab scrolls that view to the top.
- Each tab's hit area is 48px tall even though the bar is 52px including its label — the target extends over the full bar height.
Content
Labels are one word, and they name a place rather than an action: "Home", "Send", "Activity", "Profile". A 10px label has no room for two words, and the icon is not sufficient on its own — icon-only bottom navigation is consistently misread in usability testing, particularly for "Activity" versus "Home".
Accessibility
- Wrapped in
<nav aria-label="Main">. - The active tab carries
aria-current="page". - A badge is announced through the tab's accessible name — "Activity, 3 new" — not as a separate element.
- Labels are visible text, so they are also the accessible name. No
aria-labelshould contradict them.
Keyboard
A touch surface. On an attached keyboard each item is a link in the tab order; there is no arrow-key navigation, because the bar is a set of destinations rather than a composite control.
| Key | Action |
|---|---|
Tab | Each item is its own stop |
Enter | Navigate |
Tokens
--ds-bottomtab-bg · --ds-bottomtab-border · --ds-bottomtab-active · --ds-bottomtab-inactive · --ds-bottomtab-badge-bg · --ds-bottomtab-badge-text · --ds-size-bottomnav · --ds-size-icon · --ds-text-2xs
Stepper
Shows progress through multi-step flows. Three variants: circle for longer flows (4+ steps), line for short ones (2-3 steps), and vertical line for timeline tracking with timestamps.
Anatomy
Per step: a dot, a connecting line, a label, and an optional timestamp. Completed dots are 24px with a check; active and future dots are 12px.
Variants
| Variant | Steps | Use |
|---|---|---|
| Circle | 4+ | Verification, onboarding |
| Line | 2–3 | Transfer flow |
| Vertical line | any | Status timeline with timestamps |
API
interface StepperProps {
variant?: "circle" | "line" | "vertical"; // default: "line" for 2–3 steps, "circle" for 4+
steps: Array<{ id: string; label: string; timestamp?: Date }>;
currentIndex: number;
completedIndices: number[];
errorIndex?: number;
onStepSelect?: (index: number) => void; // completed steps only
}
States
| Step state | Visual | Announced as |
|---|---|---|
| Completed | Filled, check icon | "Step 1, Amount, completed" |
| Current | Filled --ds-primary, number | "Step 2, Recipient, current step" |
| Upcoming | --ds-gray-300 outline, number | "Step 3, Confirm" |
| Error | --ds-red-600, alert icon | "Step 2, Recipient, needs attention" |
Behavior
- Completed steps are tappable and return the user to that step with their data intact. Upcoming steps are not.
- Progress is never lost on back navigation. A user who returns from step 3 to step 1 finds step 2 still filled.
- The stepper is always visible on mobile, at the top of the flow, above the scroll region.
Content
Step labels are one or two words naming the task, not the screen — "Amount", "Recipient", "Confirm".
Accessibility
- Rendered as an ordered list, so the count and position are conveyed structurally.
- Current step carries
aria-current="step". - State is carried by icon and text, never by color alone — a completed step has a check, not just a fill.
- Upcoming steps are marked
aria-disabled="true"rather than removed from the tab order.
Keyboard
| Key | Action |
|---|---|
Tab | One stop for the whole stepper |
← → | Move between reachable steps |
Enter | Go to the focused step, completed steps only |
Tokens
--ds-timeline-completed-dot · --ds-timeline-active-dot · --ds-timeline-future-dot · --ds-timeline-line-completed · --ds-red-600
Breadcrumbs
Shows the user's location in the page hierarchy. The last item is the current page — plain text, not a link.
- Collapses to ellipsis on mobile when the path gets deep
- Separator between items (chevron or slash)
Anatomy
A row of links separated by an aria-hidden chevron, ending in plain text for the current page.
Variants
| Variant | Dimension | Use |
|---|---|---|
| Full | layout | Three levels or fewer |
| Collapsed | layout | Middle levels behind an expandable control |
API
interface BreadcrumbsProps {
items: Array<{ label: string; href?: string }>; // the last item omits href — it is the current page
collapseAfter?: number; // default: 3 — collapses the middle into an expandable button
}
Separators are decorative and aria-hidden. A screen reader announcing "Home slash Settings" is reading punctuation as content.
States
| State | Rendering |
|---|---|
| Full | Every level shown |
| Collapsed | Middle levels behind an expandable button, below --ds-bp-mobile or past collapseAfter |
| Current | Last item, plain text, aria-current="page" |
Behavior
Because navigation is capped at two levels, breadcrumbs appear mainly in the documentation site rather than in the product. In the app, a back affordance in the app bar covers the same need with less chrome.
The separator is decorative and aria-hidden; a screen reader announcing "Home slash Settings slash Security" is reading punctuation as content.
Content
Each crumb is the destination's page title, verbatim. A crumb that paraphrases a page title makes the trail untrustworthy.
Accessibility
- Wrapped in
<nav aria-label="Breadcrumb">containing an ordered list. - The current page carries
aria-current="page"and is not a link. - The collapsed ellipsis is a button that expands the full path, not a static character.
Keyboard
| Key | Action |
|---|---|
Tab | Through the links and the collapse control |
Enter | Follow, or expand the collapsed segment |
Tokens
--ds-link · --ds-muted · --ds-heading · --ds-text-sm
Menubar
Horizontal row of dropdown menus, usually at the top of a view. Each item opens a grouped set of actions.
- Active menu uses primary color with a subtle background tint
- Supports checkable items and radio groups inside menus
Anatomy
A horizontal row of menu triggers; each opens a panel of items, which may include separators, checkbox items, and radio groups.
Variants
One. A desktop surface; on touch the same actions open in a Sheet.
API
interface MenubarProps {
label: string;
menus: Array<{
id: string;
label: string;
items: Array<
| { type: "action"; label: string; onSelect: () => void; disabled?: boolean }
| { type: "checkbox"; label: string; checked: boolean; onChange: (v: boolean) => void }
| { type: "radio"; label: string; value: string; groupId: string }
| { type: "separator" }
>;
}>;
}
A desktop pattern. On touch the same actions surface in a Sheet.
States
| State | Rendering |
|---|---|
| Closed | All menus collapsed |
| Open | One menu open; others close on open |
| Active item | --ds-primary with a tinted background |
Behavior
Opening one menu closes any other. Moving the pointer across triggers while a menu is open switches menus without a second click.
Content
Trigger labels are single words. Item labels are verb-first.
Accessibility
role="menubar"withrole="menuitem"children; the whole bar is one Tab stop.←→move between menus,↓opens a menu,Esccloses it and returns focus to its trigger.- Checkable items use
role="menuitemcheckbox"; grouped choices userole="menuitemradio".
Menubar is a desktop pattern. It does not appear in the mobile product, where the same actions surface in a sheet.
Keyboard
| Key | Action |
|---|---|
Tab | One stop for the whole menubar |
← → | Between menus |
↓ | Open the focused menu |
↑ ↓ | Within an open menu |
Esc | Close and return focus to the trigger |
Tokens
--ds-primary · --ds-primary-light · --ds-white · --ds-border · --ds-shadow-lg · --ds-z-dropdown
Side navigation
Sidebar navigation for sections within a page or app. Active item gets a left border in primary color.
- Supports nested sections with indentation
- Fixed on desktop, collapsible on mobile
Anatomy
A vertical stack of sections, each with a title and its items. The active item carries a left border in --ds-sidebar-active.
Variants
| Variant | Dimension | Use |
|---|---|---|
| Fixed | layout | 1024px and above |
| Collapsed | layout | Below 1024px — opens as a Sheet |
API
interface SideNavProps {
label: string; // distinct from the main navigation landmark
sections: Array<{
title: string;
items: Array<{ label: string; href: string }>;
}>;
activeHref: string;
collapsedBelow?: number; // default: 1024 — becomes a Sheet below this width
}
States
| State | Visual | Token |
|---|---|---|
| Default | --ds-fg label | --ds-sidebar-bg |
| Hover | --ds-sidebar-hover background | — |
| Active | --ds-sidebar-active left border and label, tinted background | --ds-sidebar-active-bg |
| Focus-visible | 3px solid outline, inset | --ds-focus |
Behavior
- Below 1024px the sidebar collapses behind a trigger and opens as a sheet.
- Active section stays expanded on navigation; other sections retain their open/closed state.
- Scroll position inside the sidebar persists across page changes.
Content
Section titles are nouns; item labels match their destination's page title.
Accessibility
<nav aria-label="Sections">, distinct from the main navigation landmark.- Active item carries
aria-current="page". - Nested groups use a disclosure pattern with
aria-expanded, not a nested tree role.
Keyboard
| Key | Action |
|---|---|
Tab | Through the items in visual order |
Enter | Follow |
Esc | Close, when collapsed into a Sheet |
Tokens
--ds-sidebar-bg · --ds-sidebar-border · --ds-sidebar-active · --ds-sidebar-active-bg · --ds-sidebar-hover
Tree view
Expandable, nested list — good for file browsers, account structures, or category trees.
- Chevron icons show which nodes can expand
- Indentation shows depth
Anatomy
Rows at increasing indentation; expandable rows carry a chevron inline-start.
Variants
| Variant | Dimension | Use |
|---|---|---|
| Flat | layout | One level, no chevrons |
| Nested | layout | Multi-level with expand and collapse |
API
interface TreeNode {
id: string;
label: string;
children?: TreeNode[];
}
interface TreeViewProps {
label: string;
nodes: TreeNode[];
expandedIds: string[];
onExpandedChange: (ids: string[]) => void;
selectedId?: string;
onSelect?: (id: string) => void;
}
Depth is conveyed by aria-level, not by indentation — a screen reader cannot see an indent.
States
| State | Rendering |
|---|---|
| Collapsed | Chevron inline-start, aria-expanded="false" |
| Expanded | aria-expanded="true" |
| Leaf | No chevron, no aria-expanded |
| Selected | aria-selected="true" |
Behavior
Expansion state is controlled and persists across renders. Selecting a parent does not select its children.
Content
Node labels are nouns. Depth carries the relationship, so labels do not repeat their parent.
Accessibility
role="tree"/role="treeitem"witharia-expandedon expandable nodes andaria-levelon each item.- One Tab stop;
↑↓move between visible nodes,→expands or descends,←collapses or ascends. - Indentation is visual only — depth must be conveyed by
aria-level, since a screen reader cannot see the indent.
Tree view does not appear in the Senco product. It exists for the documentation site and for potential admin surfaces, and the two-level navigation cap means it should not be introduced into the app.
Keyboard
| Key | Action |
|---|---|
Tab | One stop for the whole tree |
↑ ↓ | Between visible nodes |
→ | Expand, or move to the first child |
← | Collapse, or move to the parent |
Enter | Select |
Tokens
--ds-fg · --ds-surface · --ds-primary-light · --ds-focus-ring · --ds-size-icon-sm
Pagination
Page-by-page navigation for long result sets. Previous/Next buttons on each end, ellipsis for large ranges.
Anatomy
Previous, a windowed range of page controls with ellipses, and Next.
Variants
| Variant | Dimension | Use |
|---|---|---|
| Numbered | layout | Pointer surfaces, known page count |
| Prev/Next only | layout | Unknown total |
API
interface PaginationProps {
page: number; // 1-based
pageCount: number;
onPageChange: (page: number) => void;
siblingCount?: number; // default: 1
}
Each page control's accessible name is "Page 3", not "3". Previous is disabled on the first page rather than hidden, so the control row does not shift.
States
| State | Rendering |
|---|---|
| First page | Previous is disabled, not hidden — the control row must not shift |
| Last page | Next is disabled |
| Current | Text, not a link, aria-current="page" |
| Truncated | Ellipsis is aria-hidden; the surrounding link names convey the skip |
Behavior
- Transaction history uses infinite scroll on mobile and pagination on desktop. Pagination gives a keyboard user a reachable end of page; infinite scroll does not.
- Previous is disabled on the first page rather than hidden, so the control row does not shift.
- The current page is shown as text, not as a link.
Content
Controls are named "Page 3", not "3". Position is stated as "Page 3 of 10".
Accessibility
<nav aria-label="Pagination">.- Current page carries
aria-current="page". - Each page link has an accessible name of "Page 3", not "3".
- The ellipsis is
aria-hidden; skipped ranges are conveyed by the accessible names of the surrounding links.
Keyboard
| Key | Action |
|---|---|
Tab | Through each control |
Enter | Go to that page |
Tokens
--ds-primary · --ds-muted · --ds-border · --ds-disabled-text · --ds-focus-ring
Sheet
Slide-out panel from any edge of the screen. Bottom sheet is the main mobile pattern for Senco — used for navigation drawers, detail panels, and filters.
- Overlay backdrop closes the sheet on tap
- Sizes: sm, default, lg, full
Anatomy
Backdrop, panel with a leading-edge radius, header with the title and close control, scrollable body, optional footer.
Variants
| Variant | Dimension | Use |
|---|---|---|
| Bottom | layout | The mobile default |
| Start / end | layout | Desktop side panels |
| Full | layout | A task that fills the viewport |
API
interface SheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
side?: "bottom" | "start" | "end"; // default: "bottom"
size?: "sm" | "default" | "lg" | "full"; // default: "default"
title: string; // required — a modal surface needs an accessible name
children: React.ReactNode;
}
Sheets do not stack; opening a second replaces the first. Swipe-to-dismiss is never the only dismissal — the backdrop, Escape, and a visible close control all work (SC 2.5.7).
States
| State | Rendering |
|---|---|
| Closed | Not rendered |
| Entering | --ds-duration-slow, --ds-ease-out |
| Open | Focus trapped, content behind inert, body scroll locked |
| Dragging | Bottom sheets only; follows the pointer, snaps back below the dismiss threshold |
| Exiting | --ds-duration-normal, --ds-ease-in |
Behavior
| Property | Value |
|---|---|
| Enter | --ds-duration-slow (300ms), --ds-ease-out |
| Exit | --ds-duration-normal (180ms), --ds-ease-in |
| Radius | --ds-radius-2xl on the leading edge only |
| Backdrop | --ds-overlay |
| Z-index | --ds-z-modal |
| Size | Bottom sheet height | Side sheet width |
|---|---|---|
sm | 40vh | 320px |
default | 60vh | 400px |
lg | 85vh | 560px |
full | 100dvh, no top radius | 100vw |
| Part | Specification |
|---|---|
| Header | Title --ds-text-lg 600, --ds-space-5 padding, close control inline-end — X icon, 48px target |
| Body | Scrolls independently of the page; --ds-space-5 inline padding |
| Drag handle | Bottom sheets only — 36×4px, --ds-border, --ds-radius-full, --ds-space-2 below the top edge |
| Swipe to dismiss | Released past 30% of the sheet's height, or with downward velocity above 0.5px/ms. Otherwise it snaps back over --ds-duration-normal |
| Backdrop | --ds-overlay — gray-900 at 10% in light, 30% in dark |
| Portal | Renders into a portal that carries the current theme scope (data-ds-theme), so semantic tokens resolve outside the page's own scope |
- Body scroll is locked while a sheet is open.
- A bottom sheet can be dismissed by swiping down, but swipe is never the only dismissal — the backdrop,
Esc, and a visible close control all work (SC 2.5.7). - Sheets do not stack. Opening a second sheet replaces the first.
Content
The title names the task. A sheet without a title is a modal surface with no accessible name.
Accessibility
role="dialog"witharia-modal="true"and a labelled heading.- Focus moves into the sheet on open and returns to the trigger on close.
- Focus is trapped while open — see Utilities.
Esccloses.
Tokens
--ds-overlay · --ds-z-modal · --ds-radius-2xl · --ds-duration-slow · --ds-ease-out
Design and accessibility
All navigation components need to work with keyboards — arrow keys for moving between items, Enter or Space to select. Mark the current page or section with aria-current so screen readers can announce it. Use <nav> landmarks with descriptive aria-label values for Tabs, Bottom tab bar, and Breadcrumbs. Navigation order in the DOM should match visual order on screen.
Rules that apply to every component here
- Each composite is one Tab stop. Arrow keys move within it.
- Every
<nav>has a distinctaria-label. Two unlabelled navigation landmarks on one page are indistinguishable to a screen reader user. aria-currentuses the right value:pagefor navigation,stepfor a stepper.- Current state is never carried by color alone — weight, underline, border, or icon carries it too.
- DOM order matches visual order. No
orderorrow-reversein navigation.
Do and don't
| Do | Don't |
|---|---|
| Use a stepper for the transfer flow | Use tabs for a flow with required order |
| Show icon and label in the bottom tab bar | Ship icon-only bottom navigation |
| Let a completed step be revisited | Let an upcoming step be skipped ahead to |
| Respect the bottom safe-area inset | Pin the tab bar to bottom: 0 |
Give each <nav> an aria-label | Ship three unlabelled navigation landmarks |
Known gaps
- No mobile pattern documented for Hover card's desktop-only interaction in the same nav context.
- Tree view and Menubar are specified but unused in the product; they are documentation-site components.
- Sheet stacking is prohibited by rule but not enforced by the component.