Skip to content

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.

Open in demo
Open in demo

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:

Stop50100200300400500600700800900950
Lightness989691846447342618114
ts
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:

css
: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 ​

ts
import { useTheme } from '@core/theme/useTheme'

const {
  setPrimary,
  setSecondary,
  setAccent,
  setTheme,
  toggleDark,
  isDark,
  currentTheme,
  exportTheme,
  importTheme,
} = useTheme()
MemberTypeDescription
setPrimary(hex)functionWrites the primary ramp to <html>.
setSecondary(hex)functionThe secondary ramp.
setAccent(hex)functionThe accent ramp.
setTheme(payload)functionAny of { primary, secondary, accent } at once.
toggleDark()functionFlips the dark class on <html>.
isDarkReadonly<Ref<boolean>>
currentThemeComputedRef<ThemeSnapshot>The three colors, the mode, the density and the type preset.
exportTheme()stringThat snapshot as JSON.
importTheme(json)functionApplies 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:

ts
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:

ts
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:

TokenLight
--sa-shadow-xs0 1px 2px rgb(16 24 40 / 0.04)
--sa-shadow-sm0 2px 4px rgb(16 24 40 / 0.05)
--sa-shadow-md0 4px 12px rgb(16 24 40 / 0.06)
--sa-shadow-lg0 8px 24px rgb(16 24 40 / 0.08)
--sa-shadow-xl0 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:

PresetBaseFactor
compact13px1.2
default16px1.2
comfortable16px1.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.

ts
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.

ts
useConfig().setUi('density', 'compact')

Radius ​

One variable, --radius on <html>, and every corner derives from it.

Value--radius
none0rem
small0.375rem
medium0.625rem
large1rem

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:

PresetPrimarySecondaryAccent
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 ​