Interface and effects

Theme and palette

Two complementary systems: the theme, which switches between light and dark, and the palette, which generates every colour of the interface from a handful of base colours. Both live in CSS variables, so your own CSS comes along without any effort.

Light and dark theme

Three states: light, dark and system, which follows the operating system, including when it changes while the page is open. An explicit choice is applied on the root element with the data-theme attribute:

<html data-theme="dark">

With system, or when the visitor has never chosen, the attribute is absent and the system preference holds, read through prefers-color-scheme. A page nobody configured is never written to: including the script does not repaint your site.

<div>
  <button v-theme-toggle>Switch the theme</button>
  <p v-show="$theme.resolved === 'dark'">You are on the dark theme.</p>
  <p v-show="$theme.resolved === 'light'">You are on the light theme.</p>
</div>

v-theme-toggle on its own switches between light and dark, as it always has. Two other shapes cover the third state:

<div>
  <!-- Walks system, light, dark and back to system. -->
  <button v-theme-toggle.cycle>Theme: { $theme.current }</button>

  <!-- One button per state, pressed while it is the current choice. -->
  <div class="linha">
    <button v-theme-toggle:system>System</button>
    <button v-theme-toggle:light>Light</button>
    <button v-theme-toggle:dark>Dark</button>
  </div>
</div>

The button receives aria-pressed (except with .cycle, which has three states rather than two), a descriptive aria-label unless you wrote one, data-voodoo-theme with the theme on screen and data-voodoo-theme-mode with the choice, system included, so your CSS can pick an icon:

.theme-icon { display: none; }
[data-voodoo-theme-mode="system"] .theme-icon--system,
[data-voodoo-theme-mode="light"] .theme-icon--light,
[data-voodoo-theme-mode="dark"] .theme-icon--dark { display: inline; }
V.theme.current;    // 'light', 'dark' or 'system'
V.theme.resolved;   // 'light' or 'dark', with 'system' already resolved
V.theme.chosen;     // true once the visitor picked one, 'system' included
V.theme.set('dark');
V.theme.set('system');
V.theme.toggle();   // light and dark only; returns the theme it applied
V.theme.cycle();    // system, light, dark; returns the new choice

The choice lives in localStorage, under the key voodoo:theme, as the plain string light, dark or system. The JSON spelling "dark", written by older builds, is read too. A change made in another tab, or in a same-origin frame, reaches the page without a reload, and every change fires a voodoo:theme event on document.

No flash on load

The library restores the choice as soon as it runs, but a script loaded with defer runs after the first paint, so a visitor who chose dark on a light system would see the page open light and then switch. Put this in the <head>, before any stylesheet, and the right theme is on the root before anything is drawn. It reads the same key and writes exactly what V.theme writes, so the two never disagree:

<script>
  (function () {
    try {
      var t = localStorage.getItem('voodoo:theme');
      if (t && t.charAt(0) === '"') t = JSON.parse(t);
      var r = document.documentElement;
      if (t === 'light' || t === 'dark') {
        r.setAttribute('data-theme', t);
        r.style.colorScheme = t;
      } else if (t === 'system') {
        r.removeAttribute('data-theme');
      }
    } catch (e) {}
  })();
</script>
Without JavaScript

Write the dark theme twice in your CSS, once under @media (prefers-color-scheme: dark) for :root:not([data-theme="light"]) and once for :root[data-theme="dark"]. The first keeps a page without JavaScript, or a server-rendered one before the script arrives, on the visitor's system theme; the second is the explicit choice. The library's own tokens are written that way.

Writing CSS that follows the theme

.my-box {
  background: var(--v-surface);
  color: var(--v-text);
  border: 1px solid var(--v-border);
  border-radius: var(--v-radius);
  box-shadow: var(--v-shadow);
}

Because the variables already change with the theme, you do not have to write two versions of the same rule.

The palette

V.palette() generates, from a handful of base colours, the full scale of shades from 50 to 900, the dark-theme version, and the text colour with the best contrast over each colour, all written as CSS variables on :root.

The maths happens in OKLCH, a perceptually uniform space: steps with the same difference in lightness look equally far apart to the eye, which is not the case in HSL. The text colour uses the real WCAG relative-luminance formula, so the result is always readable.

V.palette({ primary: '#6D3BF5', accent: '#FF3D8B', radius: '12px', font: 'Inter' });
V.palette({ preset: 'oceano' });
OptionWhat it does
presetThe starting point. The colours you pass override it
primary, accent, success, warning, danger, infoBase colours
neutralThe colour that tints backgrounds, text and borders. Default: the hue of the primary
radiusBorder radius, such as 12px or 0.75rem
fontThe main family. The page is still responsible for loading the font
monoFontThe monospaced family, used by VCodeBlock
persistSaves the choice in localStorage. Default true

Presets

NamePrimaryAccent
violeta#6D3BF5#FF3D8B
oceano#0E7BC4#0FB5C9
floresta#1F8A4C#7FA80E
poente#E4632A#D62F63
grafite#4C5A70#2E7FD1

All of them have their contrast verified in both themes.

<div>
  <script>
    V.data({ usePalette: (name) => V.palette.use(name) });
  </script>

  <div v-data="{ presets: ['violeta', 'oceano', 'floresta', 'poente', 'grafite'] }">
    <div class="linha">
      <button v-for="p in presets" @click="usePalette(p)">{ p }</button>
    </div>
    <VButton variant="primary">A primary button</VButton>
    <VBadge tone="success">Success</VBadge>
    <VAlert tone="info" title="Notice">The whole palette changes at once.</VAlert>
  </div>
</div>
V.palette.use('floresta');   // changes only the preset, keeping radius and font
V.palette.names;             // ['violeta', 'oceano', 'floresta', 'poente', 'grafite']
V.palette.reset();           // goes back to the default and erases the saved choice
V.palette.current;           // the palette in use, with all its scales

CSS tokens

These always exist, with a built-in default value, even without calling V.palette():

TokenWhat it is for
--v-primary, --v-primary-hover, --v-primary-contrastThe main colour
--v-accentThe accent colour
--v-success, --v-warning, --v-danger, --v-infoStates
--v-surface, --v-surface-2Backgrounds
--v-text, --v-text-mutedText
--v-borderBorders
--v-radius, --v-radius-smRadii
--v-shadowShadow
--v-easeThe transition curve
--v-z-modal, --v-z-drawer, --v-z-dropdown, --v-z-toast, --v-z-tooltipLayers

After V.palette(), the set grows with intermediate backgrounds, a smaller and a larger shadow, larger radii, the focus ring, the type families and the 50-to-900 scales of every base colour. The full list is in the configuration reference.

Derived colours, and checking contrast

V.palette.scale('#6D3BF5')['700'];           // a dark shade of the scale
V.palette.scale('#6D3BF5', true);            // the dark-theme scale
V.palette.contrastText('#6D3BF5');           // '#fff' or '#000', whichever reads better
V.palette.contrastRatio('#6D3BF5', '#fff');  // the WCAG ratio, from 1 to 21
V.palette.luminance('#6D3BF5');              // relative luminance
Use it while developing

contrastRatio gives you the real ratio: 4.5 is the WCAG AA minimum for normal text and 3 for large text. Checking a colour in the console is faster than finding the problem later, in an accessibility audit.

Turning off the injected CSS

<script src="voodoo.full.min.js?v=0.6.2" data-no-styles defer></script>

No style is injected. The components go on working, but with no appearance, and writing the rules for the v-* classes is up to you. If you use a utility CSS framework, the more comfortable road is usually the opposite: keep the injected CSS and point the framework's colours at the --v-* variables, so that the two systems move together.