Skip to content

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 · typeDefaultDescription
Value
openboolean= falsefalseWhether the panel is open (v-model:open); every way out sets it false and emits close.
State
submittingboolean= falsefalseA request is out: every close is refused with a shake.
Behavior
title*stringThe 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= falsefalseUnsaved 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 needTake
A record to edit or createCrud drawer, with Save, loading and steps, built on this panel
A short task for the person's whole attentionModal
A small panel of controls under a buttonPopover, with the page live
A second panel over this oneNever; 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