Skip to content

App shell

Organize an app around a sidebar for navigation and a main area for the page you are working on.

<script setup lang="ts">import { computed, onMounted, ref } from 'vue'import { BarChart3, Inbox, LifeBuoy, ReceiptText, Settings, Users } from '@lucide/vue'import type { NavGroup } from 'grani'import { UiAppShell, UiStack, UiHeader, UiCard, UiProgress, useToday } from 'grani' // The app's wordmark: the name the home link is read by is the word the logo shows.const brand = 'grani' // The places are anchors on this page: the one pressed is the current one, and on arrival the// page's hash. An app passes its route's path instead. A server never sees a hash: it is read once// the page is in the browser.const current = ref('#overview') const follow = (e: MouseEvent) => {  const to = (e.target as Element).closest('a[href^="#"]')?.getAttribute('href')  if (to) current.value = to} // Sample figures, new each day: read from today's date in the browser, so the server's page and the// client's agree, and a page photographed on a fixed day is the same every time.const goal = ref<{ percent: number; left: string; days: number; perDay: string } | null>(null) const amount = (n: number) => new Intl.NumberFormat('en').format(Math.round(n)) const { now } = useToday() onMounted(() => {  if (location.hash) current.value = location.hash  const day = Number(now.value.replaceAll('-', ''))  const target = 10000  const earned = 500 + ((day * 7919) % 9000)  const days = 1 + ((day * 104729) % 27)  goal.value = {    percent: Math.round((earned / target) * 100),    left: amount(target - earned),    days,    perDay: amount((target - earned) / days),  }}) const nav: NavGroup[] = [  {    items: [      { to: '#overview', label: 'Overview', icon: BarChart3 },      { to: '#inbox', label: 'Inbox', icon: Inbox, count: 4 },      { to: '#invoices', label: 'Invoices', icon: ReceiptText },      { to: '#clients', label: 'Clients and their contracts, by region', icon: Users },    ],  },  {    label: 'Workspace',    items: [      { to: '#settings', label: 'Settings', icon: Settings },      { to: '#help', label: 'Help', icon: LifeBuoy },    ],  },] const title = computed(  () => nav.flatMap((g) => g.items).find((i) => i.to === current.value)?.label ?? 'Overview',)</script> <template>  <UiAppShell    :brand="brand"    home="#overview"    :current="current"    :nav="nav"    class="w-full rounded-card border border-border [--ui-shell-height:560px]"    @click="follow"  >    <UiStack gap="title">      <UiHeader :title="title" description="The page scrolls here; the sidebar stays." />      <div class="grid gap-4 @3xl:grid-cols-3">        <UiCard v-for="n in 6" :key="n" :title="`Block ${n}`">          <div class="h-16 rounded-control bg-surface-muted" />        </UiCard>      </div>    </UiStack>    <template #logo>      <span class="text-base font-bold tracking-tight text-fg">        <!-- lint: english-ok -->        grani<i aria-hidden="true" class="ms-0.5 inline-block size-1 rotate-45 bg-brand-solid" />      </span>    </template>    <template #mark      ><span aria-hidden="true" class="m-1 size-3 shrink-0 rotate-45 bg-brand-solid"    /></template>    <template v-if="goal" #banner>      <div class="flex items-baseline justify-between text-xs font-medium text-fg-muted">        <span>Left to the goal</span>        <span class="tabular-nums">{{ goal.percent }}%</span>      </div>      <p class="mt-1 text-base font-semibold tabular-nums text-fg">{{ goal.left }} EUR</p>      <UiProgress        aria-label="Left to the goal"        :value="goal.percent"        :max="100"        class="mt-2 h-1.5"        :value-text="`${goal.percent}% of the goal`"      />      <p class="mt-2 text-xs text-fg-muted">        {{ goal.days }} days left, {{ goal.perDay }} EUR a day      </p>    </template>  </UiAppShell></template>

Also called app layout, sidebar layout, navigation shell.

Usage

<UiAppShell :nav="nav">…</UiAppShell>

With GraniResolver in the Vite config, a template needs no import: set it up once.

API

Name · typeDefaultDescription
Value
collapsedbooleanDesktop: only the icons show. Bound to a value, the shell follows it and only asks through update:collapsed; unbound or undefined, it keeps its own.
State
loadingboolean= falsefalseThe places are on their way: with nav still empty, the sidebar draws rows in their shape.
Behavior
nav*NavGroup[]The places of the app, in groups; an empty label draws none.
brandstring= """"The app's name: the logo when #logo is not given, and the name of the link home; unset, a translated "App".
homestring= "/""/"Where the logo leads; the item on it is the current page only on it, not under it. A javascript: or data: address is refused, as for the places.
currentstring= """"The path of the page shown (useRoute().path): it lights its item and closes the sheet.

Anything else lands on the root element: native attributes, aria-*, data-testid.

When to use another component

When you needTake
A set of panes on one screenTabs
A window or a panel over the pageModal or Drawer
The name of the page inside the main areaHeader page
Places inside a section of a pageList item links in a List