UX patterns

Pages

The shape of each kind of app page, and the loading, empty and error states every page shows.

A staff member who has learned one list in the admin area should already know every other list. So pages in the admin and account areas are built from a few shapes. This page tells you which shape your page is, which shared components build it, and what it shows while it loads, when it is empty and when a request fails.

For: module authors and app developers who add a page to the admin or account area.

The components come from UI. Which area a page belongs in is on Layouts and navigation.

Which shape do I use?

Your page showsShapeSection
Many records of one kind, such as users or eventsA list page, with a detail page per recordLists
One record with several sectionsA detail pageDetail pages
A record with little of its own, such as a unitA slideover over the listLight entities
A few records that are not worth a table, such as passkeysA divided list in a cardDivided list
Numbers and what needs a decisionAn overviewOverview pages

Every page has the same root: a plain <div class="flex flex-col gap-4">. There is no page wrapper component.

Lists

A top-level entity gets a list page and a detail page. The list page has, from top to bottom:

  • A heading (PageHeading) with the entity's name. The primary action, such as "Add user" or "New event", goes in its #actions slot.
  • A toolbar (PageToolbar) with search and filters, once the list is longer than about eight rows. Leave it out for short lists.
  • A table in a card. A UTable sits inside TableCard, styled with tableUi(): small muted headers, compact rows on desktop and taller rows on mobile. Rows are clickable and open the detail page.
  • A row menu (RowMenu) in the last column, for the row's actions. See Row actions.
  • Pagination (TablePagination) in the card's #footer: "21–40 of 132", rows per page and pages.
  • An empty state (UEmpty) when there are no rows, never an empty table. See Empty states.

The row menu opens with Open first and the destructive action last, in its own red group:

Tables share these behaviours:

  • Sticky header. Long lists add sticky and tableUi({}, { sticky: true }). The header then stays in view while the table scrolls inside its card.
  • Sorting. A sortable column uses useTableSort().header(id, label): a ghost button that cycles ascending, descending and unsorted.
  • State in the URL. A server-paginated list keeps page, page size, search, sort and filters in the URL with useTableQuery. So a filtered list can be linked, and it survives a reload.
Loading never blanks a list that is already on screen. TableCard :loading shows skeleton rows on the first load only. Every later load, such as the next page, a new sort or a search, passes :refreshing. That keeps the current rows and dims them until the new ones arrive. The empty state shows only after a load succeeded with no rows, never while status is 'pending'. See Loading.

Cells follow the same rules everywhere:

  • First column. It is the record's identity (TableIdentity): the name, a muted second line such as an email or a slug, and an optional avatar or icon. Badges that belong to the name go in its #badges slot.
  • Numbers. They are right-aligned with tabular figures (meta: numericColumn).
  • Dates. They use useFormat() in a tabular-nums span.
  • Missing values. A missing value is a dimmed dash (emptyCell()), never an empty cell.
  • Status. A status is a subtle badge whose colour follows the status. The component is StatusBadge, and it picks the colour with statusColor. To change the colour of one status, pass colors, for example :colors="{ archived: 'warning' }".
<script setup lang="ts">
import type { TableColumn } from '@nuxt/ui'

const t = useModuleT('events')
const format = useFormat()
const actions = useEventActions()

const { page, pageSize, search, sort: sortState, query } = useTableQuery({ sort: { id: 'startsAt', desc: true } })
const { header } = useTableSort(null, sortState)
const { data, status } = await useFetch('/api/admin/events', { query, default: () => ({ items: [], total: 0 }) })
const pending = computed(() => status.value === 'pending')

const TableIdentity = resolveComponent('TableIdentity')
const RowMenu = resolveComponent('RowMenu')
const columns: TableColumn<EventRow>[] = [
  {
    accessorKey: 'title',
    header: header('title', t('admin.title', 'Title')),
    cell: ({ row }) => h(TableIdentity, { title: row.original.title, description: row.original.location }),
  },
  { accessorKey: 'startsAt', header: header('startsAt', t('admin.starts', 'Starts')) },
  { accessorKey: 'capacity', header: t('admin.capacity', 'Capacity'), meta: numericColumn },
  { id: 'actions', cell: ({ row }) => h(RowMenu, { items: actions.menu(row.original), subject: row.original.title }) },
]
</script>

<template>
  <div class="flex flex-col gap-4">
    <PageHeading :title="t('admin.events', 'Events')">
      <template #actions>
        <UButton icon="i-lucide-plus" :label="t('admin.create', 'New event')" @click="actions.create()" />
      </template>
    </PageHeading>

    <PageToolbar>
      <UInput v-model="search" icon="i-lucide-search" :placeholder="t('admin.search', 'Search events')" />
    </PageToolbar>

    <TableCard :loading="pending && !data.items.length" :refreshing="pending && !!data.items.length">
      <UTable
        :data="data.items"
        :columns="columns"
        :ui="tableUi({}, { sticky: true })"
        sticky
        @select="(_, row) => navigateTo(`/admin/events/${row.original.id}`)"
      >
        <template #startsAt-cell="{ row }">
          <span class="tabular-nums">{{ format.dateTime(row.original.startsAt) }}</span>
        </template>
        <template #empty>
          <UEmpty variant="naked" icon="i-lucide-calendar" :title="t('admin.empty', 'No events yet')">
            <template #actions>
              <UButton :label="t('admin.create', 'New event')" @click="actions.create()" />
            </template>
          </UEmpty>
        </template>
      </UTable>
      <template #footer>
        <TablePagination v-model:page="page" v-model:page-size="pageSize" :total="data.total" />
      </template>
    </TableCard>
  </div>
</template>

A table in a tab or on a detail page sits in TableCard :title, with #actions for its primary action. That gives it the same header as SectionCard. Never wrap a UTable in a flush SectionCard. Pass tableUi({ tr: '' }) when the rows open nothing.

A short list that loads in one go sorts on the client. Bind useTableSort()'s sorting with v-model:sorting, and leave out useTableQuery and TablePagination.

Detail pages

A detail page shows one record. It has:

  • A heading (PageHeading) with a breadcrumb back to the list, such as [Users, Ada Lovelace]. A status #badge goes in it when the record has one. The primary actions go in #actions, with an outline RowMenu last.
  • Sections. Each section is a SectionCard. Key facts are a DescriptionList.
  • Save buttons per section. A form inside a section puts its Save button in the section's #footer, as type="submit" with form="<id>".
  • Tabs. Sections that other modules can extend are WidgetTabs. Sections the page owns are PageTabs. See Tabs.
  • A not-found state. A record that does not exist shows a UEmpty with a link back to the list, never a blank page. See Failed, forbidden and not found.

Inline settings forms use horizontal rows. Each field is a FormRow: a UFormField orientation="horizontal" with the label and its description hint on the left and the control on the right. Below sm the two stack. The rows sit in a parent with divide-y divide-default.

ControlWidth
Short controlclass="w-full sm:max-w-sm"
Long text, such as a textarea or an editorclass="w-full"
Any control in the account areaclass="w-full"

In the account area the column is narrow. A capped input would stop short of the section's footer actions, and the right edge would go ragged. Slideover forms stay vertical.

<SectionCard :title="t('password.title', 'Password')">
  <UForm id="change-password" :schema="schema" :state="state" class="flex flex-col divide-y divide-default" @submit="save">
    <FormRow name="newPassword" :label="t('new', 'New password')" :description="t('newHint', 'At least 8 characters.')" required>
      <UInput v-model="state.newPassword" type="password" class="w-full sm:max-w-sm" />
    </FormRow>
  </UForm>
  <template #footer>
    <div class="flex justify-end">
      <UButton type="submit" form="change-password" :loading="busy" :label="t('save', 'Change password')" />
    </div>
  </template>
</SectionCard>

Tabs

Both tab components look the same: a bordered strip with an underline highlight. So a page never looks different because of how it is built.

UseWhen
WidgetTabsOther modules should be able to add sections to the page. An example is the user detail page, where Payments or Membership appear when those modules are installed. The active tab is in ?tab=.
PageTabsThe page fixes its own sections, such as a form's builder, settings and access. Pass items with to for link tabs, where each tab is a route, or v-model for state tabs.

WidgetTabs comes from the Widget module, and other modules add tabs with extendTabs.

Light entities

An entity with little of its own, such as a unit or an application, opens in a slideover instead of on a detail page. The open record lives in the URL query, for example ?application=<id>. So ⌘K and other pages can link straight to it.

Divided list

A short list of records that is not a table, such as passkeys, documents or applications, is a list inside a card:

  • The card is a SectionCard flush around a ul.divide-y.divide-default.
  • Each row is li.flex.items-center.gap-3.px-4.py-3.
  • A row holds a leading icon or avatar, a min-w-0 flex-1 text block, and a trailing button or RowMenu.
  • The empty state goes inside the card, as UEmpty variant="naked".
<SectionCard flush :title="t('documents.title', 'Documents')">
  <ul class="divide-y divide-default">
    <li v-for="doc in documents" :key="doc.id" class="flex items-center gap-3 px-4 py-3">
      <UIcon :name="doc.icon" class="size-5 shrink-0 text-muted" />
      <span class="min-w-0 flex-1 truncate font-medium text-highlighted">{{ doc.label }}</span>
      <UButton :to="doc.path" color="neutral" variant="outline" :label="t('documents.download', 'Download')" />
    </li>
  </ul>
</SectionCard>

Page-wide draft

Most pages save per section: the form sits in a SectionCard, and Save is in its #footer. One case is different: a single draft that spans the whole page and that several cards edit, such as the form builder. Save then stays in the heading's #actions, next to the status. The page still has one editor and one Save, never a Save per card.

Overview pages

An overview shows numbers first, then what needs a decision:

  • Each number is a StatCard that links (to) to the list it summarises.
  • Then come ListCards for what needs a decision, such as recent items or upcoming events.

The admin dashboard and the account overview are both built this way, from widget areas.

Empty states

An empty state says what the thing is and offers the single action that gets you going. An example is "No events yet" with a "New event" button.

  • It is a UEmpty. There is no separate empty-state component.
  • Inside a card, use variant="naked".
  • When a prerequisite is missing, point to it instead of the action, for example "Add a ticket type first".

Failed, forbidden and not found

A page whose data did not load shows a UEmpty as the page body. It sits inside the page root, below the heading when the heading does not depend on the data. There are three cases. All use the default variant, because they replace the body.

CaseIconTitle and descriptionAction
Failed (any other error)i-lucide-circle-alert"X could not be loaded", and errorMessage(error, fallback)A neutral outline "Try again" (i-lucide-rotate-ccw) that refreshes
Forbidden (401, 403)i-lucide-lock"You are not allowed to see X"None
Not found (404)The entity's own icon, such as i-lucide-user-x"X not found", and a hint such as "It may have been deleted."A neutral outline "All X" that links to the list
<UEmpty
  v-if="error && !data"
  :icon="forbidden ? 'i-lucide-lock' : 'i-lucide-circle-alert'"
  :title="forbidden ? t('admin.forbidden', 'You are not allowed to see events') : t('admin.loadFailed', 'Events could not be loaded')"
  :description="forbidden ? undefined : errorMessage(error, t('admin.loadFailedHint', 'Try again in a moment.'))"
  :actions="forbidden ? [] : [{ label: t('retry', 'Try again'), icon: 'i-lucide-rotate-ccw', color: 'neutral', variant: 'outline', onClick: () => refresh() }]"
/>
Check error && !data, not error alone. Otherwise a failed background refresh replaces rows that are already on screen.

Loading

A page never says "nothing here" before it knows. An empty state, a "join" hero or a "not a member" message is a claim about the data. So it renders only after a load succeeded and found nothing.

  • Pages await their data. Await the fetch that loads the page's main data. That fetch is useFetch, useCachedFetch or a module composable that can be awaited, such as useMyTickets(). The server then renders the page with its data, and hydration shows the same thing. Never leave the main data un-awaited with a default such as default: () => [].
  • Data that should survive navigation uses useCachedFetch. See UI. Plain useFetch data is dropped when the page unmounts. Going back then flashes the empty state while it refetches.
  • Widgets may load lazily. Only widgets, and secondary parts of a page that must not hold up the render, load without waiting. They use useLazyFetch or an un-awaited fetch. While they load, they show a skeleton: each card component takes a loading prop for it (SectionCard, ListCard, TableCard and StatCard).
  • Skeletons keep the size. A skeleton has the size of what it replaces, so the layout does not jump when the data arrives.
Gate on "pending and nothing to show": status === 'pending' && !data, or useCachedFetch's loading. Gating on status === 'pending' alone replaces the current content during a background refresh.
Errors are not empty. Do not swallow a failed request into default data. Check error and show a UEmpty with the message, or a toast for a background refresh. Otherwise "you have no tickets" shows because a request failed.

Next steps

Copyright © 2026