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>
Usage
import { Sidebar, SidebarGroup, SidebarLabel, SidebarTrigger } from '@hina-ui/vue'
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>
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>
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 provided | Expanded | Rail |
|---|---|---|
icon + wordmark | Icon and wordmark side by side | Icon stays in place; wordmark fades out |
Only icon | Icon visible | Stays in place |
Only wordmark | Wordmark aligned to the header start | Entire brand region collapses |
| Neither | No brand region | No 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>
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>
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,
SidebarGroupis 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
navlandmark whose default accessible name follows the interface language (“Sidebar navigation” in English);labeloverrides it. - In the rail form the entry text is invisible, but each NavLink’s
labelremains as itsaria-label. closableonly 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
altfor 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 |
| Slot | Slot 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 |