AppShell

The outer frame of an application, holding the sidebar, header and main area.

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 { AppShell } from '@hina-ui/vue'
ts

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>
vue

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>
vue

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>
vue

@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>
vue

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 sidebar slot unchanged.
  • On desktop, SidebarTrigger switches between expanded and the form named by collapsible; 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" on Sidebar to hide it. mobileTitle only 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 autoClose on, a route change closes the drawer so it does not stay in front of the content after navigating. It is on by default.
  • With restoreKey set, the component saves and restores the scroll position of the main area by itself: the position is written to history.state per 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 header and the main area as main; the sidebar’s navigation landmark comes from Sidebar itself.
  • SidebarTrigger carries 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 Drawer guarantees.

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

VariableDefault
Description
--hn-app-shell-header-hcalc(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 } })
}
ts