useTheme
The runtime theme engine. Set a brand color and its 50–950 ramp is written to :root for every component to pick up.
vue
<script setup lang="ts">
import { defaultConfig } from '@core/config/defaultConfig'
import { useTheme } from '@core/theme/useTheme'
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 { onBeforeUnmount, onMounted } from 'vue'
const BRANDS = [
{ label: 'Teal', hex: defaultConfig.theme.primary },
{ label: 'Indigo', hex: '#4f46e5' },
{ label: 'Emerald', hex: '#059669' },
{ label: 'Rose', hex: '#e11d48' },
]
const { setPrimary, currentTheme } = useTheme()
onMounted(() => setPrimary(defaultConfig.theme.primary))
onBeforeUnmount(() => setPrimary(defaultConfig.theme.primary))
</script>
<template>
<div class="flex flex-col gap-5">
<div class="flex flex-wrap items-center gap-2">
<Button
v-for="brand in BRANDS"
:key="brand.hex"
size="sm"
variant="outline"
:aria-pressed="currentTheme.primary === brand.hex"
@click="setPrimary(brand.hex)"
>
{{ brand.label }}
</Button>
</div>
<div 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>
<Badge>Active</Badge>
<Switch :model-value="true" />
</div>
<Progress :model-value="64" />
<p class="sa-text-xs text-muted-foreground">
primary {{ currentTheme.primary }} · density {{ currentTheme.density }} · dark {{ currentTheme.isDark }}
</p>
</div>
</div>
</template>Import
ts
import { useTheme } from '@core/theme/useTheme'Signature
ts
function useTheme(): {
setPrimary: (hex: string) => void
setSecondary: (hex: string) => void
setAccent: (hex: string) => void
setTheme: (payload: { primary?: string, secondary?: string, accent?: string }) => void
toggleDark: () => void
isDark: Readonly<Ref<boolean>>
currentTheme: ComputedRef<ThemeSnapshot>
exportTheme: () => string
importTheme: (json: string) => void
}Returns
| Member | Type | Description |
|---|---|---|
setPrimary | (hex: string) => void | Generates the 50–950 ramp and writes --sa-primary-{stop} on :root. |
setSecondary | (hex: string) => void | The same for --sa-secondary-{stop}. |
setAccent | (hex: string) => void | The same for --sa-accent-{stop}. |
setTheme | (payload) => void | Any of the three at once. A key you leave out is left alone. |
toggleDark | () => void | Flips between light and dark. |
isDark | Readonly<Ref<boolean>> | Whether dark is on. |
currentTheme | ComputedRef<ThemeSnapshot> | The three colors, isDark, the density and the typography preset. |
exportTheme | () => string | That snapshot as JSON. |
importTheme | (json: string) => void | Applies a snapshot. Throws TypeError on invalid JSON or a non-object. |
ThemeSnapshot
| Key | Type |
|---|---|
primary | string |
secondary | string |
accent | string |
isDark | boolean |
density | 'compact' | 'default' | 'comfortable' |
typographyPreset | 'compact' | 'default' | 'comfortable' |
Example
vue
<script setup lang="ts">
import { useTheme } from '@core/theme/useTheme'
import { ref } from 'vue'
const { setPrimary, toggleDark, isDark, exportTheme, importTheme } = useTheme()
const saved = ref('')
function save() {
saved.value = exportTheme()
}
function restore() {
if (saved.value)
importTheme(saved.value)
}
</script>
<template>
<div class="flex flex-wrap items-center gap-3">
<input type="color" value="#1DB9C3" @input="setPrimary(($event.target as HTMLInputElement).value)">
<button type="button" @click="toggleDark()">
{{ isDark ? 'Light' : 'Dark' }}
</button>
<button type="button" @click="save()">
Save theme
</button>
<button type="button" :disabled="!saved" @click="restore()">
Restore
</button>
</div>
</template>Notes
- It is a module-scope singleton. Every caller gets the same state, so a color set in Settings is the color a widget three routes away reads.
- The ramp is generated by
generateShades, so one hex produces all eleven stops. You never write the stops yourself. isDarkis backed by VueUse'suseColorMode, which writes thedarkclass on<html>and persists the choice undersa-color-mode. The colors themselves are not persisted here —useConfigstores them and the config wiring replays them on boot.importThemeapplies density and the typography preset as well, by callinguseDensity().setDensityanduseTypography().setScale. A key with the wrong type is skipped rather than throwing.- The setters no-op safely when there is no
document, so importing this on the server does not break.
Related
useConfig— where the colors are stored and persisted.useDensityanduseTypography— the other two halves of a snapshot.useThemeProvider— the per-tenant palette at startup.- Theming — the tokens, the presets and the ramps.