Skip to main content
ENKO

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

ComponentPurposeLandmark
TabsSwitch between content sections in one view—
Bottom tab bar (iOS app mobile)Main mobile navigation at the bottom of the screen<nav>
StepperShow progress through multi-step flows—
BreadcrumbsShow where the user is in the page hierarchy<nav>
MenubarHorizontal bar of dropdown menus—
Side navigationSection-level navigation in a sidebar<nav>
Tree viewExpandable list for nested structures—
PaginationMove between pages of results<nav>
SheetSlide-out panel from any screen edge—

Choosing between them

NeedUse
Switch views, same page, same dataTabs
Move between top-level app areasBottom tab bar (mobile) / Side navigation (desktop)
Advance through an ordered, irreversible flowStepper
Show position in a hierarchy the user navigated downBreadcrumbs
Page through a long result setPagination
Show secondary content without leaving the pageSheet

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" and size="lg" for the underline style shown here

Anatomy

┌──────────┬──────────┬──────────┐
│  Recent  │  Sent    │ Received │
├══════════┴──────────┴──────────┤   active: 2px --ds-primary underline
│                                │
│  panel content                 │
└────────────────────────────────┘

Variants

VariantDimensionUse
LinestyleDefault — underline on the active tab
ScrollablelayoutMore 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

StateVisual
Active--ds-primary label, weight 600, 2px underline
Inactive--ds-muted label, weight 500, no underline
Hover--ds-heading label, --ds-surface background
Focus-visibleFocus 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", with aria-controls and aria-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.
KeyAction
← →Previous / next tab (wraps)
Home / EndFirst / last tab
TabMove from the tab list into the panel

Tokens

--ds-primary · --ds-muted · --ds-surface · --ds-border · --ds-focus-ring

Tabs
Sent transfers shown here.

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

PartTokenValue
Bar height--ds-size-bottomnav52px
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-icon20px
Label size--ds-text-2xs10px

Variants

VariantDimensionUse
Four itemslayoutDefault
Five itemslayoutCeiling — 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

StateRendering
Inactive--ds-bottomtab-inactive icon and label
Active--ds-bottomtab-active, aria-current="page"
With badgeCount or dot in --ds-bottomtab-badge-bg, folded into the accessible name
Active reselectedScrolls 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-label should 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.

KeyAction
TabEach item is its own stop
EnterNavigate

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

Bottom tab bar
Home screen

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

VariantStepsUse
Circle4+Verification, onboarding
Line2–3Transfer flow
Vertical lineanyStatus 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 stateVisualAnnounced as
CompletedFilled, check icon"Step 1, Amount, completed"
CurrentFilled --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

KeyAction
TabOne stop for the whole stepper
← →Move between reachable steps
EnterGo 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

Circle
✓
Account
✓
Details
3
Review
4
Confirm
Line (horizontal)
Line (vertical)

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

VariantDimensionUse
FulllayoutThree levels or fewer
CollapsedlayoutMiddle 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

StateRendering
FullEvery level shown
CollapsedMiddle levels behind an expandable button, below --ds-bp-mobile or past collapseAfter
CurrentLast 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

KeyAction
TabThrough the links and the collapse control
EnterFollow, or expand the collapsed segment

Tokens

--ds-link · --ds-muted · --ds-heading · --ds-text-sm

Breadcrumb

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

StateRendering
ClosedAll menus collapsed
OpenOne 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" with role="menuitem" children; the whole bar is one Tab stop.
  • ← → move between menus, ↓ opens a menu, Esc closes it and returns focus to its trigger.
  • Checkable items use role="menuitemcheckbox"; grouped choices use role="menuitemradio".

Menubar is a desktop pattern. It does not appear in the mobile product, where the same actions surface in a sheet.

Keyboard

KeyAction
TabOne stop for the whole menubar
← →Between menus
↓Open the focused menu
↑ ↓Within an open menu
EscClose and return focus to the trigger

Tokens

--ds-primary · --ds-primary-light · --ds-white · --ds-border · --ds-shadow-lg · --ds-z-dropdown

Menubar

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

VariantDimensionUse
Fixedlayout1024px and above
CollapsedlayoutBelow 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

StateVisualToken
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-visible3px 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

KeyAction
TabThrough the items in visual order
EnterFollow
EscClose, 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

VariantDimensionUse
FlatlayoutOne level, no chevrons
NestedlayoutMulti-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

StateRendering
CollapsedChevron inline-start, aria-expanded="false"
Expandedaria-expanded="true"
LeafNo chevron, no aria-expanded
Selectedaria-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" with aria-expanded on expandable nodes and aria-level on 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

KeyAction
TabOne stop for the whole tree
↑ ↓Between visible nodes
→Expand, or move to the first child
←Collapse, or move to the parent
EnterSelect

Tokens

--ds-fg · --ds-surface · --ds-primary-light · --ds-focus-ring · --ds-size-icon-sm

Tree view
Passport.pdf
Proof of address.pdf

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

VariantDimensionUse
NumberedlayoutPointer surfaces, known page count
Prev/Next onlylayoutUnknown 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

StateRendering
First pagePrevious is disabled, not hidden — the control row must not shift
Last pageNext is disabled
CurrentText, not a link, aria-current="page"
TruncatedEllipsis 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

KeyAction
TabThrough each control
EnterGo 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

VariantDimensionUse
BottomlayoutThe mobile default
Start / endlayoutDesktop side panels
FulllayoutA 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

StateRendering
ClosedNot rendered
Entering--ds-duration-slow, --ds-ease-out
OpenFocus trapped, content behind inert, body scroll locked
DraggingBottom sheets only; follows the pointer, snaps back below the dismiss threshold
Exiting--ds-duration-normal, --ds-ease-in

Behavior

PropertyValue
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
SizeBottom sheet heightSide sheet width
sm40vh320px
default60vh400px
lg85vh560px
full100dvh, no top radius100vw
PartSpecification
HeaderTitle --ds-text-lg 600, --ds-space-5 padding, close control inline-end — X icon, 48px target
BodyScrolls independently of the page; --ds-space-5 inline padding
Drag handleBottom sheets only — 36×4px, --ds-border, --ds-radius-full, --ds-space-2 below the top edge
Swipe to dismissReleased 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
PortalRenders 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" with aria-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.
  • Esc closes.

Tokens

--ds-overlay · --ds-z-modal · --ds-radius-2xl · --ds-duration-slow · --ds-ease-out

Sheet

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 distinct aria-label. Two unlabelled navigation landmarks on one page are indistinguishable to a screen reader user.
  • aria-current uses the right value: page for navigation, step for a stepper.
  • Current state is never carried by color alone — weight, underline, border, or icon carries it too.
  • DOM order matches visual order. No order or row-reverse in navigation.

Do and don't

DoDon't
Use a stepper for the transfer flowUse tabs for a flow with required order
Show icon and label in the bottom tab barShip icon-only bottom navigation
Let a completed step be revisitedLet an upcoming step be skipped ahead to
Respect the bottom safe-area insetPin the tab bar to bottom: 0
Give each <nav> an aria-labelShip 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.