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>
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' });
| Option | What it does |
|---|---|
preset | The starting point. The colours you pass override it |
primary, accent, success, warning, danger, info | Base colours |
neutral | The colour that tints backgrounds, text and borders. Default: the hue of the primary |
radius | Border radius, such as 12px or 0.75rem |
font | The main family. The page is still responsible for loading the font |
monoFont | The monospaced family, used by VCodeBlock |
persist | Saves the choice in localStorage. Default true |
Presets
| Name | Primary | Accent |
|---|---|---|
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():
| Token | What it is for |
|---|---|
--v-primary, --v-primary-hover, --v-primary-contrast | The main colour |
--v-accent | The accent colour |
--v-success, --v-warning, --v-danger, --v-info | States |
--v-surface, --v-surface-2 | Backgrounds |
--v-text, --v-text-muted | Text |
--v-border | Borders |
--v-radius, --v-radius-sm | Radii |
--v-shadow | Shadow |
--v-ease | The transition curve |
--v-z-modal, --v-z-drawer, --v-z-dropdown, --v-z-toast, --v-z-tooltip | Layers |
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
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.