Content and look
Theme
The kit's look is Nuxt UI, adjusted in
app/app.config.ts(Licensees only) and a few tokens in
app/assets/css/main.css(Licensees only).
The dashboard is built from a handful of kit components on top of it. You work
here when you change the brand colour or font, or build a dashboard page that
should match the kit's.
How it works
ui.colors in app.config.ts picks the Nuxt UI palettes. Every override below
it is written in Nuxt UI's tokens (bg-elevated, bg-inverted/5,
--ui-border) rather than fixed shades, so changing a palette re-tints
everything.
Surfaces get lighter as they come towards the reader, in both themes:
| Surface | Light | Dark |
|---|---|---|
| Dashboard ground | bg-accented | bg-default |
| Dashboard panel | bg-elevated | bg-elevated/25 |
| Card | bg-muted | bg-elevated/50 |
| Modal, slideover, drawer | bg-muted | bg-elevated |
| Input | bg-default (white) | bg-inverted/5 |
| Menu, popover, tooltip, toast | bg-default + ring | neutral-800 mixed 70% with neutral-700 |
The rest of app.config.ts follows from that table. Dark inputs, outline
buttons and highlighted menu rows use a white wash (bg-inverted/5 or /10),
so they sit a step above whatever holds them. Rules inside cards, modals,
slideovers and drawers redefine --ui-border as white at 10% in the dark
theme, where the default border colour matches the surface. Overlays dim with
bg-black/60. Buttons default to color: 'neutral' and size: 'lg', and
disabled controls sit at 50% opacity.
Change the brand colour
export default defineAppConfig({
ui: {
colors: {
primary: 'indigo',
neutral: 'slate',
},
},
})
The kit ships primary: 'zinc' and neutral: 'neutral'. Nuxt UI paints active
links, focus rings and anything given color="primary" in the primary palette.
Solid buttons default to neutral, black on light and white on dark, so give
color="primary" to a button that should carry the brand.
A light primary such as orange or amber needs dark text on its solid fills.
Add it per component under ui:
button: {
compoundVariants: [
{ color: 'primary', variant: 'solid', class: 'text-neutral-900' },
],
},
badge: {
compoundVariants: [
{ color: 'primary', variant: 'solid', class: 'text-neutral-900' },
],
},
Change the font
Rename the family in main.css:
@theme static {
--font-sans: 'Geist', ui-sans-serif, system-ui, sans-serif;
}
@nuxt/fonts reads that name, then downloads and self-hosts the family. The Open
Graph image has its own copy: change ogImage.defaults.fonts in
nuxt.config.ts(Licensees only) (['Inter:400', 'Inter:500', 'Inter:600'])
and the font-family in
app/components/OgImage/Default.takumi.vue(Licensees only).
The heading letter-spacing in main.css is tuned for Inter, so check it
against the new face.
Build a dashboard section
DashboardSection(Licensees only) is the card every
dashboard page is made of. The heading sits inside the card, with the action at
its end. SettingRow(Licensees only) puts one setting's
label and help on the left and its control on the right:
<template>
<DashboardSection
:title="$t('reports.title')"
:description="$t('reports.description')"
>
<template #actions>
<UButton :label="$t('reports.export')" @click="exportReports" />
</template>
<SettingRow
:label="$t('reports.weekly.label')"
:help="$t('reports.weekly.help')"
>
<USwitch v-model="weekly" :aria-label="$t('reports.weekly.label')" />
</SettingRow>
</DashboardSection>
</template>
| Prop | What it does |
|---|---|
title, description | The heading. With neither, and no #heading, #actions or #toolbar, no header is drawn |
fill | Caps the card at the panel's height and scrolls its body. Only for a page that is one list |
bleed | Removes the body's padding, so a table runs edge to edge |
tone="danger" | A red tint and border for actions that can't be undone |
The slots are #heading (replaces title and description), #meta (beside the
title, such as a count badge), #actions, #toolbar, #footer and the
default body.
- Put the section's action in
#actions, a form's Save included. Call the form'ssubmit()from there rather than posting the state yourself, so the schema still runs. - Pass
#footeronly when there is something in it. A passed slot draws the footer even when it renders nothing. - A page draws no wrapper of its own. The shell already stacks sections with a
gap and caps them at
lg:max-w-3xl. - The body is a container, and
SettingRowgoes two-column at@2xl, so rows respond to the card's width when the notifications drawer narrows the panel. - A destructive action on a list row is
color="error" variant="ghost". An empty state inside a card isUEmptywithvariant="naked".
Build a page that is one list
Give the section fill and bleed, put the filters in #toolbar, and page
with DashboardPager(Licensees only) in the footer.
This is the shape of
app/pages/dashboard/customers.vue(Licensees only):
<template>
<DashboardSection
fill
bleed
:title="$t('reports.title')"
:description="$t('reports.description')"
>
<template #toolbar>
<UInput v-model="search" type="search" icon="i-lucide-search" />
</template>
<SkeletonTable v-if="isPending" :rows="perPage" :columns="columns.length" />
<UTable
v-else
class="min-h-0 flex-1"
:data="data?.rows ?? []"
:columns="columns"
:loading="asyncStatus === 'loading'"
:ui="{ root: 'scroll-fade-y' }"
/>
<template v-if="data && data.total > data.perPage" #footer>
<DashboardPager
v-model:page="page"
:total="data.total"
:per-page="data.perPage"
/>
</template>
</DashboardSection>
</template>
Inside the settings or organization shell, the page also declares
definePageMeta({ fill: true }), so the shell gives it a bounded height to
fill. The toolbar is a container too: lay filters out with @2xl: or @4xl:
variants, not viewport breakpoints.
scroll-fade-y fades a scroller's top or bottom edge only while there is more
to scroll that way. The dashboard panel's body and a fill card's body already
carry it. In a fill card the table is what scrolls, so the table's root takes
the class.
Show a placeholder while loading
Use the skeleton components, sized like what they stand in for:
| Component | Stands in for | Props |
|---|---|---|
SkeletonCard | A DashboardSection with rows | rows (2), action |
SkeletonRows | List rows, with an icon or switch before and a control after | rows (3), leading, trailing, lines |
SkeletonForm | Form fields | fields (2), hint |
SkeletonTable | The first page of a UTable | rows (5), columns (4) |
Later pages keep their rows and dim them through UTable's loading. A single
USkeleton of your own either stands in for one control, with
role="status" and :aria-label="$t('common.loading')", or carries
aria-hidden="true" inside a labelled wrapper.
test/unit/skeleton-labels.test.ts(Licensees only)
fails on one that does neither.
Add a chart
DashboardActivityCard(Licensees only) wraps
the unovis stacked bar chart with its legend, an accessible table, and the
loading, empty and failed states. Give each series the next
--color-chart-<n> slot, in order:
const series = computed<DashboardChartSeries[]>(() =>
['signIns', 'failedSignIns', 'sessions'].map((id, index) => ({
id,
label: t(`reports.series.${id}`),
color: `var(--color-chart-${index + 1})`,
})),
)
The eight slots in main.css are ordered so neighbours stay distinguishable
with colour blindness, in both themes. Keep a series on its slot when a range
leaves another series empty. The first three are distinct in any arrangement;
past three, fold the tail into one series or rely on the legend and table.
The chart renders only in the browser (DashboardStackedBarChart.client.vue),
so unovis never enters the Worker bundle. Its segment gaps are stroked in
--color-chart-surface, the card's colour, so change that token with the
card's background.
DashboardStatCard takes better: 'higher' | 'lower' | 'neither' to colour
its trend. Pass lower for a count that should fall, such as failed sign-ins,
and neither for one that is only activity.
Configuration
| Key | Where | Default | What it does |
|---|---|---|---|
ui.colors.primary | app/app.config.ts | 'zinc' | Brand palette |
ui.colors.neutral | app/app.config.ts | 'neutral' | Greys every surface and override is built from |
ui.button.defaultVariants | app/app.config.ts | color: 'neutral', size: 'lg' | What a bare UButton looks like |
toaster | app/app.config.ts | bottom right, 3 at most, collapsed, no progress bar | Passed to UApp |
--font-sans | app/assets/css/main.css | 'Inter' | The app's typeface |
--color-chart-1 to --color-chart-8 | app/assets/css/main.css | blue, orange, aqua, yellow, magenta, green, violet, red | Chart series colours, separate values per theme |
--color-chart-surface | app/assets/css/main.css | the card's colour | Gap between stacked segments |
ogImage.defaults.fonts | nuxt.config.ts | Inter 400, 500, 600 | Fonts the Open Graph renderer loads |
Good to know
- Card variants in Nuxt UI's theme are keyed by slot. Give an override an
object such as
{ root: '…' }. A plain string replaces the variant, and the card loses its ring and the rules between header, body and footer. - The production build's CSS minifier rewrites an
animationshorthand with a0sduration, which breaks scroll-driven animations. Write them in longhands, asscroll-fade-ydoes, and check the built CSS:pnpm devdoesn't minify. - A mask hides everything outside an element's border box, the focus outline
included. Put
scroll-fade-yon a specific scroller, never on every table. - The dashboard darkens
text-mutedin the light theme so it keeps 4.5:1 contrast on the grey grounds. Menus, modals and toasts are teleported outside the dashboard and keep Nuxt UI's value. - The Open Graph renderer, Takumi, has no
radial-gradient. The default template draws its glow as a blurred white disc instead.
A link with a lock opens the kit’s private repository, which every licensee can read.