Dark mode
The dark theme is the dark class on <html>: grani's colors swap under it, and color-scheme follows on grani's elements, or on the whole page with grani/base.css. Four steps make it a choice of the person: let Tailwind follow the class, give the page the theme's background, set the class before the first paint, and give a switch.
Point Tailwind's dark: variant at the class, after the imports, so the app's dark: classes and grani agree:
@custom-variant dark (&:where(.dark, .dark *));Give the page the theme's background and text, which swap with it: without them the page stays white while grani's text turns light for a dark page, and a field's label all but disappears. grani/base.css darkens only the browser's own canvas, not to grani's color.
<body class="bg-bg text-fg"></body>Set the class before the first paint, in index.html, so a dark page never flashes light:
<script> let theme = null try { theme = localStorage.getItem('theme') // may be blocked } catch {} const dark = theme === 'dark' || (theme !== 'light' && matchMedia('(prefers-color-scheme: dark)').matches) document.documentElement.classList.toggle('dark', dark)</script>
Keep the choice in a small composable. System follows the device and changes with it; Light and Dark stay until the person picks again. The composable is for the browser only: it calls matchMedia as the module loads, so an app rendered on a server imports it from code that runs in the browser.
import { ref, watchEffect } from 'vue' export type Theme = 'system' | 'light' | 'dark' // Blocked storage keeps the choice for this visit only.const storage = { get: () => { try { return localStorage.getItem('theme') } catch { return null } }, set: (value: Theme) => { try { if (value === 'system') localStorage.removeItem('theme') else localStorage.setItem('theme', value) } catch {} },} const stored = storage.get()const theme = ref<Theme>(stored === 'light' || stored === 'dark' ? stored : 'system')const device = matchMedia('(prefers-color-scheme: dark)')const deviceDark = ref(device.matches)device.addEventListener('change', (e) => (deviceDark.value = e.matches)) watchEffect(() => { const dark = theme.value === 'dark' || (theme.value === 'system' && deviceDark.value) document.documentElement.classList.toggle('dark', dark) storage.set(theme.value)}) export const useTheme = () => theme
The switch is a segmented UiRadioGroup:
<script setup lang="ts">import { UiRadio, UiRadioGroup } from 'grani'import { useTheme } from './useTheme' const theme = useTheme()</script> <template> <UiRadioGroup v-model="theme" variant="segmented" aria-label="Theme"> <UiRadio value="system">System</UiRadio> <UiRadio value="light">Light</UiRadio> <UiRadio value="dark">Dark</UiRadio> </UiRadioGroup></template>
A brand of your own is set once for each theme: Brand says how.