Sidebar

The navigation column on the left of an application, collapsible to a rail of icons.

Creator workspace

Overview

Toggle the sidebar to see the logo, groups and footer stay in place. On narrow screens, the button opens the drawer.

<script setup lang="ts">
  import { ref } from 'vue'
  import { FileText, House, Images, Settings } from '@lucide/vue'
  import {
    AppShell,
    Avatar,
    Heading,
    Inline,
    NavLink,
    Sidebar,
    SidebarGroup,
    SidebarLabel,
    SidebarTrigger,
    Stack,
    Text,
  } from '@hina-ui/vue'

  const selected = ref('overview')
  const groups = [
    {
      label: 'Content',
      items: [
        { id: 'overview', label: 'Overview', icon: House },
        { id: 'articles', label: 'Articles', icon: FileText },
        { id: 'library', label: 'Media library', icon: Images },
      ],
    },
    { label: 'Manage', items: [{ id: 'settings', label: 'Settings', icon: Settings }] },
  ]
</script>

<template>
  <AppShell mobile-title="Hina UI" class="border-line h-112 w-full rounded-lg border">
    <template #sidebar>
      <Sidebar>
        <template #icon>
          <Avatar src="/favicon.png" name="Hina UI" class="rounded-md" />
        </template>
        <template #wordmark>
          <Stack gap="none">
            <Text weight="medium" class="truncate">Hina UI</Text>
            <Text size="xs" tone="muted" class="truncate">Workspace</Text>
          </Stack>
        </template>

        <SidebarGroup v-for="group in groups" :key="group.label" :label="group.label">
          <NavLink
            v-for="item in group.items"
            :key="item.id"
            :href="`#${item.id}`"
            :label="item.label"
            :active="selected === item.id"
            @click.prevent="selected = item.id"
          >
            <template #icon><component :is="item.icon" /></template>
            {{ item.label }}
          </NavLink>
        </SidebarGroup>

        <template #footer>
          <Inline gap="sm" align="center" :wrap="false">
            <Avatar src="/avatars/paper.webp" name="Shion Hoshimi" />
            <SidebarLabel as="div" class="flex-1">
              <Stack gap="none">
                <Text size="sm" weight="medium" class="truncate">Shion Hoshimi</Text>
                <Text size="xs" tone="muted" class="truncate">Administrator</Text>
              </Stack>
            </SidebarLabel>
          </Inline>
        </template>
      </Sidebar>
    </template>
    <template #header>
      <SidebarTrigger />
      <Heading :level="2" size="sm" class="truncate">Creator workspace</Heading>
    </template>
    <Stack gap="sm" class="p-6">
      <Heading :level="3" size="sm">
        {{ groups.flatMap(group => group.items).find(item => item.id === selected)?.label }}
      </Heading>
      <Text size="sm" tone="muted">
        Toggle the sidebar to see the logo, groups and footer stay in place. On narrow screens, the
        button opens the drawer.
      </Text>
    </Stack>
  </AppShell>
</template>
vue

Usage

import { Sidebar, SidebarGroup, SidebarLabel, SidebarTrigger } from '@hina-ui/vue'
ts

Sidebar goes in the sidebar slot of an AppShell, with its entries — usually a run of NavLink — in the default slot. It brings its own navigation landmark and scroll container, so a long list scrolls within the column.

Its form comes from AppShell; it holds no state of its own. Outside an AppShell it is always expanded and cannot be collapsed.

Sidebar entries go in the default slot.

<script setup lang="ts">
  import { AppShell, NavLink, Sidebar, Text } from '@hina-ui/vue'
</script>

<template>
  <AppShell class="border-line h-64 w-full rounded-lg border">
    <template #sidebar>
      <Sidebar>
        <NavLink href="#" active label="Overview">Overview</NavLink>
        <NavLink href="#" label="My shelf">My shelf</NavLink>
        <NavLink href="#" label="Favourites">Favourites</NavLink>
      </Sidebar>
    </template>

    <Text size="sm" tone="muted" class="block p-6">Sidebar entries go in the default slot.</Text>
  </AppShell>
</template>
vue

Every NavLink inside the sidebar should carry a label: once collapsed to a rail the text fades out and label takes over as both the hover hint and the accessible name.

Examples

Groups

SidebarGroup gathers entries under a collapsible heading. label is the group name and defaultOpen sets whether it starts open, which it does by default.

The “Creating” group starts collapsed.

<script setup lang="ts">
  import { AppShell, NavLink, Sidebar, SidebarGroup, Text } from '@hina-ui/vue'
</script>

<template>
  <AppShell class="border-line h-96 w-full rounded-lg border">
    <template #sidebar>
      <Sidebar>
        <NavLink href="#" active label="Overview">Overview</NavLink>
        <SidebarGroup label="Library">
          <NavLink href="#" label="Galgame">Galgame</NavLink>
          <NavLink href="#" label="Light novels">Light novels</NavLink>
        </SidebarGroup>
        <SidebarGroup label="Creating" :default-open="false">
          <NavLink href="#" label="My entries">My entries</NavLink>
          <NavLink href="#" label="Drafts">Drafts</NavLink>
        </SidebarGroup>
      </Sidebar>
    </template>

    <Text size="sm" tone="muted" class="block p-6">The “Creating” group starts collapsed.</Text>
  </AppShell>
</template>
vue

Brand icon and wordmark

The icon and wordmark slots form the default header. The icon occupies a fixed 32 × 32 pixel box; SVGs and images preserve their proportions. The wordmark accepts text, SVG, Image, or composed content.

Slots providedExpandedRail
icon + wordmarkIcon and wordmark side by sideIcon stays in place; wordmark fades out
Only iconIcon visibleStays in place
Only wordmarkWordmark aligned to the header startEntire brand region collapses
NeitherNo brand regionNo brand region

With an icon, the brand row retains its height in rail form. With only a wordmark, the entire brand region collapses, including its vertical padding, and the navigation moves up to fill the space. Expanding the sidebar or opening the mobile drawer shows the full brand region. The wordmark automatically shares the navigation label transition without a SidebarLabel wrapper.

Both slots receive { state }. Providing header fully replaces the default header; icon and wordmark are then not rendered.

Icon and wordmark

Brand slots

Icon and wordmark

Wordmark only

Brand slots

Wordmark only

Icon only

Brand slots

Icon only

<script setup lang="ts">
  import { House } from '@lucide/vue'
  import { AppShell, Image, NavLink, Sidebar, SidebarTrigger, Stack, Text } from '@hina-ui/vue'
  import Wordmark from '../../../components/docs/Wordmark.vue'

  const examples = [
    { mode: 'both', label: 'Icon and wordmark' },
    { mode: 'wordmark', label: 'Wordmark only' },
    { mode: 'icon', label: 'Icon only' },
  ]
</script>

<template>
  <Stack gap="lg" class="w-full">
    <Stack v-for="example in examples" :key="example.mode" gap="sm">
      <Text size="sm" tone="muted">{{ example.label }}</Text>
      <AppShell mobile-title="Hina UI" class="border-line h-52 w-full rounded-lg border">
        <template #sidebar>
          <Sidebar>
            <template v-if="example.mode !== 'wordmark'" #icon>
              <Image src="/favicon.png" alt="Hina UI" class="rounded-md" />
            </template>
            <template v-if="example.mode !== 'icon'" #wordmark>
              <Wordmark class="items-center" />
            </template>
            <NavLink href="#" active label="Overview" @click.prevent>
              <template #icon><House /></template>
              Overview
            </NavLink>
          </Sidebar>
        </template>
        <template #header>
          <SidebarTrigger />
          <Text size="sm" weight="medium">Brand slots</Text>
        </template>
        <Text size="sm" tone="muted" class="block p-6">{{ example.label }}</Text>
      </AppShell>
    </Stack>
  </Stack>
</template>
vue

Header and footer

The header slot fully replaces the default brand header, while footer sits below the entries. Neither scrolls with the entries. Custom headers are not automatically hidden as logos.

The header retains its expanded content width. The footer follows the sidebar width so buttons and overlay anchors stay within the rail. For horizontal avatar and label layouts, use Inline with :wrap="false" and truncate or whitespace-nowrap on text to avoid wrapping during collapse. Wrap text and secondary actions in SidebarLabel to fade them out with NavLink labels and delay their fade-in on expansion. Keep the logo and Avatar outside it so their size and position stay fixed.

SidebarLabel preserves its layout space. In rail form, its contents are hidden from view, interaction, screen readers and keyboard focus. The header and footer slots still provide { state } for custom content that needs the current form.

Creator workspace

Overview

Toggle the sidebar to see the logo, groups and footer stay in place. On narrow screens, the button opens the drawer.

<script setup lang="ts">
  import { ref } from 'vue'
  import { FileText, House, Images, Settings } from '@lucide/vue'
  import {
    AppShell,
    Avatar,
    Heading,
    Inline,
    NavLink,
    Sidebar,
    SidebarGroup,
    SidebarLabel,
    SidebarTrigger,
    Stack,
    Text,
  } from '@hina-ui/vue'

  const selected = ref('overview')
  const groups = [
    {
      label: 'Content',
      items: [
        { id: 'overview', label: 'Overview', icon: House },
        { id: 'articles', label: 'Articles', icon: FileText },
        { id: 'library', label: 'Media library', icon: Images },
      ],
    },
    { label: 'Manage', items: [{ id: 'settings', label: 'Settings', icon: Settings }] },
  ]
</script>

<template>
  <AppShell mobile-title="Hina UI" class="border-line h-112 w-full rounded-lg border">
    <template #sidebar>
      <Sidebar>
        <template #header>
          <Inline gap="sm" align="center" :wrap="false">
            <Avatar src="/favicon.png" name="Hina UI" class="rounded-md" />
            <SidebarLabel as="div" class="flex-1">
              <Stack gap="none">
                <Text weight="medium" class="truncate">Hina UI</Text>
                <Text size="xs" tone="muted" class="truncate">Workspace</Text>
              </Stack>
            </SidebarLabel>
          </Inline>
        </template>

        <SidebarGroup v-for="group in groups" :key="group.label" :label="group.label">
          <NavLink
            v-for="item in group.items"
            :key="item.id"
            :href="`#${item.id}`"
            :label="item.label"
            :active="selected === item.id"
            @click.prevent="selected = item.id"
          >
            <template #icon><component :is="item.icon" /></template>
            {{ item.label }}
          </NavLink>
        </SidebarGroup>

        <template #footer>
          <Inline gap="sm" align="center" :wrap="false">
            <Avatar src="/avatars/paper.webp" name="Shion Hoshimi" />
            <SidebarLabel as="div" class="flex-1">
              <Stack gap="none">
                <Text size="sm" weight="medium" class="truncate">Shion Hoshimi</Text>
                <Text size="xs" tone="muted" class="truncate">Administrator</Text>
              </Stack>
            </SidebarLabel>
          </Inline>
        </template>
      </Sidebar>
    </template>
    <template #header>
      <SidebarTrigger />
      <Heading :level="2" size="sm" class="truncate">Creator workspace</Heading>
    </template>
    <Stack gap="sm" class="p-6">
      <Heading :level="3" size="sm">
        {{ groups.flatMap(group => group.items).find(item => item.id === selected)?.label }}
      </Heading>
      <Text size="sm" tone="muted">
        Toggle the sidebar to see the logo, groups and footer stay in place. On narrow screens, the
        button opens the drawer.
      </Text>
    </Stack>
  </AppShell>
</template>
vue

Behaviour

  • The three forms are 256 pixels wide when expanded, 56 as a rail and 0 when hidden, and the width transitions continuously between them.
  • Collapsed to a rail, SidebarGroup is forced open, its heading fades into a divider while retaining the same space. Entries in expanded groups keep their vertical positions.
  • The entry area uses ScrollArea without edge shadows; the header and footer stay fixed at either end.
  • When fully hidden, the entire sidebar leaves interaction and keyboard focus.
  • Width and label transitions use Hina motion tokens and switch instantly with reduced motion enabled.
  • Inside the mobile Drawer, the sidebar fills the drawer height with the footer pinned to the bottom and only the entries scrolling. The header includes a close button by default. Set :closable="false" to hide it; with no header or brand slots, the button row leaves no empty space. The drawer title is not duplicated. Horizontal padding comes from the drawer; the header, entries and footer retain their vertical padding.

Accessibility

  • The entry area is a nav landmark whose default accessible name follows the interface language (“Sidebar navigation” in English); label overrides it.
  • In the rail form the entry text is invisible, but each NavLink’s label remains as its aria-label.
  • closable only controls the mobile close button. Escape, the backdrop and controlled state can still close the drawer when the button is hidden.
  • Hidden wordmarks leave screen readers and keyboard focus. Supply alt for brand images and an appropriate accessible name for SVGs. A link around the icon also needs an accessible name.
  • A group heading hidden by the rail also leaves the keyboard order, so no control is focusable while invisible.

API

Sidebar

Prop
Type
Default
Description
label
string
Interface language
Accessible name of the landmark
closable
boolean
true
Whether to show the mobile drawer close button
class
string
—
Classes appended to the root
SlotSlot props
Description
default—
Sidebar entries
header{ state }
Fully replaces the header, taking priority over brand slots
icon{ state }
Brand icon in a fixed square box, retained in rail form
wordmark{ state }
Brand wordmark, automatically faded out in rail form
footer{ state }
Content below the entries

SidebarGroup

Prop
Type
Default
Description
label
string
Required
Group name
defaultOpen
boolean
true
Whether it starts open
class
string
—
Classes appended to the root
Slot
Description
default
Entries in the group

SidebarLabel

Controls the visibility of custom labels and secondary content in rail form. Always visible outside a sidebar state provider.

Prop
Type
Default
Description
as
string
'span'
Element to render
class
string
—
Classes appended to the root
Slot
Description
default
Content hidden when the sidebar collapses to a rail

SidebarTrigger

The button that switches the sidebar’s form, usually placed in the header slot of AppShell. It has nothing to configure and does not render outside an AppShell.

Prop
Type
Default
Description
class
string
—
Classes appended to the root