Skip to content

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:

SurfaceLightDark
Dashboard groundbg-accentedbg-default
Dashboard panelbg-elevatedbg-elevated/25
Cardbg-mutedbg-elevated/50
Modal, slideover, drawerbg-mutedbg-elevated
Inputbg-default (white)bg-inverted/5
Menu, popover, tooltip, toastbg-default + ringneutral-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>
PropWhat it does
title, descriptionThe heading. With neither, and no #heading, #actions or #toolbar, no header is drawn
fillCaps the card at the panel's height and scrolls its body. Only for a page that is one list
bleedRemoves 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's submit() from there rather than posting the state yourself, so the schema still runs.
  • Pass #footer only 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 SettingRow goes 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 is UEmpty with variant="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:

ComponentStands in forProps
SkeletonCardA DashboardSection with rowsrows (2), action
SkeletonRowsList rows, with an icon or switch before and a control afterrows (3), leading, trailing, lines
SkeletonFormForm fieldsfields (2), hint
SkeletonTableThe first page of a UTablerows (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

KeyWhereDefaultWhat it does
ui.colors.primaryapp/app.config.ts'zinc'Brand palette
ui.colors.neutralapp/app.config.ts'neutral'Greys every surface and override is built from
ui.button.defaultVariantsapp/app.config.tscolor: 'neutral', size: 'lg'What a bare UButton looks like
toasterapp/app.config.tsbottom right, 3 at most, collapsed, no progress barPassed to UApp
--font-sansapp/assets/css/main.css'Inter'The app's typeface
--color-chart-1 to --color-chart-8app/assets/css/main.cssblue, orange, aqua, yellow, magenta, green, violet, redChart series colours, separate values per theme
--color-chart-surfaceapp/assets/css/main.cssthe card's colourGap between stacked segments
ogImage.defaults.fontsnuxt.config.tsInter 400, 500, 600Fonts 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 animation shorthand with a 0s duration, which breaks scroll-driven animations. Write them in longhands, as scroll-fade-y does, and check the built CSS: pnpm dev doesn't minify.
  • A mask hides everything outside an element's border box, the focus outline included. Put scroll-fade-y on a specific scroller, never on every table.
  • The dashboard darkens text-muted in 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.