Vitral 0.2
Theming

Theming

Three layers of tokens compiled to CSS variables, and the four ways to change one.

A theme is data. A preset is a tree of tokens in three layers, and the engine compiles it to CSS variables under the --vt- prefix. Nothing re-renders when it changes: the variables are rewritten and the browser repaints.

The three layers

preset.ts
// primitive: palettes, radii, the raw material
{ blue: { 500: '#3b82f6' }, borderRadius: { md: '4px' } }

// semantic: what the application means
{ primary: { color: '{blue.500}' },
  formField: { borderColor: '{surface.300}' },
  colorScheme: { dark: { formField: { borderColor: '{surface.700}' } } } }

// component: what one control needs
{ button: { root: { paddingX: '0.875rem', borderRadius: '{borderRadius.md}' } } }

A value in braces is a reference, and it survives compilation as one: '{primary.color}' becomes var(--vt-primary-color), not the colour it currently holds. That is what makes a single setPrimary() re-colour every button, tag, focus ring and chart series at once.

compiled
/* '{primary.color}' becomes a reference, not a copy */
--vt-primary-color: #3b82f6;
--vt-button-padding-x: 0.875rem;
--vt-button-border-radius: var(--vt-border-radius-md);

Naming

  • A component's token name is its lower-case name with no dashes: ToggleSwitch → toggleswitch.
  • A root segment is dropped: button.root.paddingX → --vt-button-padding-x.
  • Anything that differs between schemes goes under colorScheme: { light, dark } at any depth.

Extending a preset

theme.ts
import { definePreset, palette, Prism } from '@vitral/vue';

export const Brand = definePreset(Prism, {
    semantic: {
        primary: palette('#7c3aed'),
        formField: { borderRadius: '2px' }
    },
    components: {
        button: { root: { borderRadius: '999px' } }
    }
});

definePreset deep-merges into a base, so a brand preset states only its differences. palette('#7c3aed') derives the eleven shades from one colour.

At runtime

useTheme.ts
import { useTheme } from '@vitral/vue';

const { setPreset, setColorScheme, toggleDark, setPrimary, setSurface, setBorders, isDark } = useTheme();

setPrimary('{emerald}');          // a palette reference
setPrimary('#7c3aed');            // or a hex value, and the shades are derived
setSurface({ dark: '{slate}' });  // the greys, per scheme
setColorScheme('system');         // light, dark, or whatever the OS says
setBorders('strong');             // the edges that meet WCAG 1.4.11

Two sets of edges

A preset draws its borders the way it wants to look, which in most of them is quieter than WCAG 1.4.11 asks of a control's boundary. The stronger set lives in the preset under strongBorders, compiles into a block of its own, and is chosen with borders: 'strong' when the plugin is installed or setBorders afterwards. Nothing is recompiled either way — it is an attribute on <html>, so the switch is instant and a preset of your own can carry its own pair.

theme.ts
export const Brand = definePreset(Ink, {
    semantic: { colorScheme: { light: { formField: { borderColor: '{surface.200}' } } } },
    // Only what changes when the stronger edges are asked for.
    strongBorders: {
        colorScheme: {
            light: { formField: { borderColor: 'color-mix(in srgb, {text.color} 40%, {surface.200})' } },
            dark: { formField: { borderColor: 'color-mix(in srgb, {text.color} 26%, {surface.800})' } }
        }
    }
});

app.use(Vitral, { theme: { preset: Brand, borders: 'strong' } });

One instance at a time

dt writes the same tokens on one component's root element, so a single control can differ without a preset for it. Below that, plain CSS variables work as they always did.

Example.vue
<!-- one instance, one token -->
<Button label="Square" :dt="{ button: { borderRadius: '0' } }" />

<!-- or a whole subtree, with the CSS variables directly -->
<div style="--vt-primary-color: tomato">
    <Button label="Tomato" />
</div>

Scope and cascade

Component CSS is injected once per component, in document order, and can be wrapped in a cascade layer with the cssLayer option. With it, any application rule wins on specificity alone and !important stays out of your stylesheet.