Theming
The theme engine turns three brand colors into CSS variables at runtime, and the rest of the look is tokens on top of them.
<script setup lang="ts">
import type { Shade } from '@core/theme/colorGenerator'
import { generateShades } from '@core/theme/colorGenerator'
import { useTheme } from '@core/theme/useTheme'
import SaRating from '@shared/components/common/SaRating.vue'
import { Badge } from '@shared/components/ui/badge'
import { Button } from '@shared/components/ui/button'
import { Progress } from '@shared/components/ui/progress'
import { Switch } from '@shared/components/ui/switch'
import { computed, ref } from 'vue'
const STOPS: Shade[] = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950]
const swatches = ['#1DB9C3', '#4f46e5', '#059669', '#e11d48', '#d97706', '#334155']
const picked = ref('#1DB9C3')
const { isDark } = useTheme()
const vars = computed<Record<string, string>>(() => {
const ramp = generateShades(picked.value)
const out: Record<string, string> = {}
for (const stop of STOPS)
out[`--sa-primary-${stop}`] = ramp[stop]
out['--primary'] = isDark.value ? ramp[500] : ramp[600]
return out
})
</script>
<template>
<div class="flex flex-col gap-5">
<div class="flex flex-wrap items-center gap-2">
<button
v-for="hex in swatches"
:key="hex"
type="button"
class="size-7 rounded-full border border-border"
:style="{ background: hex, outline: picked === hex ? '2px solid currentColor' : 'none', outlineOffset: '2px' }"
:aria-label="hex"
:aria-pressed="picked === hex"
@click="picked = hex"
/>
<code class="text-xs text-muted-foreground">{{ picked }}</code>
</div>
<div :style="vars" class="flex flex-col gap-4 rounded-lg border border-border p-4">
<div class="flex flex-wrap items-center gap-3">
<Button size="sm">
Save changes
</Button>
<Button size="sm" variant="outline">
Cancel
</Button>
<Badge>Active</Badge>
<Switch :model-value="true" />
<SaRating :model-value="4" readonly />
</div>
<Progress :model-value="64" />
<div class="flex flex-wrap gap-1">
<span
v-for="stop in STOPS"
:key="stop"
class="h-6 w-6 rounded"
:style="{ background: `var(--sa-primary-${stop})` }"
:title="`${stop}`"
/>
</div>
</div>
</div>
</template>Pick a color and the buttons, badge, switch, rating and progress bar follow it, along with the eleven swatches under them. That is the same ramp the app runs on. This preview writes the variables on its own wrapper so it re-themes a corner of a page; in an app you set theme.primary on the config and the whole shell follows.
The ramp
generateShades takes a hex, reads its hue and saturation, and holds both while walking a fixed lightness table:
| Stop | 50 | 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 950 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| Lightness | 98 | 96 | 91 | 84 | 64 | 47 | 34 | 26 | 18 | 11 | 4 |
import { generateShades } from '@core/theme/colorGenerator'
const ramp = generateShades('#1DB9C3')The result is written to <html> as --sa-primary-50 through --sa-primary-950, and the same for secondary and accent. Because the table is fixed, any brand color lands with the same contrast relationships between its stops.
The module also exports hexToHsl, hslToHex, generateComplementary (hue plus 180 degrees), generateAnalogous and generateDarkVariant.
From ramp to component
Components never name a ramp stop. They use the semantic layer:
:root {
--primary: var(--sa-primary-600);
}
.dark {
--primary: var(--sa-primary-500);
}Dark mode takes a lighter stop on purpose: 600 on a near-black ground is too dim. Every other semantic token — --background, --card, --muted, --border, --destructive and the rest — is defined the same way, which is why one color change moves the whole interface and nothing is hard-coded.
useTheme
import { useTheme } from '@core/theme/useTheme'
const {
setPrimary,
setSecondary,
setAccent,
setTheme,
toggleDark,
isDark,
currentTheme,
exportTheme,
importTheme,
} = useTheme()| Member | Type | Description |
|---|---|---|
setPrimary(hex) | function | Writes the primary ramp to <html>. |
setSecondary(hex) | function | The secondary ramp. |
setAccent(hex) | function | The accent ramp. |
setTheme(payload) | function | Any of { primary, secondary, accent } at once. |
toggleDark() | function | Flips the dark class on <html>. |
isDark | Readonly<Ref<boolean>> | |
currentTheme | ComputedRef<ThemeSnapshot> | The three colors, the mode, the density and the type preset. |
exportTheme() | string | That snapshot as JSON. |
importTheme(json) | function | Applies a snapshot. Throws a TypeError on invalid JSON or a non-object. |
It is a module-scope singleton, so every caller sees the same state.
Do not call the setters from a component. Set the config value and let the wiring call them:
import { useConfig } from '@core/config/useConfig'
useConfig().mergeConfig({ theme: { primary: '#7c3aed' } })Calling setPrimary directly changes the screen without telling the store, so the next reload undoes it.
Dark mode
useColorMode from VueUse, configured to write the class dark on <html> and store the choice under sa-color-mode. It is not part of SmartAdminConfig, which is why the customizer's copied snippet mentions the mode in a comment instead of a key.
features.darkMode enforces rather than hides: switch it off while the app is dark and the app returns to light, because a dark app with no way back is the worse failure.
The app background
The shell's ground is its own pair of values, one per mode:
theme: {
background: { light: '#f1f2f7', dark: '#0e1013' },
}They are written as --sa-app-bg-light and --sa-app-bg-dark, read by .sa-app-bg on every layout root. Cards stay bg-card on top, which is what gives the floating-card look. The same two values are mirrored as bootstrap defaults in tokens.css, so the paint before Vue mounts already matches.
Soft and Airy
The shadow scale is deliberately shallow, so cards read as floating rather than cut out:
| Token | Light |
|---|---|
--sa-shadow-xs | 0 1px 2px rgb(16 24 40 / 0.04) |
--sa-shadow-sm | 0 2px 4px rgb(16 24 40 / 0.05) |
--sa-shadow-md | 0 4px 12px rgb(16 24 40 / 0.06) |
--sa-shadow-lg | 0 8px 24px rgb(16 24 40 / 0.08) |
--sa-shadow-xl | 0 16px 40px rgb(16 24 40 / 0.10) |
Dark mode keeps the same geometry with far more opacity, from 0.20 to 0.50, because a 4% shadow is invisible on a near-black ground.
Alongside them: --sa-surface-overlay for popovers and dialogs, --sa-text-muted for secondary text, and the .sa-shadow-* utility classes with .sa-shadow-none as the escape hatch.
Typography
Three presets, each a base size and a ratio. The seven named sizes derive as base × factor^offset, where base is offset zero:
| Preset | Base | Factor |
|---|---|---|
compact | 13px | 1.2 |
default | 16px | 1.2 |
comfortable | 16px | 1.333 |
The offsets run from xs at two steps down to 3xl at four steps up, and the result is written as --sa-text-xs through --sa-text-3xl in rem.
useConfig().mergeConfig({ typographyScale: 'comfortable' })Density
compact, default and comfortable, applied as a class on <html>: sa-density-compact and so on. Density changes spacing and control heights; the type scale is a separate axis, so a compact table can keep comfortable text.
useConfig().setUi('density', 'compact')Radius
One variable, --radius on <html>, and every corner derives from it.
| Value | --radius |
|---|---|
none | 0rem |
small | 0.375rem |
medium | 0.625rem |
large | 1rem |
Fonts
Eighteen families in three slots: primary for the interface, arabic for right-to-left text and mono for code. Ten Latin families, three monospace, five Arabic. A font outside the list is a customFonts entry with a name, a stylesheet URL and optionally the weights to load; custom fonts register before the font watchers run, so a persisted one is there at the first paint.
Presets
Six, each a color trio plus a radius and a density:
| Preset | Primary | Secondary | Accent |
|---|---|---|---|
| Teal | #1DB9C3 | #71717a | #0ea5e9 |
| Indigo | #4f46e5 | #64748b | #8b5cf6 |
| Emerald | #059669 | #6b7280 | #10b981 |
| Rose | #e11d48 | #71717a | #f43f5e |
| Amber | #d97706 | #78716c | #f59e0b |
| Slate | #334155 | #64748b | #0ea5e9 |
A preset is a partial config, so it only sets the keys it names: picking one keeps your fonts, layout and language.
Where to go next
- Global config — every theme key and its default.
- Theming guide — the task view: change the brand color and ship it.