Drawer
Read or edit details in a side panel without leaving the list or page behind it. Built on it: Crud drawer.
<script setup lang="ts">import { ref } from 'vue'import { Copy, Download, ExternalLink, Mail } from '@lucide/vue'import { UiCard, UiList, UiListItem, UiBadge, UiDrawer, UiIconButton } from 'grani' // One list of invoices behind every tab: the panel stands beside it, and the list answers.type Status = 'draft' | 'sent' | 'paid' interface Line { item: string qty: string amount: string} interface Invoice { id: string client: string total: string issued: string due: string status: Status lines: Line[] payments: { on: string; how: string; amount: string }[]} const INVOICES: Invoice[] = [ { id: 'INV-0042', client: 'Acme Corp', total: '€1 240.00', issued: 'Sep 3', due: 'Sep 30', status: 'paid', lines: [ { item: 'Design review', qty: '2 × €400.00', amount: '€800.00' }, { item: 'Prototype', qty: '1 × €440.00', amount: '€440.00' }, ], payments: [{ on: 'Sep 12', how: 'Bank transfer', amount: '€1 240.00' }], }, { id: 'INV-0043', client: 'Birch & Co', total: '€380.00', issued: 'Sep 9', due: 'Oct 9', status: 'sent', lines: [{ item: 'Support, September', qty: '4 h × €95.00', amount: '€380.00' }], payments: [], }, { id: 'INV-0044', client: 'Cedar Ltd', total: '€2 115.50', issued: 'Sep 14', due: 'Oct 14', status: 'sent', lines: [ { item: 'Workshop', qty: '1 × €1 500.00', amount: '€1 500.00' }, { item: 'Travel', qty: '1 × €415.50', amount: '€415.50' }, { item: 'Materials', qty: '4 × €50.00', amount: '€200.00' }, ], payments: [{ on: 'Sep 20', how: 'Card', amount: '€500.00' }], }, { id: 'INV-0045', client: 'Northwind', total: '€640.00', issued: 'Sep 20', due: 'Oct 20', status: 'draft', lines: [{ item: 'Audit', qty: '1 × €640.00', amount: '€640.00' }], payments: [], }, { id: 'INV-0046', client: 'Lumen Studio', total: '€95.00', issued: 'Sep 28', due: 'Oct 28', status: 'paid', lines: [{ item: 'Hosting, October', qty: '1 × €95.00', amount: '€95.00' }], payments: [{ on: 'Sep 29', how: 'Card', amount: '€95.00' }], },] const STATUS: Record<Status, { label: string; tone: 'neutral' | 'info' | 'success' }> = { draft: { label: 'Draft', tone: 'neutral' }, sent: { label: 'Sent', tone: 'info' }, paid: { label: 'Paid', tone: 'success' },} // Detail: the row pressed opens its own invoice.const current = ref<Invoice | null>(null) const detailOpen = ref(false) const openRow = (row: Invoice) => { current.value = row detailOpen.value = true}</script> <template> <UiCard :padded="false" class="w-full max-w-md"> <UiList> <UiListItem v-for="row in INVOICES" :key="row.id" as="button" :title="row.id" :description="row.client" @click="openRow(row)" > <template #actions> <UiBadge :tone="STATUS[row.status].tone" :label="STATUS[row.status].label" /> </template> </UiListItem> </UiList> </UiCard> <UiDrawer v-model:open="detailOpen" :title="current?.id ?? ''"> <template #description> <UiBadge v-if="current" :tone="STATUS[current.status].tone" :label="STATUS[current.status].label" variant="inline" size="sm" /> <span>{{ current?.client }}</span> </template> <template #actions> <UiIconButton :icon="ExternalLink" ariaLabel="Open as a page" variant="ghost" /> </template> <template v-if="current"> <UiCard> <p class="text-2xl font-semibold tabular-nums text-fg">{{ current.total }}</p> <p class="text-sm text-fg-muted">Due {{ current.due }} · Issued {{ current.issued }}</p> </UiCard> <UiCard title="Lines" :padded="false"> <UiList> <UiListItem v-for="line in current.lines" :key="line.item" size="sm" :title="line.item" :description="line.qty" > <template #actions> <span class="text-sm tabular-nums text-fg">{{ line.amount }}</span> </template> </UiListItem> </UiList> </UiCard> <UiCard title="Payments" :padded="false"> <UiList> <UiListItem v-for="pay in current.payments" :key="pay.on" size="sm" :title="pay.on" :description="pay.how" > <template #actions> <span class="text-sm tabular-nums text-fg">{{ pay.amount }}</span> </template> </UiListItem> <UiListItem v-if="!current.payments.length" size="sm" title="No payments yet" :description="`Due ${current.due}`" /> </UiList> </UiCard> <UiCard :padded="false"> <UiList> <UiListItem v-if="current.status !== 'paid'" as="button" size="sm" title="Send a reminder" > <template #actions><Mail aria-hidden="true" /></template> </UiListItem> <UiListItem as="button" size="sm" title="Download the PDF"> <template #actions><Download aria-hidden="true" /></template> </UiListItem> <UiListItem as="button" size="sm" title="Copy the link"> <template #actions><Copy aria-hidden="true" /></template> </UiListItem> </UiList> </UiCard> </template> </UiDrawer></template>
Also called side panel, sheet, off-canvas panel, slide-over.
Usage
<UiDrawer :title="title">…</UiDrawer> With GraniResolver in the Vite config, a template needs no import: set it up once.
API
| Name · type | Default | Description |
|---|---|---|
| Value | ||
| openboolean= false | false | Whether the panel is open (v-model:open); every way out sets it false and emits close. |
| State | ||
| submittingboolean= false | false | A request is out: every close is refused with a shake. |
| Behavior | ||
| title*string | The panel's name, read first by a screen reader; required, a dialog without one is mute. | |
| descriptionstring= "" | "" | One line under the title, read right after it. |
| persistentboolean= false | false | Unsaved input: the backdrop and Escape are refused with a shake; the cross still closes. |
| Look | ||
| sizemd | lg= "md" | "md" | The panel's width: md 512px, the default; lg 768px for what reads wide, a text or a code. |
Attributes are placed by the component itself: its description says where.
When to use another component
| When you need | Take |
|---|---|
| A record to edit or create | Crud drawer, with Save, loading and steps, built on this panel |
| A short task for the person's whole attention | Modal |
| A small panel of controls under a button | Popover, with the page live |
| A second panel over this one | Never; what opens from it is a Modal for a short task, or a step of the same panel. Development warns when a drawer opens over another; a window or a question over it is fine |