Components and screens
Read when: you build a screen: the shell, a page header, cards, a table, a form, a modal or slideover, tabs, an empty state, a toast, a loading state, a chart. Owns: how Nuxt UI components are used and sized in a dashboard, the header tools, tables, forms, overlays, feedback, charts, and accessibility rules.
TL;DR
- One
h1per screen,h2per section; one solid primary button per area, on the end; the rest neutral. - Section card:
rounded-xl border border-default bg-default p-5; a table card drops the padding. - Columns are a
computed<TableColumn<T>[]>so headers follow the language. - Hints sit behind an info icon in the label (
FieldLabel), never as a line of help text. USkeletonshaped like the content while loading; a toast after an action;useConfirmbefore anything that cannot be undone.
House components (StatTile, FieldLabel, EmptyState, ConfirmModal) and composables are defined in
blueprint.md; this file shows how screens use them.
Dashboard shell
The hand-built shell is app/layouts/dashboard.vue in blueprint.md.
It suits a tool with a handful of screens.
Nuxt UI's dashboard components give a resizable, collapsible sidebar and a mobile slideover:
<!-- layouts/dashboard.vue -->
<template>
<UDashboardGroup storage="local" storage-key="my-app-dashboard">
<UDashboardSidebar collapsible resizable :default-size="15" :min-size="10" :max-size="20">
<template #header="{ collapsed }"><AppBrand :compact="collapsed" /></template>
<template #default="{ collapsed }">
<UNavigationMenu :items="links" :collapsed="collapsed" orientation="vertical" />
</template>
</UDashboardSidebar>
<slot />
</UDashboardGroup>
</template>
<!-- pages/invoices.vue -->
<template>
<UDashboardPanel id="invoices">
<template #header>
<UDashboardNavbar :title="t('nav.invoices')">
<template #leading><UDashboardSidebarCollapse /></template>
</UDashboardNavbar>
</template>
<template #body><!-- content --></template>
</UDashboardPanel>
</template>
Checked 2026-11-03 (ui.nuxt.com/docs/components/dashboard-sidebar, dashboard-group, dashboard-panel): sizes are
percent by default (unit on the group changes it), the sidebar mode on mobile is slideover by default
(drawer, modal), side is left or right, and each panel should get an id when several pages use one.
Check the sidebar's side in Arabic before shipping; the docs do not describe RTL.
Header tools
<!-- HeaderTools.vue -->
<template>
<div class="flex items-center gap-1 rounded-lg border border-default bg-default p-1">
<UTooltip :text="loadedAt ? t('header.refreshedAt', { time: timeOfDay(loadedAt) }) : t('header.refresh')">
<UButton icon="i-lucide-refresh-cw" color="neutral" variant="ghost" size="sm" :aria-label="t('header.refresh')" :loading="refreshing" @click="refresh()" />
</UTooltip>
<UTooltip :text="t('header.language')">
<UButton :label="locale === 'ar' ? 'EN' : 'AR'" color="neutral" variant="ghost" size="sm" class="font-mono font-semibold" :aria-label="t('header.language')" @click="setLocale(locale === 'ar' ? 'en' : 'ar')" />
</UTooltip>
<UTooltip :text="t('header.signOut')">
<UButton icon="i-lucide-log-out" color="neutral" variant="ghost" size="sm" :aria-label="t('header.signOut')" @click="signOut()" />
</UTooltip>
<UTooltip :text="t('header.colorMode')"><UColorModeButton size="sm" /></UTooltip>
</div>
</template>
Page header
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0">
<h1 class="text-2xl font-bold text-highlighted">{{ t('nav.invoices') }}</h1>
<p class="mt-1 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted">
<span class="inline-flex items-center gap-1.5">
<UIcon name="i-lucide-hash" class="size-3.5 text-dimmed" /> <bdi dir="ltr" class="font-mono text-xs">{{ customer.id }}</bdi>
</span>
</p>
</div>
<div class="flex flex-wrap gap-2">
<UButton :label="t('invoices.import')" icon="i-lucide-upload" color="neutral" variant="outline" />
<UButton :label="t('invoices.add')" icon="i-lucide-plus" />
</div>
</div>
A help link is color="neutral" variant="ghost" with i-lucide-circle-help.
Cards
- Section:
<section class="space-y-4 rounded-xl border border-default bg-default p-5">+<h2 class="font-semibold text-highlighted">. Grid of sections:grid gap-4 lg:grid-cols-2. - Key-value list:
<dl class="divide-y divide-default rounded-lg border border-default text-sm">, rowsflex items-center justify-between gap-3 px-4 py-2.5,dttext-muted,ddtext-highlighted. - A card that opens something is itself a
buttonor link, withhover:shadow-mdand a visible focus ring. - A share or progress bar: track
h-2 overflow-hidden rounded-full bg-(--chart-track), fillh-full rounded-full bg-(--chart-bar)with:style="{ width: pct + '%' }".
Tables
import { h } from 'vue'
import type { TableColumn } from '@nuxt/ui'
const columns = computed<TableColumn<Invoice>[]>(() => [
{ id: 'customer', header: t('invoices.customer'), meta: { class: { td: 'w-full min-w-56' } } },
{ id: 'number', header: t('invoices.number'), meta: { class: { th: 'hidden md:table-cell', td: 'hidden md:table-cell' } } },
{ id: 'total', header: t('invoices.total'), meta: { class: { th: 'hidden lg:table-cell', td: 'hidden lg:table-cell' } } },
{ id: 'status', header: t('invoices.status') },
{ id: 'actions', header: () => h('span', { class: 'sr-only' }, t('common.actions')), meta: { class: { td: 'w-0' } } },
])
<section class="rounded-xl border border-default bg-default">
<UTable :data="invoices" :columns :empty="t('invoices.none')" :loading
:ui="{ th: 'py-2.5 text-xs font-medium whitespace-nowrap text-muted', td: 'py-3.5' }">
<template #customer-cell="{ row }">
<div class="flex min-w-0 items-center gap-3">
<UAvatar :alt="row.original.customer" icon="i-lucide-building-2" class="size-9 shrink-0" :ui="{ root: 'bg-primary/10', icon: 'size-4.5 text-primary' }" />
<p class="truncate font-medium text-highlighted">{{ row.original.customer }}</p>
</div>
</template>
<template #number-cell="{ row }"><bdi dir="ltr" class="font-mono text-xs text-toned">{{ row.original.number }}</bdi></template>
<template #total-cell="{ row }"><span class="tabular-nums">{{ money(row.original.total) }}</span></template>
<template #status-cell="{ row }">
<UBadge v-bind="statusOf(row.original.status)" variant="subtle" class="rounded-full whitespace-nowrap" />
</template>
<template #actions-cell="{ row }">
<div class="flex items-center justify-end gap-0.5">
<UTooltip :text="t('common.edit')">
<UButton icon="i-lucide-pencil" color="neutral" variant="ghost" size="sm" :aria-label="t('common.edit')" @click="edit(row.original)" />
</UTooltip>
<UTooltip :text="t('common.delete')">
<UButton icon="i-lucide-trash-2" color="error" variant="ghost" size="sm" :aria-label="t('common.delete')" @click="remove(row.original)" />
</UTooltip>
</div>
</template>
</UTable>
</section>
- One column takes the spare width (
w-full), actions take none (w-0), secondary columns hide by breakpoint. - Clickable rows: add
thead: 'border-t border-default'when a toolbar sits above,tr: 'cursor-pointer hover:bg-muted/50', and keep a real button in the row for keyboard users. - A cell built in script uses
h(resolveComponent('UBadge'), …); slots read better, useh()only for shared cells. - Filters above:
UInput icon="i-lucide-search",USelectfor a category,UTabs :content="false"for status with a countbadge: { label: '12', color: 'neutral', variant: 'subtle' }per tab. - Table, list and cards views: a small
UTabsswitch; keep the choice inlocalStorageinsidetry/catch.
Forms
<form id="invoice-form" class="space-y-4" autocomplete="off" @submit.prevent="save">
<div class="grid gap-4 sm:grid-cols-2">
<UFormField :label="t('invoices.customer')" required>
<UInput v-model.trim="form.customer" class="w-full" required />
</UFormField>
<UFormField>
<template #label><FieldLabel :label="t('invoices.reference')" :hint="t('hints.reference')" /></template>
<UInput v-model.trim="form.reference" icon="i-lucide-hash" class="w-full" />
</UFormField>
</div>
<UAlert v-if="error" color="error" variant="subtle" icon="i-lucide-circle-alert" :description="error" />
</form>
- Every input is
w-full; the grid sets the width. - Passwords:
type="password"withautocomplete="current-password"or"new-password". USwitchwith its label in#label. A monospace field::ui="{ base: 'font-mono' }".- Schema validation:
UForm :schema :state @submittakes any Standard Schema library (Zod, Valibot, Yup, Joi, Superstruct, Regle); errors reach theUFormFieldwith the samename(checked 2026-11-03, ui.nuxt.com/docs/components/form). For a few fields, the server's 422 lines in theUAlertare enough. - Unsaved drafts may live in
localStorageunder<app>:drafts, wrapped intry/catch.
Modals and slideovers
<UModal v-model:open="open" :title="editing ? t('invoices.edit') : t('invoices.add')"
:ui="{ footer: editing ? 'justify-between' : 'justify-end' }">
<template #body><!-- the form, id="invoice-form" --></template>
<template #footer>
<UButton v-if="editing" :label="t('common.delete')" icon="i-lucide-trash-2" color="error" variant="ghost" @click="remove" />
<div class="flex gap-2">
<UButton :label="t('common.cancel')" color="neutral" variant="outline" @click="open = false" />
<UButton type="submit" form="invoice-form" :label="t('common.save')" :loading="saving" />
</div>
</template>
</UModal>
form="invoice-form"lets the footer button submit the form in the body; Enter submits too.- Reset the form in
watch(open, isOpen => …)when it opens: resetting on close shows the empty form during the closing animation. - Wide content:
:ui="{ content: 'sm:max-w-5xl' }". Body spacing::ui="{ body: 'space-y-4' }". USlideoverhas the same slots plusside; use it for details next to a list or a long form::ui="{ content: 'w-full max-w-none sm:max-w-3xl' }".- Confirm:
if (await confirm({ title, confirmLabel, danger: true })) { … }. Skipdescriptionunless the title alone would mislead.
Tabs
- A segmented switch:
<UTabs v-model="metric" :items :content="false" size="xs" />, items{ label, value }. - Section tabs on a screen:
variant="link", items with anicon. - Content tabs: each item gets a
slotname; fill#<slot>. - Many tabs on a phone: wrap in
<div class="overflow-x-auto">and giveUTabsclass="mx-auto w-max"(or:ui="{ list: 'w-max' }").
Empty states
<EmptyState icon="i-lucide-receipt" :title="t('invoices.empty')"> with the primary action and a neutral
outline second action in the slot. In a table use :empty; in a chart a
grid h-48 place-items-center text-sm text-muted line.
Toasts
const toast = useToast()
toast.add({ title: t('invoices.saved'), color: 'success', icon: 'i-lucide-check' })
toast.add({ title: t('invoices.saveFailed'), description: (error as Error).message, color: 'error' })
toast.add({ title: t('invoices.partly'), description: lines.join('\n'), color: 'warning', icon: 'i-lucide-triangle-alert' })
Position once on <UApp :toaster="{ position: 'bottom-center' }">. Copy buttons use useCopy().
Loading and freshness
USkeletonblocks shaped like the content (titleh-8 w-56, cardh-32 w-full rounded-xl). A centredi-lucide-loader-circle animate-spin text-mutedonly for the first boot of the whole app.- Buttons that start work get
:loading. - Data older than 30 s refetches silently (no skeletons) on
focusandvisibilitychange. Poll (60 s) only while something waits on an outside system. Remove listeners and the interval inonBeforeUnmount. - The refresh button's tooltip shows the time of the last load.
Alerts
UAlert color="error" variant="subtle" icon="i-lucide-circle-alert" :title :description above what it concerns.
Show the server's translated message, never a stack or a vendor payload. A row with a problem shows a
color="error" badge with the message in a UTooltip.
Charts
Plain div bars, no chart library, colours from the chart tokens (theme.md).
<div class="rounded-xl border border-default bg-default p-5">
<div class="flex flex-wrap items-center justify-between gap-3">
<h3 class="font-semibold text-highlighted">{{ title }}</h3>
<ul v-if="series.length > 1" class="flex flex-wrap gap-3 text-xs text-toned">
<li v-for="s in series" :key="s.name" class="inline-flex items-center gap-1.5">
<span class="size-2.5 rounded-sm" :style="{ background: s.color }" /> {{ s.name }}
</li>
</ul>
</div>
<div class="mt-5 flex gap-3">
<div class="relative h-48 min-w-0 flex-1">
<div v-for="tick in ticks" :key="tick" class="absolute inset-x-0 border-t border-default" :style="{ top: `${100 - (tick / top) * 100}%` }" />
<div class="absolute inset-0 flex items-end gap-0.5">
<UTooltip v-for="(p, i) in points" :key="p.key" :text="tooltip(p)" :delay-duration="0">
<div class="group flex h-full flex-1 items-end justify-center">
<div class="w-full max-w-6 rounded-t-[4px] bg-(--chart-bar) transition-opacity group-hover:opacity-80"
:style="{ height: `${(p.value / top) * 100}%`, minHeight: p.value ? '2px' : '0' }" />
</div>
</UTooltip>
</div>
</div>
<div class="flex h-48 w-12 shrink-0 flex-col justify-between text-[11px] text-muted tabular-nums" dir="ltr">
<span v-for="tick in ticks" :key="tick">{{ format(tick) }}</span>
</div>
</div>
<table class="sr-only"><caption>{{ title }}</caption><!-- same numbers as rows --></table>
</div>
- Three gridlines: 0, half, a rounded top. Non-zero bars at least 2px tall.
- Stacked series: a
flex flex-col-reverse overflow-hidden rounded-t-[4px]column with one segment per series,gap-pxbetween columns. - Day labels under the bars: first and last always, others every fifth from
sm; edge labels anchored withstart-0/end-0.
Accessibility
- Icon-only buttons:
aria-label, plus aUTooltipwhen the icon is not obvious. - Current nav item:
aria-current="page". Decorative imagesalt=""; screenshots say what they show. - Colour never carries meaning alone: a status badge has a label and an icon.
- Nuxt UI overlays trap focus and close on Escape; don't rebuild them from
divs. - Contrast for any new token pair in both modes: 4.5:1 for text, 3:1 for large text and UI marks.
- Custom animation respects
prefers-reduced-motion.