Sidebar

A responsive, collapsible navigation panel composed from flexible layout parts.
Overview
vue
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import {
  Avatar,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuRoot,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
  Sidebar,
  SidebarBody,
  SidebarFooter,
  SidebarGroup,
  SidebarGroupLabel,
  SidebarHeader,
  SidebarInset,
  SidebarMenu,
  SidebarMenuButton,
  SidebarMenuItem,
  SidebarProvider,
  SidebarRail,
  SidebarTrigger,
} from '@typlog/ui'

const links = [
  { label: 'Overview', icon: 'lucide:layout-dashboard' },
  { label: 'Posts', icon: 'lucide:file-text' },
  { label: 'Analytics', icon: 'lucide:chart-no-axes-column' },
  { label: 'Settings', icon: 'lucide:settings' },
]
</script>

<template>
  <SidebarProvider class="h-112 overflow-hidden rounded-lg border border-gray-5">
    <Sidebar v-slot="{ collapsed }" collapsible="icon" class="bg-gray-1">
      <SidebarHeader>
        <Icon icon="lucide:layers" />
        <div v-if="!collapsed">
          <strong>Typlog</strong>
        </div>
      </SidebarHeader>
      <SidebarBody>
        <SidebarGroup>
          <SidebarGroupLabel>Navigation</SidebarGroupLabel>
          <SidebarMenu>
            <SidebarMenuItem v-for="(link, index) in links" :key="link.label">
              <SidebarMenuButton
                as="a"
                href="#"
                :active="index === 0"
                :icon="link.icon"
                :text="link.label"
              />
            </SidebarMenuItem>
          </SidebarMenu>
        </SidebarGroup>
      </SidebarBody>
      <SidebarFooter>
        <SidebarMenu>
          <DropdownMenuRoot>
            <DropdownMenuTrigger as-child>
              <SidebarMenuButton
                aria-label="Open user menu"
                tooltip="Alex Lovelace"
                trailing-icon="lucide:chevrons-up-down"
              >
                <template #icon>
                  <Avatar
                    src="https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?&w=256&h=256&q=70&crop=focalpoint&fp-x=0.5&fp-y=0.3&fp-z=1&fit=crop"
                    alt="Alex"
                  />
                </template>
                <template #text>
                  <span class="block truncate text-sm font-medium text-gray-12">Alex Lovelace</span>
                  <span class="block truncate text-xs text-gray-10">alex@example.com</span>
                </template>
              </SidebarMenuButton>
            </DropdownMenuTrigger>
            <DropdownMenuContent side="top" align="start" :side-offset="8" class="w-48">
              <DropdownMenuItem>
                <Icon icon="lucide:user" />
                Profile
              </DropdownMenuItem>
              <DropdownMenuItem>
                <Icon icon="lucide:credit-card" />
                Billing
              </DropdownMenuItem>
              <DropdownMenuItem>
                <Icon icon="lucide:bell" />
                Notifications
              </DropdownMenuItem>
              <DropdownMenuSeparator />
              <DropdownMenuItem color="red">
                <Icon icon="lucide:log-out" />
                Log out
              </DropdownMenuItem>
            </DropdownMenuContent>
          </DropdownMenuRoot>
        </SidebarMenu>
      </SidebarFooter>
      <SidebarRail />
    </Sidebar>

    <SidebarInset>
      <header class="flex h-12 items-center gap-3 border-b border-gray-4 px-4">
        <SidebarTrigger />
        <strong>Overview</strong>
        <SidebarTrigger target="right" class="ml-auto" />
      </header>
      <div class="grid gap-4 p-4 sm:grid-cols-2">
        <div v-for="index in 4" :key="index" class="h-24 rounded-lg bg-gray-3"></div>
      </div>
    </SidebarInset>

    <Sidebar
      side="right"
      width="14rem"
      collapsible="offcanvas"
      mobile-title="Inspector"
      mobile-description="Edit page settings"
    >
      <SidebarHeader><strong>Inspector</strong></SidebarHeader>
      <SidebarBody class="flex flex-col gap-4 text-sm">
        <label class="flex flex-col gap-1">
          <span class="text-gray-11">Title</span>
          <input value="Overview" class="rounded-md border border-gray-6 bg-transparent px-2 py-1.5" />
        </label>
        <label class="flex flex-col gap-1">
          <span class="text-gray-11">Status</span>
          <select class="rounded-md border border-gray-6 bg-transparent px-2 py-1.5">
            <option>Published</option>
            <option>Draft</option>
          </select>
        </label>
      </SidebarBody>
      <SidebarRail />
    </Sidebar>
  </SidebarProvider>
</template>

SidebarProvider arranges an optional left Sidebar, central inset, and optional right Sidebar. It owns the responsive breakpoint and lets triggers target either edge. Each Sidebar keeps its own mobile open and desktop collapsed states, rendering inline at 1024px and above by default and as an accessible modal drawer/slideover on narrower viewports. Set breakpoint on the provider to change where the persistent desktop panel switches to the mobile drawer presentation. On touch devices, the mobile panel can be swiped toward its edge to dismiss it.

Use at most one Sidebar per side in a provider. Left and right state is independent on desktop. On mobile, opening one panel requests that the other close so modal sidebars do not stack.

SidebarGroup and the SidebarMenu* components are navigation structure inside a sidebar; they do not manage layout, routing, active state, or expansion. SidebarMenu is the configuration boundary for a menu tree: its size and color are inherited by menu and submenu buttons. For other content, compose ordinary semantic elements directly inside SidebarBody.

API Reference ​

SidebarProvider ​

PropDefaultType
as"div"
AsTagComponent

The element or component used for the layout container.

breakpoint1024
number

The viewport width in pixels at which the sidebar switches between mobile and desktop behavior.

PropDefaultType
collapsed
–
boolean

Controls whether the desktop sidebar is collapsed.

collapsedWidth"3.5rem"
string

The desktop width when collapsible="icon".

collapsible"offcanvas"
"icon""none""offcanvas"

The desktop collapse behavior.

defaultCollapsedfalse
boolean

Initial desktop collapsed state when collapsed is uncontrolled.

defaultOpenfalse
boolean

Initial mobile open state when open is uncontrolled.

mobileDescription"Site navigation"
string

Accessible description announced when the sidebar opens on mobile.

mobileTitle"Navigation"
string

Accessible title announced when the sidebar opens on mobile.

mobileWidth"18rem"
string

The sidebar width below the mobile breakpoint.

open
–
boolean

Controls whether the mobile sidebar is open.

side"left"
"left""right"

The edge of the layout that contains the sidebar.

variant"default"
"default""floating""inset"

The visual relationship between the sidebar and main content.

width"16rem"
string

The expanded desktop width.

SidebarTrigger ​

PropDefaultType
disabledfalse
boolean

Prevents the trigger from changing sidebar state.

label
–
string

Overrides the generated accessible label.

target"left"
"left""right"

The sidebar edge controlled by this trigger.

SidebarRail ​

PropDefaultType
disabledfalse
boolean

Prevents the rail from changing the collapsed state.

label
–
string

Overrides the generated accessible label.

Layout parts ​

SidebarHeader and SidebarFooter provide layout and spacing only. Add a border utility or Separator when a visual divider is needed.

The default Sidebar slot exposes collapsed as the current visual state. It follows desktop icon-collapse state and remains false in the complete mobile panel, so arbitrary header or body content can use v-if="!collapsed" without application-level breakpoint checks.

SidebarInset scrolls vertically when SidebarProvider has a constrained height, while each sidebar keeps its header and footer fixed and scrolls only SidebarBody. Set the provider height at the layout boundary—for example, h-dvh overflow-hidden for a full-height application shell.

PropDefaultType
color
–

Overrides the accent color inherited from the ThemeProvider.

size"2"
"1""2""3"

Controls the inherited size of menu and submenu buttons.

PropDefaultType
activefalse
boolean

Marks this item as the current selection.

disabledfalse
boolean

Prevents interaction with this item.

icon
–
string

Iconify icon rendered before the text.

text
–
string

Primary button text. Also supplies the collapsed tooltip by default.

tooltip
–
falsestring

Label shown when an icon-only collapsed item is hovered or focused. Set to false to disable the tooltip inferred from text.

trailingIcon
–
string

Iconify icon rendered in the trailing position.

PropDefaultType
activefalse
boolean

Marks this item as the current selection.

disabledfalse
boolean

Prevents interaction with this item.

icon
–
string

Iconify icon rendered before the text.

text
–
string

Primary button text.

trailingIcon
–
string

Iconify icon rendered in the trailing position.

Examples ​

Set size once on SidebarMenu to coordinate button height, spacing, typography, icons, and nested submenu dimensions. The default 2 uses a 2.25rem button baseline; 1 tightens the entire scale and 3 adds room across the same set of measurements.

Size is configured once for each menu tree.
vue
<script setup lang="ts">
import {
  Badge,
  Sidebar,
  SidebarBody,
  SidebarGroup,
  SidebarGroupLabel,
  SidebarInset,
  SidebarMenu,
  SidebarMenuButton,
  SidebarMenuItem,
  SidebarProvider,
} from '@typlog/ui'

const sizes = [
  { value: '1', label: 'Compact' },
  { value: '2', label: 'Default' },
  { value: '3', label: 'Spacious' },
] as const
</script>

<template>
  <SidebarProvider class="h-112 overflow-hidden rounded-lg border border-gray-5">
    <Sidebar width="15rem" collapsible="none">
      <SidebarBody>
        <SidebarGroup v-for="size in sizes" :key="size.value">
          <SidebarGroupLabel>{{ size.label }} · size {{ size.value }}</SidebarGroupLabel>
          <SidebarMenu :size="size.value">
            <SidebarMenuItem>
              <SidebarMenuButton active icon="lucide:inbox" text="Inbox">
                <template #trailing><Badge>12</Badge></template>
              </SidebarMenuButton>
            </SidebarMenuItem>
            <SidebarMenuItem>
              <SidebarMenuButton icon="lucide:file-text" text="Drafts" />
            </SidebarMenuItem>
          </SidebarMenu>
        </SidebarGroup>
      </SidebarBody>
    </Sidebar>
    <SidebarInset class="p-4 text-sm text-gray-11">
      Size is configured once for each menu tree.
    </SidebarInset>
  </SidebarProvider>
</template>

Set color on a menu tree to override the provider accent for active, active-hover, and keyboard-focus states. Normal text and disabled states continue to use semantic gray tokens.

Active and focus states inherit each menu's accent color.
vue
<script setup lang="ts">
import {
  Sidebar,
  SidebarBody,
  SidebarGroup,
  SidebarGroupLabel,
  SidebarInset,
  SidebarMenu,
  SidebarMenuButton,
  SidebarMenuItem,
  SidebarProvider,
} from '@typlog/ui'

const menus = [
  { label: 'Editorial', color: 'blue', icon: 'lucide:notebook-pen' },
  { label: 'Audience', color: 'ruby', icon: 'lucide:users' },
] as const
</script>

<template>
  <SidebarProvider class="h-80 overflow-hidden rounded-lg border border-gray-5">
    <Sidebar width="15rem" collapsible="none">
      <SidebarBody>
        <SidebarGroup v-for="menu in menus" :key="menu.color">
          <SidebarGroupLabel>{{ menu.label }}</SidebarGroupLabel>
          <SidebarMenu :color="menu.color">
            <SidebarMenuItem>
              <SidebarMenuButton
                active
                :icon="menu.icon"
                :text="`${menu.label} overview`"
              />
            </SidebarMenuItem>
            <SidebarMenuItem>
              <SidebarMenuButton icon="lucide:settings" text="Settings" />
            </SidebarMenuItem>
          </SidebarMenu>
        </SidebarGroup>
      </SidebarBody>
    </Sidebar>
    <SidebarInset class="p-4 text-sm text-gray-11">
      Active and focus states inherit each menu's accent color.
    </SidebarInset>
  </SidebarProvider>
</template>

Collapse modes ​

offcanvas collapses the default sidebar layout width. Floating and inset sidebars keep --sidebar-width, translate the layout wrapper beyond its edge, and release its layout space with an offset. icon reduces the panel to collapsedWidth; the default wrapper matches that width, while floating and inset wrappers retain their padding around it. none keeps the sidebar expanded.

Toggle
Toggle
Toggle
vue
<script setup lang="ts">
import {
  Sidebar,
  SidebarBody,
  SidebarProvider,
  SidebarHeader,
  SidebarInset,
  SidebarTrigger,
} from '@typlog/ui'

const modes = ['offcanvas', 'icon', 'none'] as const
</script>

<template>
  <div class="grid gap-4">
    <SidebarProvider
      v-for="mode in modes"
      :key="mode"
      class="h-64 overflow-hidden rounded-lg border border-gray-5"
    >
      <Sidebar v-slot="{ collapsed }" :collapsible="mode" width="10rem">
        <SidebarHeader>
          <span class="grid size-7 shrink-0 place-items-center rounded bg-accent-9 text-white">T</span>
          <strong v-if="!collapsed">{{ mode }}</strong>
        </SidebarHeader>
        <SidebarBody class="text-sm text-gray-11">
          <p v-if="!collapsed">The sidebar uses the {{ mode }} collapse mode.</p>
        </SidebarBody>
      </Sidebar>
      <SidebarInset>
        <div class="flex items-center gap-2 p-3">
          <SidebarTrigger />
          <span class="text-sm">Toggle</span>
        </div>
      </SidebarInset>
    </SidebarProvider>
  </div>
</template>

Right side ​

vue
<script setup lang="ts">
import {
  Sidebar,
  SidebarBody,
  SidebarProvider,
  SidebarHeader,
  SidebarInset,
  SidebarRail,
  SidebarTrigger,
} from '@typlog/ui'
</script>

<template>
  <SidebarProvider class="h-72 overflow-hidden rounded-lg border border-gray-5">
    <SidebarInset class="p-4">
      <SidebarTrigger target="right" />
    </SidebarInset>
    <Sidebar side="right" collapsible="offcanvas" width="13rem">
      <SidebarHeader>
        <strong>Inspector</strong>
      </SidebarHeader>
      <SidebarBody class="text-sm text-gray-11">
        <span>Properties appear on the right.</span>
      </SidebarBody>
      <SidebarRail />
    </Sidebar>
  </SidebarProvider>
</template>

Two sides ​

Place SidebarInset between two sidebars inside SidebarProvider and set each trigger's target to the edge it controls. Source order is normalized to left, inset, right.

Editor
Left and right panels keep independent state. On mobile, opening one closes the other.
vue
<script setup lang="ts">
import { ref } from 'vue'
import {
  Sidebar,
  SidebarBody,
  SidebarProvider,
  SidebarHeader,
  SidebarInset,
  SidebarRail,
  SidebarTrigger,
} from '@typlog/ui'

const leftOpen = ref(false)
const leftCollapsed = ref(false)
const rightOpen = ref(false)
const rightCollapsed = ref(false)
</script>

<template>
  <SidebarProvider class="h-80 overflow-hidden rounded-lg border border-gray-5">
    <Sidebar
      v-slot="{ collapsed }"
      v-model:open="leftOpen"
      v-model:collapsed="leftCollapsed"
      collapsible="icon"
      width="13rem"
      mobile-title="Navigation"
      mobile-description="Choose a section"
    >
      <SidebarHeader><strong v-if="!collapsed">Workspace</strong></SidebarHeader>
      <SidebarBody class="text-sm text-gray-11">
        <span v-if="!collapsed">Project navigation</span>
      </SidebarBody>
      <SidebarRail />
    </Sidebar>

    <SidebarInset>
      <header class="flex h-12 items-center gap-2 border-b border-gray-4 px-4">
        <SidebarTrigger target="left" />
        <strong>Editor</strong>
        <SidebarTrigger target="right" class="ml-auto" />
      </header>
      <div class="p-4 text-sm text-gray-11">
        Left and right panels keep independent state. On mobile, opening one closes the other.
      </div>
    </SidebarInset>

    <Sidebar
      v-model:open="rightOpen"
      v-model:collapsed="rightCollapsed"
      side="right"
      collapsible="offcanvas"
      width="13rem"
      mobile-title="Inspector"
      mobile-description="Edit document properties"
    >
      <SidebarHeader><strong>Inspector</strong></SidebarHeader>
      <SidebarBody class="flex flex-col gap-3 text-sm">
        <label class="flex flex-col gap-1">
          <span class="text-gray-11">Slug</span>
          <input value="welcome" class="rounded-md border border-gray-6 bg-transparent px-2 py-1.5" />
        </label>
        <label class="flex items-center gap-2">
          <input type="checkbox" checked /> Published
        </label>
      </SidebarBody>
      <SidebarRail />
    </Sidebar>
  </SidebarProvider>
</template>

Controlled state ​

Use v-model:open for a sidebar's mobile slideover and v-model:collapsed for its desktop panel. The two state dimensions remain independent when the viewport changes.

vue
<script setup lang="ts">
import { ref } from 'vue'
import {
  Button,
  Sidebar,
  SidebarBody,
  SidebarProvider,
  SidebarHeader,
  SidebarInset,
  SidebarTrigger,
} from '@typlog/ui'

const open = ref(false)
const collapsed = ref(false)
</script>

<template>
  <div class="flex flex-col gap-3">
    <div class="flex flex-wrap gap-2">
      <Button size="1" variant="soft" @click="open = !open">Mobile open: {{ open }}</Button>
      <Button size="1" variant="soft" @click="collapsed = !collapsed">Desktop collapsed: {{ collapsed }}</Button>
    </div>
    <SidebarProvider class="h-72 overflow-hidden rounded-lg border border-gray-5">
      <Sidebar
        v-model:open="open"
        v-model:collapsed="collapsed"
        width="13rem"
      >
        <SidebarHeader><strong>Controlled</strong></SidebarHeader>
        <SidebarBody class="text-sm text-gray-11">Both states are owned by the parent.</SidebarBody>
      </Sidebar>
      <SidebarInset class="p-4">
        <SidebarTrigger />
      </SidebarInset>
    </SidebarProvider>
  </div>
</template>

Mobile slideover ​

Resize the browser below 768px to exercise focus trapping, outside-click dismissal, and Escape-key dismissal. mobileTitle and mobileDescription provide the accessible dialog name and description. This example sets a custom breakpoint so the same provider API can match an application layout.

Below 768px this opens a modal slideover.

vue
<script setup lang="ts">
import {
  Sidebar,
  SidebarBody,
  SidebarProvider,
  SidebarHeader,
  SidebarInset,
  SidebarTrigger,
} from '@typlog/ui'
</script>

<template>
  <SidebarProvider :breakpoint="768" class="h-72 overflow-hidden rounded-lg border border-gray-5">
    <Sidebar mobile-title="Workspace navigation" mobile-description="Choose a workspace section">
      <SidebarHeader><strong>Workspace</strong></SidebarHeader>
      <SidebarBody>
        <nav class="flex flex-col gap-2" aria-label="Workspace navigation">
          <a href="#" class="rounded px-2 py-1.5 text-sm hover:bg-gray-3">Projects</a>
          <a href="#" class="rounded px-2 py-1.5 text-sm hover:bg-gray-3">Team</a>
          <a href="#" class="rounded px-2 py-1.5 text-sm hover:bg-gray-3">Settings</a>
        </nav>
      </SidebarBody>
    </Sidebar>
    <SidebarInset class="p-4">
      <div class="flex items-center gap-3">
        <SidebarTrigger />
        <p class="text-sm text-gray-11">Below 768px this opens a modal slideover.</p>
      </div>
    </SidebarInset>
  </SidebarProvider>
</template>

Inset layout ​

Dashboard
vue
<script setup lang="ts">
import {
  Sidebar,
  SidebarBody,
  SidebarProvider,
  SidebarHeader,
  SidebarInset,
  SidebarTrigger,
} from '@typlog/ui'
</script>

<template>
  <SidebarProvider class="h-80 overflow-hidden rounded-lg">
    <Sidebar variant="inset" width="13rem">
      <SidebarHeader><strong>Typlog</strong></SidebarHeader>
      <SidebarBody class="text-sm text-gray-11">Inset navigation</SidebarBody>
    </Sidebar>
    <SidebarInset>
      <header class="flex h-12 items-center gap-2 border-b border-gray-4 px-4">
        <SidebarTrigger />
        <strong>Dashboard</strong>
      </header>
      <div class="p-4">
        <div class="h-40 rounded-lg bg-gray-3"></div>
      </div>
    </SidebarInset>
  </SidebarProvider>
</template>

Floating layout ​

Overview
vue
<script setup lang="ts">
import {
  Sidebar,
  SidebarBody,
  SidebarProvider,
  SidebarHeader,
  SidebarInset,
  SidebarTrigger,
} from '@typlog/ui'
</script>

<template>
  <div class="grid gap-4">
    <SidebarProvider
      class="h-64 overflow-hidden rounded-lg border border-gray-5"
    >
      <Sidebar class="bg-gray-1" variant="floating" collapsible="offcanvas" width="10em">
        <SidebarHeader><strong>floating</strong></SidebarHeader>
        <SidebarBody class="text-sm text-gray-11">Sidebar panel</SidebarBody>
      </Sidebar>
      <SidebarInset>
        <header class="flex h-12 items-center gap-3 px-2">
          <SidebarTrigger />
          <strong>Overview</strong>
        </header>
      </SidebarInset>
    </SidebarProvider>
  </div>
</template>

Rail ​

The rail is a keyboard-accessible desktop control placed on the panel edge. An external SidebarTrigger is still needed to restore an offcanvas sidebar after it is fully hidden.

The rail is an accessible collapse control.
vue
<script setup lang="ts">
import {
  Sidebar,
  SidebarBody,
  SidebarProvider,
  SidebarHeader,
  SidebarInset,
  SidebarRail,
} from '@typlog/ui'
</script>

<template>
  <SidebarProvider class="h-72 overflow-hidden rounded-lg border border-gray-5">
    <Sidebar v-slot="{ collapsed }" collapsible="icon" width="13rem">
      <SidebarHeader>
        <span class="grid size-7 shrink-0 place-items-center rounded bg-accent-9 text-white">T</span>
        <strong v-if="!collapsed">Typlog</strong>
      </SidebarHeader>
      <SidebarBody class="text-sm text-gray-11">
        <span v-if="!collapsed">Hover or focus the sidebar edge, then activate it.</span>
      </SidebarBody>
      <SidebarRail />
    </Sidebar>
    <SidebarInset class="p-4 text-sm text-gray-11">The rail is an accessible collapse control.</SidebarInset>
  </SidebarProvider>
</template>

Sidebar intentionally does not accept navigation item data or depend on Vue Router. Compose links and buttons with SidebarGroup, SidebarMenu, and their item components. Set active explicitly and configure size or accent color at the SidebarMenu boundary rather than on individual buttons.

Use icon, text, and trailingIcon for common Iconify content, or replace those parts with the icon, text, and trailing slots. The default slot is shorthand for text. Use as="a" or :as="RouterLink" for structured links. asChild remains available as a raw single-child escape hatch and does not add the structured content wrappers.

A SidebarMenuButton tooltip is enabled only on desktop when its sidebar uses icon collapse and is currently collapsed; the tooltip opens toward the content area. When tooltip is omitted, a text prop also supplies the tooltip label. Set :tooltip="false" to opt out. In the collapsed state, structured text is visually clipped rather than removed from the DOM, so the button keeps its accessible name; trailing content is hidden and only the leading icon remains visible.

vue
<script setup lang="ts">
import {
  Badge,
  Sidebar,
  SidebarBody,
  SidebarGroup,
  SidebarGroupLabel,
  SidebarHeader,
  SidebarInset,
  SidebarMenu,
  SidebarMenuButton,
  SidebarMenuItem,
  SidebarProvider,
  SidebarTrigger,
} from '@typlog/ui'

interface NavigationLink {
  label: string
  icon: string
  count?: number
}

interface NavigationGroup {
  label: string
  links: NavigationLink[]
}

const groups: NavigationGroup[] = [
  {
    label: 'Workspace',
    links: [
      { label: 'Inbox', icon: 'lucide:inbox', count: 12 },
      { label: 'Drafts', icon: 'lucide:file-pen', count: 3 },
    ],
  },
  {
    label: 'Manage',
    links: [
      { label: 'Members', icon: 'lucide:users' },
      { label: 'Settings', icon: 'lucide:settings' },
    ],
  },
]
</script>

<template>
  <SidebarProvider class="h-80 overflow-hidden rounded-lg border border-gray-5">
    <Sidebar v-slot="{ collapsed }" collapsible="icon">
      <SidebarHeader><strong v-if="!collapsed">Publication</strong></SidebarHeader>
      <SidebarBody>
        <SidebarGroup v-for="group in groups" :key="group.label">
          <SidebarGroupLabel>{{ group.label }}</SidebarGroupLabel>
          <SidebarMenu>
            <SidebarMenuItem
              v-for="link in group.links"
              :key="link.label"
            >
              <SidebarMenuButton
                as="a"
                href="#"
                :active="link.label === 'Inbox'"
                :icon="link.icon"
                :text="link.label"
              >
                <template #trailing>
                  <Badge
                    v-if="link.count"
                  >
                    {{ link.count }}
                  </Badge>
                </template>
              </SidebarMenuButton>
            </SidebarMenuItem>
          </SidebarMenu>
        </SidebarGroup>
      </SidebarBody>
    </Sidebar>
    <SidebarInset class="p-4"><SidebarTrigger /></SidebarInset>
  </SidebarProvider>
</template>

SidebarMenuSub provides only nested list semantics and indentation. Its button supports the same icon, text, trailingIcon, and named content slots as SidebarMenuButton. Compose CollapsibleRoot, an as-child CollapsibleTrigger, and CollapsibleContent when a submenu needs expandable state; place the structured SidebarMenuButton directly inside the trigger and put CollapsibleIndicator in its trailing slot. A general trailingIcon remains static, while the explicit indicator follows the collapsible state. The entire submenu is hidden visually in desktop icon-collapsed mode.

vue
<script setup lang="ts">
import {
  CollapsibleContent,
  CollapsibleIndicator,
  CollapsibleRoot,
  CollapsibleTrigger,
  Sidebar,
  SidebarBody,
  SidebarGroup,
  SidebarGroupLabel,
  SidebarInset,
  SidebarMenu,
  SidebarMenuButton,
  SidebarMenuItem,
  SidebarMenuSub,
  SidebarMenuSubButton,
  SidebarMenuSubItem,
  SidebarProvider,
  SidebarTrigger,
} from '@typlog/ui'
</script>

<template>
  <SidebarProvider class="h-80 overflow-hidden rounded-lg border border-gray-5">
    <Sidebar collapsible="icon" width="14rem">
      <SidebarBody>
        <SidebarGroup>
          <SidebarGroupLabel>Workspace</SidebarGroupLabel>
          <SidebarMenu>
            <CollapsibleRoot :as="SidebarMenuItem" default-open>
              <CollapsibleTrigger as-child>
                <SidebarMenuButton
                  icon="lucide:folder"
                  text="Projects"
                >
                  <template #trailing><CollapsibleIndicator /></template>
                </SidebarMenuButton>
              </CollapsibleTrigger>
              <CollapsibleContent>
                <SidebarMenuSub>
                  <SidebarMenuSubItem>
                    <SidebarMenuSubButton as="a" href="#" text="Typlog UI" active />
                  </SidebarMenuSubItem>
                  <SidebarMenuSubItem>
                    <SidebarMenuSubButton as="a" href="#" text="Documentation" />
                  </SidebarMenuSubItem>
                </SidebarMenuSub>
              </CollapsibleContent>
            </CollapsibleRoot>
            <SidebarMenuItem>
              <SidebarMenuButton icon="lucide:users" text="Members" />
            </SidebarMenuItem>
          </SidebarMenu>
        </SidebarGroup>
      </SidebarBody>
    </Sidebar>
    <SidebarInset class="p-4"><SidebarTrigger /></SidebarInset>
  </SidebarProvider>
</template>
Last updated Sep 15, 2026