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 · type | Default | Description |
|---|---|---|
| Value | ||
| collapsedboolean | Desktop: 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= false | false | The 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.