@vinyasa/navigation
v2.0.3
Published
10 navigation primitives: Breadcrumbs, Pagination, Tabs, NavigationMenu, Sidebar, TopNavigation, Stepper, WorkspaceSwitcher, TreeView, CommandNavigation.
Readme
@vinyasa/navigation
10 navigation primitives: Breadcrumbs, Pagination, Tabs, NavigationMenu, Sidebar, TopNavigation, Stepper, WorkspaceSwitcher, TreeView, CommandNavigation.
Installation
pnpm add @vinyasa/navigation @vinyasa/overlay @vinyasa/icons @vinyasa/tokens @vinyasa/typography react react-dom@vinyasa/tokens is a peer dependency: every component's styles reference the shared design-token contract, and rendering requires a VinyasaProvider (from @vinyasa/tokens) somewhere above them in the tree. @vinyasa/overlay is a peer for a narrower reason — WorkspaceSwitcher is built directly on its DropdownMenu, and CommandNavigation wraps its CommandPalette — both compose rather than reimplement. See @vinyasa/tokens's README and @vinyasa/overlay's README.
No separate stylesheet import is needed — each component's CSS is bundled into its own JS entry and loads automatically when you import it.
This package has no root export — every component is subpath-only (import { Breadcrumbs } from '@vinyasa/navigation/breadcrumbs', never from '@vinyasa/navigation'). A root barrel re-exporting all 10 components would let a bundler tree-shake the unused JS down to just the ones you import, but the CSS side-effect imports the others carry are not eligible for the same tree-shaking (confirmed empirically with both esbuild and Rollup on this monorepo's other packages). Removing the root entry entirely makes that the only possible outcome, not something that depends on your bundler being clever enough to shake it out.
Composition
Every component ships both a flat, spec-array-driven simple call (<Sidebar sections={...}>, <Tabs tabs={...}>) and a compound .Root/.Item/... namespace for anything the flat shape can't express — a custom icon mixed per item, a router Link in place of a plain href, richer per-item content. Reach for the simple call first; drop to the compound API only once a use case outgrows the spec shape.
Usage
Breadcrumbs
An accessible breadcrumb trail (<nav aria-label> + <ol>) that renders a flat label/href item list. The last item is always the current page — non-linked, regardless of whether it has an href.
import { Breadcrumbs } from '@vinyasa/navigation/breadcrumbs';
<Breadcrumbs
items={[
{ label: 'Acme Cloud', href: '/' },
{ label: 'Projects', href: '/projects' },
{ label: 'Website Redesign' },
]}
/>;| Prop | Type | Default | Description |
| --------------- | --------------------------------------------- | -------------- | -------------------------------------------------------------- |
| items | { key?; label: ReactNode; href?: string }[] | — | Required. Ordered trail. |
| separator | ReactNode | chevron icon | Rendered between each pair of items. |
| linkComponent | ElementType | 'a' | Swap in a router Link; receives href like a native anchor. |
| label | string | 'Breadcrumb' | Accessible name for the <nav>. |
Compound parts: Breadcrumbs.Root, .List, .Item, .Link, .Page (the current, non-navigable segment), .Separator (a real <li aria-hidden>, not CSS ::before, per the WAI-ARIA breadcrumb pattern).
<Breadcrumbs.Root>
<Breadcrumbs.List>
<Breadcrumbs.Item>
<Breadcrumbs.Link href="/">
<ListIcon /> Acme Cloud
</Breadcrumbs.Link>
</Breadcrumbs.Item>
<Breadcrumbs.Separator />
<Breadcrumbs.Item>
<Breadcrumbs.Page>2023 Q4 Report</Breadcrumbs.Page>
</Breadcrumbs.Item>
</Breadcrumbs.List>
</Breadcrumbs.Root>Pagination
A page-number trail with Previous/Next controls and a sliding active-page indicator. Collapses the middle into an ellipsis once the range gets long.
import { Pagination } from '@vinyasa/navigation/pagination';
import { useState } from 'react';
function ResultsPagination() {
const [page, setPage] = useState(1);
return <Pagination page={page} totalPages={20} onPageChange={setPage} />;
}| Prop | Type | Default | Description |
| --------------- | -------------------------- | ------- | -------------------------------------------------------------------------------------------- |
| page | number | — | Required. Current page, 1-indexed. |
| totalPages | number | — | Required. |
| onPageChange | (page: number) => void | — | Required. |
| siblingCount | number | 1 | Pages visible on each side of the current page. |
| boundaryCount | number | 1 | Pages visible at the very start/end. |
| getHref | (page: number) => string | — | When given, every page (incl. Prev/Next) renders as a real link; onPageChange still fires. |
Compound parts: .Root, .List (owns the sliding indicator via a MutationObserver watching aria-current), .Item, .Link, .Previous/.Next (pre-labeled with chevron icons), .Ellipsis. The range-computation itself is also exported standalone as getPaginationRange(page, totalPages, siblingCount?, boundaryCount?), alongside the ELLIPSIS sentinel it returns.
<Pagination
page={page}
totalPages={20}
onPageChange={setPage}
getHref={(p) => `/results?page=${p}`}
/>Tabs
A themed wrapper around Radix's @radix-ui/react-tabs, adding a sliding active-tab indicator and a ContentGroup that stacks every panel in the same CSS grid cell (all force-mounted) so the container's height never jumps when switching tabs — it's set by the tallest panel, not whichever one happens to be active.
import { Tabs } from '@vinyasa/navigation/tabs';
<Tabs
defaultValue="account"
tabs={[
{ value: 'account', label: 'Account', content: 'Update your name, email, and photo.' },
{ value: 'billing', label: 'Billing', content: 'Manage your plan and invoices.' },
]}
/>;TabsProps extends Radix's own Tabs.Root props (value/defaultValue/onValueChange/orientation, fully controlled or uncontrolled) plus:
| Prop | Type | Default | Description |
| ------ | ------------------------------------------------------------------------------- | ------- | ------------------------------------------- |
| tabs | { value: string; label: ReactNode; content: ReactNode; disabled?: boolean }[] | — | Required. Flat value/label/content triples. |
For uneven content lengths, use the compound API with .ContentGroup so height stays stable across a switch:
<Tabs.Root defaultValue="overview">
<Tabs.List>
<Tabs.Trigger value="overview">Overview</Tabs.Trigger>
<Tabs.Trigger value="logs">Build logs</Tabs.Trigger>
</Tabs.List>
<Tabs.ContentGroup>
<Tabs.Content value="overview">...</Tabs.Content>
<Tabs.Content value="logs">...</Tabs.Content>
</Tabs.ContentGroup>
</Tabs.Root>NavigationMenu
A themed wrapper around Radix's @radix-ui/react-navigation-menu for a top-level nav bar whose items are either plain links or dropdown panels listing a grid of label/href/description links.
import { NavigationMenu } from '@vinyasa/navigation/navigation-menu';
<NavigationMenu
items={[
{
key: 'products',
label: 'Products',
links: [
{
key: 'analytics',
label: 'Analytics',
href: '/analytics',
description: 'Track usage and trends',
},
{
key: 'monitoring',
label: 'Monitoring',
href: '/monitoring',
description: 'Uptime and error alerts',
},
],
},
{ key: 'pricing', label: 'Pricing', href: '/pricing' },
]}
/>;| Prop | Type | Default | Description |
| --------------- | ------------------------------------------------------------------------------------------ | ------- | --------------------------------------------------------------------- |
| items | { key; label; href?; active?; links?: { key; label; href?; description?; active? }[] }[] | — | Required. Plain link when href set; dropdown grid when links set. |
| linkComponent | ElementType | 'a' | Applies to every rendered link, top-level and inside dropdowns. |
value/defaultValue/onValueChange (which item's dropdown is open) pass straight through to Radix's Root. Compound parts mirror Radix 1:1: .Root, .List, .Item, .Trigger, .Link, .Content, .Indicator, .Viewport — reach for these for a custom card layout inside a dropdown instead of the plain link grid.
Sidebar
A collapsible, sectioned side-nav (<nav>) with a header/footer and labeled sections of items. Collapse state is controlled or uncontrolled, shared with descendants via context.
import { Sidebar } from '@vinyasa/navigation/sidebar';
<Sidebar
header="Acme Cloud"
footer="Jamie Reyes"
sections={[
{
key: 'main',
label: 'Main',
items: [
{ key: 'dashboard', label: 'Dashboard', icon: <ListIcon />, active: true },
{ key: 'deployments', label: 'Deployments', icon: <CalendarIcon /> },
],
},
]}
/>;| Prop | Type | Default | Description |
| ------------------- | --------------------------------------------- | ------------ | --------------------------------------------------------- |
| sections | { key; label?; items: SidebarItemSpec[] }[] | — | Required. |
| header / footer | ReactNode | — | Single-content header/footer slot. |
| collapsible | boolean | true | Renders the built-in collapse toggle in the header. |
| collapsed | boolean | uncontrolled | Controlled collapse state; pair with onCollapsedChange. |
| defaultCollapsed | boolean | false | Initial state when uncontrolled. |
| linkComponent | ElementType | 'a' | Used for items with href. |
SidebarItemSpec: { key; label; icon?; active?; disabled?; href?; onClick? }. When collapsed, a plain-text item label falls back to a native title attribute so meaning isn't lost on hover.
Compound parts: .Root (owns the collapse context), .Header, .Content, .Section, .Item, .Footer, .CollapseTrigger. The standalone useSidebarCollapsed() hook lets custom header/footer content elsewhere react to collapse state without prop drilling.
<Sidebar.Root>
<Sidebar.Header>
<strong>Acme Cloud</strong>
<Sidebar.CollapseTrigger />
</Sidebar.Header>
<Sidebar.Content>
<Sidebar.Section label="Main">
<Sidebar.Item as="a" href="/dashboard" icon={<ListIcon />} active>
Dashboard
</Sidebar.Item>
</Sidebar.Section>
</Sidebar.Content>
</Sidebar.Root>TopNavigation
A responsive top nav bar — brand mark, a flat row of items, an optional actions slot — with a below-md hamburger menu wired up for free.
import { TopNavigation } from '@vinyasa/navigation/top-navigation';
<TopNavigation
brand="Acme Cloud"
brandHref="/"
items={[
{ key: 'dashboard', label: 'Dashboard', active: true },
{ key: 'deployments', label: 'Deployments' },
]}
actions={
<>
<SearchIcon />
<UserIcon />
</>
}
/>;| Prop | Type | Default | Description |
| --------------------- | ------------------------- | ------------ | ------------------------------------------------------ |
| items | TopNavigationItemSpec[] | — | Required. |
| brand / brandHref | ReactNode / string | — | Brand mark, rendered as a link when brandHref given. |
| actions | ReactNode | — | Slot for search/avatar/buttons. |
| linkComponent | ElementType | 'a' | Used for the brand and any href item. |
| open | boolean | uncontrolled | Controlled mobile-menu open state below md. |
The below-md breakpoint is a real @media query against the browser viewport — resize the window to see it, it can't be simulated with a fixed-width wrapper. Compound parts: .Root, .Brand, .Content (doubles as the mobile dropdown panel), .Item, .Actions, .MobileTrigger.
Stepper
A horizontal or vertical numbered step sequence (onboarding wizard, checkout flow). Each step's state (upcoming/current/completed/error) is computed from a single activeStep index.
import { Stepper } from '@vinyasa/navigation/stepper';
<Stepper
activeStep={1}
steps={[
{ label: 'Account', description: 'Create your login' },
{ label: 'Profile', description: 'Tell us about yourself' },
{ label: 'Confirm', description: 'Review and finish' },
]}
/>;| Prop | Type | Default | Description |
| ------------- | ----------------------------------------- | -------------- | ------------------------------------------------------------------- |
| steps | { key?; label; description?; error? }[] | — | Required. error: true overrides the computed state for that step. |
| activeStep | number | — | Required. 0-indexed. |
| onStepClick | (index: number) => void | — | When given, every indicator becomes a clickable button. |
| orientation | 'horizontal' \| 'vertical' | 'horizontal' | |
Completed/error indicators swap in check/alert-triangle icons over the step number. Compound parts: .Root, .Step, .Indicator (bundles the circle with its trailing connector line), .Content, .Label, .Description.
<Stepper
steps={onboardingSteps}
activeStep={activeStep}
onStepClick={(index) => {
if (index < activeStep) setActiveStep(index);
}}
/>WorkspaceSwitcher
A workspace/team-switching dropdown trigger (avatar + name + chevron), built on @vinyasa/overlay's DropdownMenu — the one component in this package whose API surface directly composes another @vinyasa/* package.
import { WorkspaceSwitcher } from '@vinyasa/navigation/workspace-switcher';
import { useState } from 'react';
function WorkspacePicker() {
const [activeWorkspaceId, setActiveWorkspaceId] = useState('acme');
return (
<WorkspaceSwitcher
workspaces={[
{ id: 'acme', name: 'Acme Cloud', description: 'Pro plan · 12 members' },
{ id: 'globex', name: 'Globex Corp', description: 'Free plan · 3 members' },
]}
activeWorkspaceId={activeWorkspaceId}
onWorkspaceChange={setActiveWorkspaceId}
onCreateWorkspace={() => {
/* open create-workspace flow */
}}
/>
);
}| Prop | Type | Default | Description |
| ---------------------- | ---------------------- | -------------------- | -------------------------------------------------------------------------- |
| workspaces | WorkspaceSpec[] | — | Required. { id; name; description?; avatar? }. Renders nothing if empty. |
| activeWorkspaceId | string | — | Required. Falls back to workspaces[0] if it matches nothing. |
| onWorkspaceChange | (id: string) => void | — | Required. |
| onCreateWorkspace | () => void | — | Renders a "Create workspace" item below a separator when given. |
| createWorkspaceLabel | ReactNode | 'Create workspace' | |
Compound building blocks: .Trigger, .Item, .Avatar (renders workspace.avatar or falls back to name initials). The package also exports avatarImage, a vanilla-extract class string for a custom <img className={avatarImage}> avatar.
TreeView
An accessible, keyboard-navigable hierarchical tree (role="tree") — a file explorer or nested nav — with roving tabindex and controlled or uncontrolled expansion/selection.
import { TreeView, type TreeViewNodeSpec } from '@vinyasa/navigation/tree-view';
import { useState } from 'react';
const fileTree: TreeViewNodeSpec[] = [
{
id: 'src',
label: 'src',
children: [
{ id: 'components', label: 'components', children: [{ id: 'button', label: 'Button.tsx' }] },
{ id: 'index', label: 'index.ts' },
],
},
{ id: 'package', label: 'package.json' },
];
function FileExplorer() {
const [selectedId, setSelectedId] = useState<string>();
return <TreeView items={fileTree} selectedId={selectedId} onSelect={setSelectedId} />;
}| Prop | Type | Default | Description |
| -------------------- | ---------------------- | ------------ | ------------------------------------------------------------------ |
| items | TreeViewNodeSpec[] | — | Required. Recursive: { id; label; icon?; disabled?; children? }. |
| selectedId | string | — | |
| onSelect | (id: string) => void | — | Fires on click and Enter/Space. |
| expandedIds | string[] | uncontrolled | Controlled expansion. |
| defaultExpandedIds | string[] | [] | Initial expansion when uncontrolled. |
Full WAI-ARIA tree keyboard support: ArrowUp/ArrowDown move focus, ArrowRight expands or moves into the first child, ArrowLeft collapses or moves to the parent, Home/End jump to the first/last visible item, Enter/Space select. Collapsed nodes' children aren't rendered at all — not CSS-hidden. Compound parts: .Root, .Group (nested <ul role="group">), .Item.
CommandNavigation
An opinionated, batteries-included ⌘K/Ctrl+K launcher for jumping to pages — wraps @vinyasa/overlay's CommandPalette and adds an href-aware item shape plus its own global shortcut listener.
import { CommandNavigation } from '@vinyasa/navigation/command-navigation';
import { useState } from 'react';
function AppHeader() {
const [open, setOpen] = useState(false);
return (
<>
<CommandNavigation.Trigger onClick={() => setOpen(true)} />
<CommandNavigation
open={open}
onOpenChange={setOpen}
groups={[
{
key: 'pages',
heading: 'Pages',
items: [
{ key: 'dashboard', label: 'Dashboard', href: '/dashboard', icon: <ListIcon /> },
{ key: 'settings', label: 'Settings', href: '/settings', icon: <SettingsIcon /> },
],
},
{
key: 'actions',
heading: 'Actions',
items: [
{
key: 'new-project',
label: 'Create new project',
icon: <PlusIcon />,
shortcut: '⌘N',
onSelect: () => {},
},
],
},
]}
onNavigate={(href) => router.push(href)}
/>
</>
);
}| Prop | Type | Default | Description |
| ----------------- | ------------------------------ | ------------------------ | ------------------------------------------------------------------ |
| groups | CommandNavigationGroupSpec[] | — | Required. { key; heading?; items: CommandNavigationItemSpec[] }. |
| onNavigate | (href: string) => void | window.location.assign | Wire to your router's navigate function. |
| disableShortcut | boolean | false | Disables the built-in ⌘K/Ctrl+K listener. |
| open | boolean | uncontrolled | Controlled open state. |
CommandNavigationItemSpec: { key; label; href?; onSelect?; disabled?; icon?; shortcut?; keywords? } — onSelect (a non-navigation action) takes priority over href when both are given. .Trigger defaults its visible shortcut label to a platform-neutral "⌘K" rather than sniffing navigator.platform, avoiding an SSR/client mismatch — pass disableShortcut and drive open entirely from your own trigger if you need different behavior.
Subpath imports
Every component is imported by its own subpath (e.g. @vinyasa/navigation/sidebar) — there is no root @vinyasa/navigation entry, so import { X } from '@vinyasa/navigation' fails to resolve. See "This package has no root export" above for why.
Development
From the repository root:
pnpm --filter @vinyasa/navigation build
pnpm --filter @vinyasa/navigation test
pnpm --filter @vinyasa/navigation lint
pnpm storybook # Navigation/<ComponentName>