<script setup>
import { Sidebar } from "@dicehub/kappa/components/sidebar";
import { Folder, House } from "@lucide/vue";
</script>
<template>
<Sidebar.Provider style="height: 24rem">
<Sidebar.Root label="Workspace navigation">
<Sidebar.Header>Workspace <Sidebar.Close /></Sidebar.Header>
<Sidebar.Content aria-label="Workspace links">
<Sidebar.Menu>
<Sidebar.MenuItem>
<Sidebar.MenuButton href="/overview" :icon="House" tooltip="Overview" active>
Overview
</Sidebar.MenuButton>
</Sidebar.MenuItem>
<Sidebar.MenuItem>
<Sidebar.MenuButton href="/projects" :icon="Folder" tooltip="Projects">
Projects
</Sidebar.MenuButton>
</Sidebar.MenuItem>
</Sidebar.Menu>
</Sidebar.Content>
</Sidebar.Root>
<main style="flex: 1; min-width: 0">
<Sidebar.Trigger />
<!-- Application content -->
</main>
</Sidebar.Provider>
</template>Installation
Sidebar is part of Kappa. It adds no dependencies. Icons in these examples come from the documentation site's existing icon package; the library accepts any Vue icon component or icon slot.
Barrel
import { Sidebar } from "@dicehub/kappa";Granular
import { Sidebar } from "@dicehub/kappa/components/sidebar";Usage
For complete application layouts, see Blocks → Sidebar: workspace, icon rail, inset, floating, two-column navigation, and top-header examples.
Put one Root and the application content inside a Provider. Give the layout a height, then let Content scroll between Header and Footer. Keep one Trigger in the application header so the mobile drawer and offcanvas Sidebar can be reopened. A second footer toggle is not needed. These demos align both desktop headers at 4rem. Include Close in the navigation header for mobile.
Use MenuItem around every top-level entry and MenuSubItem around nested entries. These explicit list items keep navigation semantics predictable when you compose a Collapsible.
Use icons for every top-level item when collapsible="icon". Labels remain accessible when visually hidden; tooltip adds a visible label on hover or focus. Custom header and footer content must also fit the rail. Wrap text in MenuLabel, or use Context to change the composition.
Composition
Sidebar.Provider
├── Sidebar.Root <nav> / Ark Drawer on mobile
│ ├── Sidebar.Header
│ │ ├── Dropdown + MenuButton (namespace, optional)
│ │ └── Sidebar.Close (mobile)
│ ├── Sidebar.Content
│ │ └── Sidebar.Group
│ │ ├── Sidebar.GroupLabel
│ │ └── Sidebar.Menu <ul>
│ │ └── Sidebar.MenuItem <li>
│ │ ├── Sidebar.MenuButton
│ │ └── Sidebar.Collapsible (optional)
│ │ ├── Sidebar.CollapsibleTrigger
│ │ └── Sidebar.CollapsibleContent
│ │ └── Sidebar.MenuSub <ul>
│ ├── Sidebar.Loading (alternative to Content)
│ ├── Sidebar.SlidingViews (optional)
│ │ └── Sidebar.SlidingView → Sidebar.Content
│ ├── Sidebar.Footer
│ │ ├── Dropdown + MenuButton (profile, optional)
│ │ └── Sidebar.Trigger
│ └── Sidebar.ResizeHandle (optional, desktop)
└── application content
└── Sidebar.TriggerSidebar and Sidebar.Root are the same navigation component. Provider supplies the state and flex layout; it does not provide an application header, router, or store.
Kappa owns desktop state, navigation markup, dimensions, and semantic-token styling. Ark owns mobile modality, scroll locking, focus trapping, Escape/outside dismissal, and section accessibility. Mobile panels teleport to body. Global Kappa theme tokens must reach the teleport target.
Resizing adapts Ark Splitter's two-panel size model to a pixel-width Sidebar and normal flex content. No extra application wrapper is required. The separator controls the navigation landmark. Ark's collapse threshold and keyboard behavior are preserved.
The resize scope uses physical left/right panel order, including RTL. This avoids an installed Ark Splitter inconsistency between RTL pointer and keyboard direction without replacing its interaction code. Enter targets the navigation panel through Ark's collapse/expand API when that panel follows the content. Navigation retains the application locale.
Namespace, profile, and Quick search examples compose Dropdown and CommandPalette. They are not application-specific Sidebar parts. The scroll-to-item example uses native scrolling scoped to Content; it does not add a Sidebar scrolling API or move the outer page.
Inside the mobile drawer, keep Dropdown.Content in the drawer with :teleport="false" and fixed positioning. This keeps popup focus inside the modal boundary. Desktop popups can teleport to body.
Nested Sections
<Sidebar.Menu>
<Sidebar.MenuItem>
<Sidebar.Collapsible default-open>
<Sidebar.CollapsibleTrigger as-child>
<Sidebar.MenuButton :icon="Box" tooltip="Mesh">
Mesh <Sidebar.MenuChevron />
</Sidebar.MenuButton>
</Sidebar.CollapsibleTrigger>
<Sidebar.CollapsibleContent>
<Sidebar.MenuSub>
<Sidebar.MenuSubItem>
<Sidebar.MenuButton href="/mesh/geometry">Geometry</Sidebar.MenuButton>
</Sidebar.MenuSubItem>
</Sidebar.MenuSub>
</Sidebar.CollapsibleContent>
</Sidebar.Collapsible>
</Sidebar.MenuItem>
</Sidebar.Menu>Icon collapse hides nested content without resetting each section's open preference. Activating a collapsed section requests expansion of the Sidebar and section. Controlled parents must accept both updates when both levels are controlled.
Examples
Namespace Selector
A side-opening menu with framed icons, namespace shortcuts, and an Add namespace action. On mobile, the menu opens below the button inside the drawer. Ctrl/⌘ + 1–3 works only while this menu is open. Add namespace shows a placeholder page in this example; connect the action to your application.
<script setup>
import { onMounted, ref } from "vue";
import { Sidebar, Dropdown } from "@dicehub/kappa";
import { Building2, FlaskConical, Plus, UserRound } from "@lucide/vue";
const emit = defineEmits(["add"]);
const namespace = ref("Engineering");
const open = ref(false);
const isMac = ref(false);
const namespaces = [
{ name: "Engineering", icon: Building2 },
{ name: "Research", icon: FlaskConical },
{ name: "Personal", icon: UserRound },
];
const desktop = { placement: "right-start", strategy: "fixed", gutter: 8 };
const mobile = { placement: "bottom-start", strategy: "fixed", gutter: 6 };
onMounted(() => { isMac.value = /Mac|iPhone|iPad/.test(navigator.platform); });
function onShortcut(event) {
if (!open.value || !(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey || event.isComposing || event.repeat) return;
const item = namespaces[Number(event.key) - 1];
if (!item) return;
event.preventDefault();
event.stopPropagation();
namespace.value = item.name;
open.value = false;
}
</script>
<template>
<!-- Inside Sidebar.Root -->
<Sidebar.Header>
<Sidebar.Context v-slot="{ isMobile, setMobileOpen }">
<Dropdown.Root v-model:open="open" aria-label="Namespaces" :positioning="isMobile ? mobile : desktop">
<Dropdown.Trigger as-child>
<Sidebar.MenuButton :icon="Building2" :tooltip="namespace">{{ namespace }}</Sidebar.MenuButton>
</Dropdown.Trigger>
<Dropdown.Content :teleport="!isMobile" :inert="!open || undefined"
class="sidebar-demo__namespace-menu" @keydown="onShortcut">
<Dropdown.Group>
<Dropdown.Label>Namespaces</Dropdown.Label>
<Dropdown.Item v-for="(item, index) in namespaces" :key="item.name" :value="item.name" :value-text="item.name"
:aria-current="namespace === item.name ? 'true' : undefined"
:aria-keyshortcuts="(isMac ? 'Meta+' : 'Control+') + (index + 1)" @select="namespace = item.name">
<template #icon><span class="sidebar-demo__namespace-icon" aria-hidden="true"><component :is="item.icon" /></span></template>
{{ item.name }}
<template #end><Dropdown.Shortcut aria-hidden="true">{{ isMac ? '⌘' : 'Ctrl ' }}{{ index + 1 }}</Dropdown.Shortcut></template>
</Dropdown.Item>
</Dropdown.Group>
<Dropdown.Separator />
<Dropdown.Group>
<Dropdown.Item value="add-namespace" class="sidebar-demo__namespace-add" @select="emit('add'); setMobileOpen(false)">
<template #icon><span class="sidebar-demo__namespace-icon" aria-hidden="true"><Plus /></span></template>
Add namespace
</Dropdown.Item>
</Dropdown.Group>
</Dropdown.Content>
</Dropdown.Root>
</Sidebar.Context>
<Sidebar.Close />
</Sidebar.Header>
</template>
<style>
/* Shared geometry for the namespace composition and its copyable example. */
/* Part classes keep these overrides independent of the CSS bundle order. */
.sidebar-demo__namespace-menu.kappa-dropdown__content {
--kappa-dropdown-min-inline-size: 14rem;
inline-size: var(--reference-width, 14rem);
padding: 0.25rem;
}
.sidebar-demo__namespace-menu .kappa-dropdown__label {
padding: 0.25rem 0.375rem;
font-size: 0.75rem;
font-weight: 500;
letter-spacing: normal;
text-transform: none;
}
.sidebar-demo__namespace-menu .kappa-dropdown__item { padding: 0.5rem; }
.sidebar-demo__namespace-icon {
display: grid;
flex: none;
place-items: center;
box-sizing: border-box;
inline-size: 1.5rem;
block-size: 1.5rem;
border: 1px solid var(--kappa-line);
border-radius: 0.375rem;
}
.sidebar-demo__namespace-icon svg { inline-size: 0.875rem; block-size: 0.875rem; }
.sidebar-demo__namespace-menu .kappa-dropdown__shortcut { font-size: 0.75rem; }
.sidebar-demo__namespace-menu .sidebar-demo__namespace-add { color: var(--kappa-subtle); font-weight: 500; }
.sidebar-demo__namespace-add .sidebar-demo__namespace-icon svg { inline-size: 1rem; block-size: 1rem; }
@media (max-width: 767px) {
.sidebar-demo__namespace-menu .kappa-dropdown__item { min-block-size: 2.75rem; }
}
</style>Profile Selector
A footer menu with initials, name, email, profile switching, and account actions. All data is synthetic. Actions update this example only.
<Sidebar.Footer>
<Sidebar.Context v-slot="{ isMobile }">
<Dropdown.Root aria-label="Profile" :positioning="{ placement: isMobile ? 'top-start' : 'right-end', strategy: 'fixed' }">
<Dropdown.Trigger as-child>
<Sidebar.MenuButton :icon="UserRound" :tooltip="profile">{{ profile }}</Sidebar.MenuButton>
</Dropdown.Trigger>
<Dropdown.Content :teleport="!isMobile">
<Dropdown.RadioGroup v-model="profile">
<Dropdown.Label>Switch profile</Dropdown.Label>
<Dropdown.RadioItem value="Casey Rivera" close-on-select>Casey Rivera</Dropdown.RadioItem>
<Dropdown.RadioItem value="Jordan Lee" close-on-select>Jordan Lee</Dropdown.RadioItem>
</Dropdown.RadioGroup>
<Dropdown.Separator />
<Dropdown.Item value="account" @select="openAccount">Account</Dropdown.Item>
</Dropdown.Content>
</Dropdown.Root>
</Sidebar.Context>
</Sidebar.Footer>Quick Search
Quick search opens a real CommandPalette. Type a page name, use the arrow keys, then press Enter to navigate. Escape returns focus to the search button. No global shortcut is registered.
<script setup>
import { ref } from "vue";
import { Sidebar, CommandPalette } from "@dicehub/kappa";
import { Search } from "@lucide/vue";
const open = ref(false);
const selected = ref("Overview");
const items = ["Overview", "Projects", "Geometry", "Refinement", "Settings"];
</script>
<template>
<!-- Put the trigger in Sidebar.Content. -->
<Sidebar.Menu><Sidebar.MenuItem>
<Sidebar.MenuButton :icon="Search" tooltip="Quick search" @click="open = true">Quick search …</Sidebar.MenuButton>
</Sidebar.MenuItem></Sidebar.Menu>
<CommandPalette.Root v-model:open="open" :items="items" aria-label="Search navigation"
@select="item => { selected = String(item); open = false; }">
<CommandPalette.Input placeholder="Search navigation…" />
<CommandPalette.List>
<CommandPalette.Results v-slot="{ item }"><CommandPalette.Item :value="item">{{ item }}</CommandPalette.Item></CommandPalette.Results>
<CommandPalette.Empty>No pages found.</CommandPalette.Empty>
</CommandPalette.List>
</CommandPalette.Root>
</template>Resizable
Drag the separator to change the width. Drag toward the rail to collapse; drag outward to expand. Arrow keys resize, Shift increases the step, Home/End move to the edge limits, and Enter toggles collapse. Ark owns the drag threshold between the minimum width and the rail. The handle is desktop-only.
<Sidebar.Provider resizable :default-width="240" :min-width="180" :max-width="400">
<Sidebar.Root label="Project navigation">
<Sidebar.Header>Workspace <Sidebar.Close /></Sidebar.Header>
<Sidebar.Content aria-label="Project links">…</Sidebar.Content>
<Sidebar.ResizeHandle />
</Sidebar.Root>
<main style="flex: 1; min-width: 0"><Sidebar.Trigger />…</main>
</Sidebar.Provider>Controlled Width
Keep the expanded width in application state. Lock width to reject resize requests. Collapse and mobile state remain separate.
<Sidebar.Provider resizable v-model:resize-width="width" :min-width="180" :max-width="400">
<!-- width is a ref containing pixels, for example ref(240). -->
<Sidebar.Root><Sidebar.Content>…</Sidebar.Content><Sidebar.ResizeHandle /></Sidebar.Root>
<main style="flex: 1; min-width: 0"><Sidebar.Trigger />…</main>
</Sidebar.Provider>Peeking
Hover or focus the collapsed rail to peek. The live state distinguishes a temporary peek from a pinned sidebar. Pin it open, then collapse it to try again. Namespace and profile menus keep the peek visible. Escape or leaving both the Sidebar and its popup closes it without moving the page.
<Sidebar.Provider peekable :default-open="false">
<Sidebar.Root label="Project navigation">
<Sidebar.Content aria-label="Project links">…</Sidebar.Content>
</Sidebar.Root>
<main style="flex: 1; min-width: 0">
<Sidebar.Trigger />
<Sidebar.Context v-slot="{ open, isPeeking, toggle }">
<output>{{ isPeeking ? 'Peeking — temporary' : open ? 'Expanded — pinned' : 'Collapsed — ready to peek' }}</output>
<Button @click="toggle">{{ open ? 'Collapse to try peeking' : 'Pin sidebar open' }}</Button>
</Sidebar.Context>
</main>
</Sidebar.Provider>Scroll to Item
Jump to an item in a long navigation list without moving the documentation page or keyboard focus. Keep Settings visible does nothing when that row is already fully visible. This composition uses native Content scrolling; reduced motion disables smooth scrolling.
<script setup lang="ts">
import { ref } from "vue";
import { Sidebar, Button } from "@dicehub/kappa";
import { FileText, House, Settings } from "@lucide/vue";
const root = ref<HTMLElement>();
const selected = ref("Overview");
const items = ["Overview", ...Array.from({ length: 20 }, (_, index) => `Report ${index + 1}`), "Settings"];
// Scroll only Sidebar.Content. Native scrollIntoView can also move the outer page.
function scrollToItem(label: string, onlyIfNeeded = false) {
const content = root.value?.querySelector<HTMLElement>('[data-slot="sidebar-content"]');
const item = content?.querySelector<HTMLElement>(`[data-scroll-item="${CSS.escape(label)}"]`);
if (!content || !item) return;
selected.value = label;
const box = content.getBoundingClientRect();
const target = item.getBoundingClientRect();
const top = target.top - box.top - content.clientTop + content.scrollTop;
if (onlyIfNeeded && top >= content.scrollTop && top + target.height <= content.scrollTop + content.clientHeight) return;
content.scrollTo({
top: top - (content.clientHeight - target.height) / 2,
behavior: matchMedia("(prefers-reduced-motion: reduce)").matches ? "instant" : "smooth",
});
}
</script>
<template>
<div ref="root" class="sidebar-scroll-demo" data-sidebar-demo="scroll-to-item">
<Sidebar.Provider :mobile-breakpoint="0" collapsible="none" width="100%" class="sidebar-scroll-demo__layout">
<Sidebar.Root label="scroll-to-item navigation">
<Sidebar.Header><strong>Workspace</strong></Sidebar.Header>
<Sidebar.Content aria-label="Report navigation">
<Sidebar.Group><Sidebar.GroupLabel>Reports</Sidebar.GroupLabel>
<Sidebar.Menu><Sidebar.MenuItem v-for="item in items" :key="item">
<Sidebar.MenuButton :data-scroll-item="item" :active="selected === item"
:icon="item === 'Overview' ? House : item === 'Settings' ? Settings : FileText" @click="selected = item">{{ item }}</Sidebar.MenuButton>
</Sidebar.MenuItem></Sidebar.Menu>
</Sidebar.Group>
</Sidebar.Content>
</Sidebar.Root>
<div class="sidebar-scroll-demo__actions">
<strong>Scroll to item</strong>
<p>Jump to a navigation item. Only the list scrolls; keyboard focus stays on the button.</p>
<div class="sidebar-scroll-demo__buttons">
<Button v-for="item in ['Overview', 'Report 12', 'Settings']" :key="item" size="sm" variant="secondary" @click="scrollToItem(item)">Scroll to {{ item }}</Button>
<Button size="sm" variant="ghost" @click="scrollToItem('Settings', true)">Keep Settings visible</Button>
</div>
<output role="status">Selected: {{ selected }}</output>
</div>
</Sidebar.Provider>
</div>
</template>
<style>
.sidebar-scroll-demo { inline-size: 100%; min-inline-size: 0; container-type: inline-size; text-align: start; }
.sidebar-scroll-demo__layout.kappa-sidebar-provider { display: grid; grid-template-columns: minmax(0, 14rem) minmax(0, 1fr); grid-template-rows: minmax(0, 1fr); block-size: 26rem; overflow: hidden; border: 1px solid var(--kappa-line); border-radius: 0.5rem; background: var(--kappa-control); }
.sidebar-scroll-demo__layout > .kappa-sidebar__shell { min-block-size: 0; }
.sidebar-scroll-demo__actions { display: flex; flex-direction: column; justify-content: center; align-items: flex-start; min-inline-size: 0; padding: 1.25rem; gap: 0.75rem; }
.sidebar-scroll-demo__actions strong { font-size: 0.875rem; font-weight: 550; }
.sidebar-scroll-demo__actions p { margin: 0 !important; font-size: 0.8125rem !important; line-height: 1.5; color: var(--kappa-subtle); }
.sidebar-scroll-demo__buttons { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.sidebar-scroll-demo__actions output { font-size: 0.75rem; color: var(--kappa-subtle); }
@container (width < 30rem) {
.sidebar-scroll-demo__layout.kappa-sidebar-provider { grid-template-columns: minmax(0, 1fr); grid-template-rows: 17rem auto; block-size: auto; }
.sidebar-scroll-demo__layout > .kappa-sidebar__shell { border-block-end: 1px solid var(--kappa-line); }
}
</style>Sliding Views
Use the navigation header or the page button to switch between workspace and project views. You can also open Rotor study from the list and go back. The active view is shown beside the navigation. Inactive views stay mounted, hidden, and inert. Motion respects reduced-motion settings.
<script setup>
import { ref } from "vue";
import { Sidebar } from "@dicehub/kappa";
import { ArrowLeft, ArrowLeftRight, Folder } from "@lucide/vue";
const surface = ref("workspace");
</script>
<template>
<!-- Inside Sidebar.Root -->
<Sidebar.Header>
<Sidebar.MenuButton :icon="ArrowLeftRight" tooltip="Switch navigation view"
@click="surface = surface === 'workspace' ? 'project' : 'workspace'">
{{ surface === 'workspace' ? 'Workspace view' : 'Rotor study view' }}
</Sidebar.MenuButton>
<Sidebar.Close />
</Sidebar.Header>
<Sidebar.SlidingViews :active-key="surface" :direction="surface === 'project' ? 'left' : 'right'">
<Sidebar.SlidingView value="workspace" label="Workspace view">
<Sidebar.Content aria-label="Workspace links">
<Sidebar.Menu><Sidebar.MenuItem>
<Sidebar.MenuButton :icon="Folder" tooltip="Rotor study" @click="surface = 'project'">Rotor study</Sidebar.MenuButton>
</Sidebar.MenuItem></Sidebar.Menu>
</Sidebar.Content>
</Sidebar.SlidingView>
<Sidebar.SlidingView value="project" label="Project view">
<Sidebar.Content aria-label="Project links">
<Sidebar.Menu><Sidebar.MenuItem>
<Sidebar.MenuButton :icon="ArrowLeft" tooltip="Back to workspace" @click="surface = 'workspace'">Back to workspace</Sidebar.MenuButton>
</Sidebar.MenuItem></Sidebar.Menu>
</Sidebar.Content>
</Sidebar.SlidingView>
</Sidebar.SlidingViews>
</template>Compact
28px desktop rows for dense navigation. Mobile rows keep 44px touch targets.
<Sidebar.Provider compact>
<!-- The same Sidebar parts -->
</Sidebar.Provider>Icon Collapse
Start with an icon rail. Tooltips appear on hover and keyboard focus. Activating a nested section expands the rail.
<Sidebar.Provider :default-open="false">
<!-- Give each top-level item an icon and tooltip. -->
</Sidebar.Provider>Offcanvas
Hide the entire desktop Sidebar. Keep a Trigger outside it so users can reopen it.
<Sidebar.Provider collapsible="offcanvas">
<Sidebar.Root label="Project navigation">…</Sidebar.Root>
<main><Sidebar.Trigger />…</main>
</Sidebar.Provider>Non-collapsible
Keep desktop navigation expanded. Small viewports still use the accessible mobile drawer.
<Sidebar.Provider collapsible="none">…</Sidebar.Provider>Controlled State
Control desktop and mobile state separately. Lock state to test a parent that rejects a desktop change request.
<script setup>
import { ref } from "vue";
import { Sidebar } from "@dicehub/kappa/components/sidebar";
const open = ref(true);
const mobileOpen = ref(false);
</script>
<template>
<Sidebar.Provider v-model:open="open" v-model:mobile-open="mobileOpen">
<!-- Sidebar and application content -->
</Sidebar.Provider>
</template>End Placement
Logical end placement follows the text direction. Drag the separator left to grow this Sidebar. Home grows it; End collapses it, following Ark panel order.
<Sidebar.Provider side="end" resizable :default-width="240">
<Sidebar.Root><Sidebar.Content>…</Sidebar.Content><Sidebar.ResizeHandle /></Sidebar.Root>
<main style="flex: 1; min-width: 0"><Sidebar.Trigger />…</main>
</Sidebar.Provider>Right-to-left
Use DirectionProvider for Ark behavior and dir for text and layout. Start becomes the right edge.
<DirectionProvider locale="ar">
<Sidebar.Provider dir="rtl" resizable :default-width="240">
<Sidebar.Root label="التنقل الرئيسي">…<Sidebar.ResizeHandle /></Sidebar.Root>
<main><Sidebar.Trigger />…</main>
</Sidebar.Provider>
</DirectionProvider>Mobile Drawer
Open the drawer inside this resizable viewport. It starts at the preview's left edge, not at the edge of the documentation page. Tab stays inside the drawer. Escape, Close, and the backdrop dismiss it. Open example tests the same composition in a full browser tab.
<Sidebar.Provider :mobile-breakpoint="10000">
<Sidebar.Root label="Project navigation">
<Sidebar.Header>Project <Sidebar.Close /></Sidebar.Header>
<!-- Navigation -->
</Sidebar.Root>
<main><Sidebar.Trigger />…</main>
</Sidebar.Provider>Full-screen Mobile
Resize the viewport, then open the full-screen navigation. The drawer fills only that viewport, so the documentation stays visible. Try namespace and profile menus, Quick search, and a nested page such as Refinement. Its breadcrumb remains visible after the drawer closes.
<Sidebar.Provider :mobile-breakpoint="10000">
<Sidebar.Root label="Project navigation" full-screen-on-mobile>
<Sidebar.Header>Project <Sidebar.Close /></Sidebar.Header>
<!-- Navigation, namespace and profile menus -->
</Sidebar.Root>
<main><Sidebar.Trigger /> Engineering / Overview</main>
</Sidebar.Provider>Long Navigation
Header and Footer stay visible while Content scrolls. Long labels do not change the width.
<Sidebar.Provider style="height: 24rem">
<Sidebar.Root label="Reports">
<Sidebar.Header>Reports</Sidebar.Header>
<Sidebar.Content aria-label="Report links">…</Sidebar.Content>
<Sidebar.Footer><Sidebar.MenuLabel>Casey Rivera</Sidebar.MenuLabel></Sidebar.Footer>
</Sidebar.Root>
<main><Sidebar.Trigger />…</main>
</Sidebar.Provider>Loading
Deterministic placeholder rows with an accessible loading status. Motion stops under reduced-motion preferences.
<Sidebar.Root label="Project navigation">
<Sidebar.Header>Project</Sidebar.Header>
<Sidebar.Loading v-if="loading" :rows="5" />
<Sidebar.Content v-else aria-label="Project links">…</Sidebar.Content>
</Sidebar.Root>Routing and State
Supply href for normal links, or compose a router link with as-child. The library does not read the URL or select routes. Set active from your route state. For custom children, put the icon and MenuLabel inside the link.
MenuButton does not close the mobile drawer automatically. Call setMobileOpen(false) after your router accepts the navigation. This keeps rejected and asynchronous navigation under application control.
<Sidebar.Context v-slot="{ setMobileOpen }">
<Sidebar.MenuButton href="/projects" @click="navigate">
Projects
</Sidebar.MenuButton>
<!-- Close only after the router accepts navigation: setMobileOpen(false). -->
</Sidebar.Context>
<!-- Use your router component through as-child. MenuLabel hides only visually in the rail. -->
<Sidebar.MenuButton as-child tooltip="Projects">
<RouterLink to="/projects">
<Folder aria-hidden="true" />
<Sidebar.MenuLabel>Projects</Sidebar.MenuLabel>
</RouterLink>
</Sidebar.MenuButton>Provider supports v-model:open and v-model:mobile-open. Uncontrolled defaults are read once. Leaving mobile mode requests that mobile state close; the desktop preference is unchanged. Controlled parents must handle update events. No cookies, storage, global keyboard shortcuts, or application state are added.
SSR renders the desktop structure, then checks the viewport after hydration. Navigation content remounts when switching between desktop and mobile. Keep persistent form state and controlled section state outside Root when they must survive that transition.
Accessibility
- Root is a named navigation landmark, not an ARIA menu. Use Tab and Shift+Tab to move between links; Enter activates links, and Enter or Space activates buttons and disclosures.
- Active entries use
aria-current="page". Disabled entries cannot navigate or activate and leave the tab order. - Mobile uses a named modal dialog. Focus stays inside while open. Escape, Close, and backdrop interaction dismiss it; focus returns to the opening Trigger.
- Use a visible Close in mobile headers. Do not rely only on the backdrop.
- Offcanvas content is hidden and inert. Icon labels remain in the accessibility tree; nested hidden links leave the tab order.
- ResizeHandle is a focusable vertical separator. Arrow keys, Shift+Arrow, Home, End, and Enter use Ark's resize behavior. End placement reverses which edge grows the Sidebar. The handle is not rendered on mobile.
- Peeking keeps the desktop layout width fixed. Escape dismisses it without moving the pointer. It also closes when both pointer and focus leave. Popups linked through aria-controls remain part of the interaction area; their first Escape closes the popup.
- Sliding views keep inactive content mounted, hidden, and inert. In-view navigation transfers focus; an external view change does not take focus from its external control.
- Content is a keyboard-focusable scrolling region. Use a descriptive aria-label if its default is not suitable.
- Pass translated Root, Trigger, Close, and Loading labels. Pair DirectionProvider with a DOM dir attribute for RTL.
- Motion respects reduced-motion preferences. Mobile navigation controls keep a 44px minimum height, including compact mode.
API Reference
Sidebar.Provider
| Prop | Type | Default | Description |
|---|---|---|---|
open / defaultOpen | boolean | undefined / true | Controlled or initial desktop expanded state. |
mobileOpen / defaultMobileOpen | boolean | undefined / false | Independent controlled or initial mobile drawer state. |
collapsible | "icon" | "offcanvas" | "none" | "icon" | Desktop collapse mode. none ignores desktop collapse requests. |
side | "start" | "end" | "start" | Logical edge. End placement orders the desktop Sidebar after the application content. |
compact | boolean | false | 28px instead of 32px minimum desktop rows. Mobile remains 44px. |
mobileBreakpoint | number | 768 | Viewport widths below this many pixels use a drawer. Zero disables mobile mode. |
width / collapsedWidth / mobileWidth | CSS length | "16.25rem" / "3.25rem" / "18rem" | Expanded desktop, icon rail, and mobile widths. Mobile width leaves at least 3rem for the backdrop. |
resizable | boolean | false | Enable desktop resizing through ResizeHandle. The numeric resize width replaces the fixed width prop. |
resizeWidth / defaultWidth | number (px) | undefined / 260 | Controlled or initial expanded resize width. Resize emits requests; a controlled parent can reject them. |
minWidth / maxWidth | number (px) | 180 / 400 | Expanded resize bounds. A collapsed icon rail can be smaller than minWidth. |
peekable | boolean | false | Hover or focus temporarily expands a collapsed icon rail. Not used on mobile or in offcanvas/none modes. |
id | string | Vue useId() | Stable ID prefix for navigation and dialog relationships. Set IDs here, not on Root. |
Parts
| Part | Props | Default | Description |
|---|---|---|---|
Root | label | "Main navigation" | Accessible navigation and mobile dialog name. Give multiple Sidebars distinct names. |
Root | fullScreenOnMobile | false | Mobile dialog covers the viewport. |
Trigger | expandLabel / collapseLabel / openLabel / closeLabel | English action labels | Optional localized labels for the built-in icon. Custom content must provide its own accessible name. |
Trigger | disabled / asChild | false | Disable the action or compose it with a custom button. |
Close | label / asChild | "Close sidebar" / false | Mobile-only close action. Hidden on desktop. |
MenuButton | href / asChild | undefined / false | A native link when href is set; otherwise a button. asChild accepts a router link or native element. |
MenuButton | active / disabled | false | Current-page styling and aria-current; disabled controls block activation and leave the tab order. |
MenuButton | icon / tooltip | undefined | Vue icon component (or #icon slot), and optional collapsed-rail tooltip. |
Collapsible | open / defaultOpen / disabled / id | undefined / false / false / generated | Ark disclosure behavior with controlled and uncontrolled state. Sections retain their open preference during icon collapse. |
CollapsibleTrigger / CollapsibleContent | asChild | false | Compose Ark parts with Sidebar.MenuButton or a custom element. |
Loading | rows / label | 5 / "Loading navigation" | 1–20 placeholder rows and a localized accessible status name. |
ResizeHandle | label / disabled | "Resize sidebar" / false | Focus-visible desktop separator. Ark handles pointer and keyboard resizing. In offcanvas mode, reopen with an external Trigger. |
SlidingViews | activeKey / direction | required / "left" | Controlled active view key and physical transition direction (left or right). Reverse direction for back navigation. |
SlidingView | value / label | required / value | Matching view key and accessible group name. Inactive views stay mounted but are hidden and inert. |
Structural parts | asChild | false | Header, Footer, Content, Group, GroupLabel, Menu, MenuItem, MenuSub, MenuSubItem, MenuLabel, MenuBadge, and Separator forward attributes to their semantic element. |
Events and Context
Provider emits update:open(boolean), openChange({ open }), update:mobileOpen(boolean), and mobileOpenChange({ open }). Collapsible emits update:open and openChange. Controlled components emit requests without changing the supplied value.
Resizing emits update:resizeWidth(number), resize({ width }), and resizeEnd({ width }). Widths are in pixels. The expanded width is retained during collapse and mobile mode. Use v-model:resize-width to control it.
Sidebar.Context exposes unwrapped open, mobileOpen, isMobile, state, iconCollapsed, side, compact, and collapsible, plus setOpen, setMobileOpen, and toggle. useSidebarContext() exposes the same values as Vue refs in a descendant setup function.
Context also exposes resizable, width, isResizing, setWidth, peekable, and isPeeking. State can be expanded, collapsed, or peeking. Peeking never emits a desktop open change.
Styling and Exports
Structural parts forward attributes, classes, styles, and listeners. Root and Provider use reserved generated IDs; supply the Provider id prop to customize their relationships. Root exposes data-state, data-side, data-collapsible, data-mobile, and data-compact. MenuButton exposes data-active and data-disabled; Ark parts retain their state attributes.
Colors inherit Kappa control, default, subtle, line, tint, overlay, and focus tokens. Dimensions use --kappa-sidebar-width, --kappa-sidebar-collapsed-width, and --kappa-sidebar-mobile-width; prefer the matching Provider props so teleported mobile content receives them too.
Every compound part has a named export, such as SidebarProvider, SidebarRoot, and SidebarMenuButton. Public props, slots, state, events, SidebarContextValue, and SIDEBAR_DEFAULTS are exported from the same subpath.