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 { AppShell } from '@hina-ui/vue'
AppShell fills the viewport and divides the interface into three parts: the sidebar slot on the left, the header slot along the top and the default slot for the main area. All three are optional. A banner slot sits above everything, spanning the sidebar and the main area, for a Banner.
It is also the source of the sidebar’s state. Sidebar, SidebarGroup, SidebarTrigger and any NavLink inside the sidebar read their state from here; outside an AppShell they fall back to the expanded form and SidebarTrigger does not render at all.
Main content goes in the default slot.
<script setup lang="ts">
import { AppShell, NavLink, Sidebar, Text } from '@hina-ui/vue'
</script>
<template>
<AppShell class="border-line h-72 w-full rounded-lg border">
<template #sidebar>
<Sidebar>
<NavLink href="#" active label="Overview">Overview</NavLink>
<NavLink href="#" label="My shelf">My shelf</NavLink>
</Sidebar>
</template>
<Text size="sm" tone="muted" class="block p-6">Main content goes in the default slot.</Text>
</AppShell>
</template>
The component is one viewport tall by default. A frame embedded within a page can override the height through class, which is what the examples on this page do.
The main area is its own scroll container. A direct child of the default slot with min-h-full is at least as tall as the visible main area, with or without a banner. The header height is available as --hn-app-shell-header-h on the root element and can be read at any depth inside the shell.
<AppShell>
<template #header>…</template>
<div class="flex min-h-full flex-col">
<article class="flex-1">…</article>
<footer>…</footer>
</div>
</AppShell>
Examples
Collapsed form
collapsible decides what the sidebar becomes when collapsed on desktop: rail leaves a strip of icons, hidden takes the sidebar away entirely. The default is rail.
The state can be bound two-way with v-model:sidebar, taking expanded, rail or hidden. Bind it when the current form has to be read or set from elsewhere; otherwise leave the component to manage it.
Logos, avatars and entries in expanded groups stay in place during collapse. Sidebar brand slots handle icon and wordmark visibility automatically; SidebarLabel provides the same transition for custom header and footer content.
collapsible="rail" · expanded
Creator workspace
Overview
Toggle the sidebar to see the logo, groups and footer stay in place. On narrow screens, the button opens the drawer.
collapsible="hidden" · expanded
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,
type SidebarState,
} 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 }] },
]
const examples = ref<{ mode: 'rail' | 'hidden'; state: SidebarState }[]>([
{ mode: 'rail', state: 'expanded' },
{ mode: 'hidden', state: 'expanded' },
])
</script>
<template>
<Stack gap="lg" class="w-full">
<Stack v-for="example in examples" :key="example.mode" gap="sm">
<Text size="sm" tone="faint">collapsible="{{ example.mode }}" · {{ example.state }}</Text>
<AppShell
v-model:sidebar="example.state"
:collapsible="example.mode"
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>
</Stack>
</Stack>
</template>
@size-stable fires once after a desktop sidebar change finishes, including changes without an animation. Use it to resize expensive charts. When using this event, disable the chart’s continuous resize observer to avoid doing both. Descendant ScrollArea instances automatically defer measurements during the sidebar transition and catch up when it finishes; native scrolling remains available.
Scrolling the main area
The main area brings its own scroll container, so however long the content grows it scrolls within that area while the header and sidebar stay put.
The header does not scroll with the content
Paragraph 1.
Paragraph 2.
Paragraph 3.
Paragraph 4.
Paragraph 5.
Paragraph 6.
Paragraph 7.
Paragraph 8.
Paragraph 9.
Paragraph 10.
Paragraph 11.
Paragraph 12.
<script setup lang="ts">
import { AppShell, Heading, NavLink, Sidebar, Stack, Text } from '@hina-ui/vue'
const paragraphs = Array.from({ length: 12 }, (_, i) => `Paragraph ${i + 1}.`)
</script>
<template>
<AppShell class="border-line h-72 w-full rounded-lg border">
<template #sidebar>
<Sidebar>
<NavLink href="#" active label="Body">Body</NavLink>
<NavLink href="#" label="Notes">Notes</NavLink>
</Sidebar>
</template>
<template #header>
<Heading :level="2" size="sm">The header does not scroll with the content</Heading>
</template>
<Stack gap="sm" class="p-6">
<Text v-for="p in paragraphs" :key="p" size="sm" tone="muted">{{ p }}</Text>
</Stack>
</AppShell>
</template>
Behaviour
- At viewport widths of 1024 pixels and above the layout is desktop and the sidebar sits permanently on the left; below that the sidebar moves into a drawer, carrying the contents of the
sidebarslot unchanged. - On desktop,
SidebarTriggerswitches between expanded and the form named bycollapsible; on narrow screens it toggles the drawer. - The drawer’s open state can be bound with
v-model:mobileOpen. - The mobile Drawer has no extra title bar. Its close button sits in the Sidebar header by default; set
:closable="false"onSidebarto hide it.mobileTitleonly sets the accessible name and defaults to the interface language. - The sidebar fills the drawer height. Its header and footer stay at either end while the navigation fills the remaining space and scrolls independently.
- With
autoCloseon, a route change closes the drawer so it does not stay in front of the content after navigating. It is on by default. - With
restoreKeyset, the component saves and restores the scroll position of the main area by itself: the position is written tohistory.stateper history entry, restored on reload, back and forward, and a new page starts at the top or, with a#hash, at that element. Several shells on one page take different keys. - Server output carries an inline script that sets the position before activation, so a reload does not show the top first. Where the content security policy forbids inline scripts, the script does not run and the position is restored after activation.
- Restoration does not depend on a particular router: page changes are detected from history changes and from updates to the main content, and it waits up to 3 seconds for content that is not tall enough yet.
keepScrollPosition()makes the next page change keep the current position instead of starting at the top. Use it for navigations that change the path or the query without leaving the view, such as switching tabs, filtering or sorting. Call it right before navigating; it applies once, and back and forward still restore their own saved positions.- A tooltip provider is built in, so the hover hints a rail sidebar shows need no extra wrapper.
Accessibility
- The header renders as
headerand the main area asmain; the sidebar’s navigation landmark comes fromSidebaritself. SidebarTriggercarries an accessible name in the interface language (“Toggle sidebar” in English).- In the drawer form, focus is trapped inside the drawer and returned to the trigger on close, which
Drawerguarantees.
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
collapsible | 'rail' | 'hidden' | 'rail' | What the sidebar collapses to on desktop |
autoClose | boolean | true | Whether a route change closes the mobile drawer |
mobileTitle | string | Interface language | Accessible name of the mobile drawer; no visible title bar |
restoreKey | string | — | Saves and restores the scroll position of the main area; the value identifies this scroll container |
class | string | — | Classes appended to the root |
Two-way bindings
| Name | Type | Default | Description |
|---|---|---|---|
sidebar | SidebarState | 'expanded' | Sidebar form on desktop |
mobileOpen | boolean | false | Whether the mobile drawer is open |
Events
| Event | Payload | Description |
|---|---|---|
size-stable | — | The sidebar layout has settled after its state changes; interrupted transitions notify only when the final layout settles |
CSS variables
| Variable | Default | Description |
|---|---|---|
--hn-app-shell-header-h | calc(3.5rem + 1px) | Header height including its bottom border; overriding it through class resizes the header |
Slots
| Slot | Description |
|---|---|
banner | Announcement bar across the very top of the shell |
sidebar | Sidebar content, moved into the drawer when narrow |
header | Content of the top bar |
default | Content of the main area |
Expose
| Name | Type | Description |
|---|---|---|
mainViewport | HTMLElement | undefined | Viewport element of the main scroll area |
mainArea | ScrollArea | undefined | The main area’s scroll container |
Functions
| Function | Description |
|---|---|
keepScrollPosition(key?: string) | Keeps the scroll position of the main area across the next page change; pass a restoreKey to address one shell |
import { keepScrollPosition } from '@hina-ui/vue'
function sort(order: string) {
keepScrollPosition()
router.replace({ query: { ...route.query, order } })
}