Skip to content

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.

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