UX Patterns
Every module that ships screens follows the same handful of patterns, so the app feels like one product no matter which
modules are installed. The building blocks live in @frigear-nu/ui; the admin shell is
@frigear-nu/admin and the account shell is @frigear-nu/account. This page is
the rulebook: read it before adding a page, and follow it when reviewing one.
app.config.ts) says;
the shared components only add structure and spacing.Layout and navigation
There are three areas, each with its own layout. A page picks its area by path, not by importing a layout.
| Area | Paths | Layout | What it is |
|---|---|---|---|
| Public site | everything else | default from the base layer | Header and footer. Public pages use UContainer > UPage. |
| Admin back office | /admin/** | admin (Admin) | Dashboard shell: a collapsible sidebar with grouped navigation, a ⌘K palette and the account menu in the sidebar footer. |
| Account area | /account/** | account (Account) | Inside the site layout, with a persistent left account navigation. |
Admin back office
The sidebar navigation is grouped, and each group reads its own menu path:
| Group | Menu path | Put here |
|---|---|---|
| Overview | admin.nav.overview | The dashboard. |
| Manage | admin.nav (root) | The things staff manage every day: users, events, forms. |
| Settings | admin.nav.settings | Configuration: OAuth applications, integrations. |
New features go into one of these groups. A new group needs a reason, such as a whole new class of screens that does not read as "managing" or "settings". The ⌘K palette lists the same groups, so a page that is in the navigation is also searchable. Colour mode is not in the chrome: it lives in the account menu under Appearance.
Account area
/account/** renders in the account layout: the site header and footer stay, and the page body gets a left
navigation (UPageAside) built from the account menu. On narrow screens the navigation becomes a tab strip above
the content. /account itself is an overview: a summary card, the account.overview widget area
and shortcuts to the other pages. A module adds a page by registering the route and an item in the account
menu; it never draws its own account navigation.
Public site
Public pages (events, forms, content) use UContainer > UPage with UPageHeader for the title and UPageSection for
bands. App pages, meaning everything in the admin and account areas, use PageHeading instead. Do not mix them: the
public header is a marketing-sized hero, PageHeading is a working title with actions.
Pages
Lists
A top-level entity gets a list page and a detail page.
PageHeadingwith the entity's name and the primary action ("Add user", "New event") in#actions.PageToolbarwith search and filters once the list is longer than about eight rows; leave it out for short lists.- A
UTableinsideTableCard, withtableUi()for the shared table styling: small muted headers, compact rows on desktop, taller rows on mobile. Rows are clickable and open the detail page. Long lists addstickyandtableUi({}, { sticky: true })so the header stays in view while the table scrolls inside its card. - A
RowMenuin the last column for the row's actions (see Forms and actions). - Sortable columns use
useTableSort().header(id, label): a ghost button that cycles ascending, descending, unsorted. TablePaginationgoes in the card's#footer("21–40 of 132", rows per page, pages). Server-paginated lists keep page, page size, search, sort and filters in the URL withuseTableQuery, so a filtered list can be linked and survives a reload.- An empty list is a
UEmpty(empty states), not an empty table.
Loading never blanks a list that is already on screen. TableCard :loading shows skeleton rows on the first load
only; every load after that (the next page, a new sort, a search) passes :refreshing, which keeps the current rows
and dims them until the new ones arrive.
Cells follow the same conventions everywhere:
- The first column is a
TableIdentity: the name, with a muted second line (an email, a slug) and an optional avatar or icon. Badges that belong to the name go in its#badgesslot. - Numbers are right-aligned with tabular figures (
meta: numericColumn); dates useuseFormat()in atabular-numsspan. - A missing value is
emptyCell(), a dimmed dash, not an empty cell. - Status badges are
variant="subtle"withstatusColor.
<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/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 short list that is loaded in one go sorts on the client: bind useTableSort()'s sorting with
v-model:sorting and leave out useTableQuery and TablePagination.
Pages have no wrapper component: the page root is a plain <div class="flex flex-col gap-4">.
Detail pages
PageHeadingwith abreadcrumbback to the list ([Users, Ada Lovelace]), a status#badgewhen the record has one, and the primary actions (outlineRowMenulast) in#actions.- Sections are
SectionCards. A form inside a section puts its Save button in the section's#footer(type="submit"withform="<id>"). Key facts are aDescriptionList. - Sections other modules can extend are
WidgetTabs(extendTabs); sections the page owns arePageTabs. See which tabs to use. - A record that does not exist shows a
UEmptywith a link back to the list, not a blank page.
Tabs
Both tab components share one look (a bordered strip with an underline highlight), so a page never looks different because of how it is built.
| Use | When |
|---|---|
WidgetTabs | Other modules should be able to add sections to the page, such as the user detail page, where Payments or Membership appear when those modules are installed. The active tab is in ?tab=. |
PageTabs | The sections are fixed by the page itself (a form's builder, settings, access). Pass items with to for link tabs (each tab is a route), or v-model for state tabs. |
Light entities
An entity with little of its own (a unit, an application) opens in a slideover instead of getting a detail page. The
open record lives in the URL query (?application=<id>), so ⌘K and other pages can deep-link to it.
Overview pages
An overview shows numbers as StatCards, each linking (to) to the list it summarises, then ListCards for what
needs a decision: recent items, upcoming events. The admin dashboard and the account overview are both built this way
from widget areas.
Empty states
An empty state is a UEmpty that says what the thing is and offers the single action that gets you going: "No events
yet" and a "New event" button. Inside a card use variant="naked". When a prerequisite is missing, point to it instead
of the action ("Add a ticket type first"). There is no separate empty-state component.
Forms and actions
Create and edit
- Create and edit forms open in a
FormSlideovercontaining aUFormwith a zod schema. Short yes/no choices are a modal (UModal). Never inline a create form next to a table. - Open forms through
useOverlay(); the overlay resolves with the saved record (orfalsewhen cancelled), so the caller can navigate to it. - Cancel is
color="neutral" variant="outline"and the primary action comes last.FormSlideoverdoes this for you. - Hints belong in
UFormField'sdescription, not in extra paragraphs.
<!-- EventFormSlideover.vue -->
<script setup lang="ts">
const props = defineProps<{ event?: EventRow }>()
const emit = defineEmits<{ close: [saved: EventRow | false] }>()
const t = useModuleT('events')
const { busy, run } = useAction()
const state = reactive({ title: props.event?.title ?? '' })
const save = () => run(async () => {
const saved = await $fetch<EventRow>(props.event ? `/api/events/${props.event.id}` : '/api/events', {
method: props.event ? 'PATCH' : 'POST',
body: state,
})
emit('close', saved)
}, { success: t('admin.saved', 'Event saved') })
</script>
<template>
<FormSlideover
form-id="event-form"
:title="event ? t('admin.edit', 'Edit event') : t('admin.create', 'New event')"
:submit-label="event ? t('admin.save', 'Save event') : t('admin.create', 'New event')"
:loading="busy"
@close="emit('close', false)"
>
<UForm id="event-form" :state="state" class="flex flex-col gap-4" @submit="save">
<UFormField name="title" :label="t('admin.title', 'Title')" :description="t('admin.titleHint', 'Shown on the public page.')">
<UInput v-model="state.title" />
</UFormField>
</UForm>
</FormSlideover>
</template>
One actions composable per entity
Every entity has a use<Entity>Actions() composable (useUserActions, useEventActions, useFormActions) that
owns create, edit and remove, the toasts and the refresh afterwards. It returns menu(record) for the row menu, so
every list and detail page offers the same actions in the same order. Pages stay thin. It lives in the module that owns
the entity, not in @frigear-nu/ui.
// composables/useEventActions.ts
export function useEventActions() {
const t = useModuleT('events')
const overlay = useOverlay()
const confirm = useConfirmDialog()
const toast = useToast()
const { localize } = useLocaleLink()
async function create() {
const saved = await overlay.create(EventFormSlideover).open()
if (saved) await navigateTo(localize(`/admin/events/${saved.id}`))
}
async function remove(event: EventRow) {
const confirmed = await confirm({
title: t('admin.deleteTitle', 'Delete {name}?', { name: event.title }),
description: t('admin.deleteBody', 'Its tickets and attendees are deleted too. This cannot be undone.'),
confirmText: t('admin.delete', 'Delete event'),
destructive: true,
})
if (!confirmed) return
try {
await $fetch(`/api/events/${event.id}`, { method: 'DELETE' })
toast.add({ title: t('admin.deleted', 'Event deleted'), description: event.title })
await refreshNuxtData('events')
}
catch (e) {
toast.add(errorToToast(e))
}
}
const menu = (event: EventRow) => [
[{ label: t('open', 'Open'), icon: 'i-lucide-arrow-right', to: localize(`/admin/events/${event.id}`) }],
[{ label: t('admin.edit', 'Edit event'), icon: 'i-lucide-pencil', onSelect: () => overlay.create(EventFormSlideover).open({ event }) }],
[{ label: t('admin.delete', 'Delete event'), icon: 'i-lucide-trash', color: 'error' as const, onSelect: () => remove(event) }],
]
return { create, remove, menu }
}
Row actions
Row actions go in a RowMenu (the ⋯ button) at the end of the row, with groups in this order: Open first, then
the edits, then the destructive action in its own group, coloured red. There are no loose icon buttons in rows. Pass
subject so the button's accessible name reads "Actions for Ada Lovelace".
Destructive actions
Destructive actions confirm with useConfirmDialog({ destructive: true }), never window.confirm. The title asks about
the record by name ("Delete Spring Gala?"), the description says what else is affected ("Its tickets and attendees are
deleted too"), and the confirm button names the verb and the object ("Delete event").
Feedback and errors
- A successful action is a toast with a title and, when useful, the record's name as the description.
- A failed action is a toast built with
errorToToast(e), oruseErrorToast()when you want a ready-made function. They read the server's message from the usual places and fall back to a translated generic message. - Page-level problems that block the page (not found, forbidden) use
UEmpty, not a toast. useAction()wraps the common "busy, try, toast on error" block; do not hand-write it again.
Deep links instead of state hand-offs
A page that needs context from another page reads it from the URL or opens the right form directly. "Add ticket type"
on an event opens the form with the event preselected; a ⌘K entry links to ?application=<id>. Do not pass state
through stores or history.state.
Text
- Every string goes through
useModuleT('<namespace>')with an English fallback, and the module shipsdaandenJSON (see Module Kit). Shared strings (Open, Edit, Delete, "Not set") come from@frigear-nu/ui'spatterns.*keys; see overriding them. - Buttons and menu items name the object: "Add unit", "Delete application", "Revoke token". Never bare "Add", "Delete" or "Save" unless the context is a section that is clearly about one thing.
- Format dates and money through
useFormat()so they follow the active locale; do not calltoLocaleStringorIntldirectly. - Build links with
useLocaleLink().localizeso a visitor on/da/...stays under their locale.
Checklist for a new page
- Pick the area (admin, account or public) and register the route; add one item to the matching menu.
- Use the heading for the area:
PageHeadingin app areas,UPageHeaderon public pages. - Lists are
TableCardplusUTable, with aRowMenuand aUEmpty; details areSectionCards with a breadcrumb. - Writes go through a
use<Entity>Actions()composable and aFormSlideover; destructive ones confirm. - Strings go through
useModuleTwithdaandenJSON; buttons name the object. - Check it in light and dark, at desktop width and at 375px.
Where things live
| Need | Where |
|---|---|
Components, useConfirmDialog, useErrorToast, useFormat, statusColor, tableUi | @frigear-nu/ui |
| Admin shell, navigation groups, dashboard areas | @frigear-nu/admin |
| Account shell, account menu, overview widgets | @frigear-nu/account |
| Extensible tabs and widget areas | @frigear-nu/widget |
Navigation menus (admin.nav.*, account, main) | @frigear-nu/menu |