Keyboard shortcut: Ctrl + K
Get started

Theming

A theme relies on a set of CSS custom properties. Customizing the theme simply consists of redefining some of them, without any build step or recompilation. No component uses hardcoded values (color, border radius, transition duration): all rely on semantic --vectis-* tokens.
Modifying a token immediately propagates the change across all components. The theme is controlled via the data-theme attribute, which can be applied to any HTML element, not just the root of the page.

Switching Themes

Vectis UI includes two native themes: light and dark. Both reuse the same semantic tokens by binding them to different shades of the palettes. Switching themes requires no additional CSS files and imposes no re-rendering of your Vue components. You simply modify an HTML attribute at any time, without recompilation or visual flash (FOUC).

html
<!-- No attribute at all is the light theme -->
<html>

<!-- The same page, dark -->
<html data-theme="dark">

Light

Live

Dark

Live

The two examples above share the exact same HTML markup: only the theme attribute differs. While the attribute is generally applied to the <html> tag for the entire application, it can be set on any element in the DOM. Thanks to CSS inheritance, the attribute closest to the component prevails across its entire subtree. A dark navigation bar in a light layout, a light preview card in a dark editor, or an invoice that remains light: none of these cases require a dedicated theme, you simply add the attribute to the targeted container.

html
<!-- A dark aside inside a light page: the nearest attribute wins,
     and everything under it inherits. -->
<main data-theme="light">
  <VDataTable :columns="columns" :rows="rows" />

  <aside data-theme="dark">
    <VButton tone="accent">Deploy</VButton>
  </aside>
</main>

Child elements automatically inherit their parent's theme as long as they do not define a new one. Indeed, the data-theme attribute merely reassigns a set of CSS variables, which propagate naturally along the DOM. Theme nesting thus occurs with zero performance overhead and remains reversible at any depth.

The attribute also applies the CSS color-scheme property. Thus, elements not directly managed by the design system automatically adapt to the active theme instead of retaining their default light style. This mainly concerns scrollbars, native form controls, and browser-specific components.

Tokens intentionally contain no prefers-color-scheme media query. Following the system preference is a choice that belongs to the application, not the design system. This is easily implemented in a single line of JavaScript to read the system configuration and apply the attribute. Handling this in pure CSS would make manual user overrides impossible.

ts
// Following the system, in one line. The application makes that call, not the library.
const dark = window.matchMedia('(prefers-color-scheme: dark)')
document.documentElement.dataset.theme = dark.matches ? 'dark' : 'light'

Customizing Colors and Tokens

All library styles rely on design tokens. Generated from a typed TypeScript source formatted according to a specification inspired by the W3C DTCG spec, they are then exposed as CSS variables structured across two levels:

  • Primitives: Five OKLCH palettes of 11 shades each (e.g., --vectis-color-indigo-500), as well as spacing, typography, radius, shadow, duration, and transition scales.
  • Semantic roles: The only tokens directly consumed by components: --vectis-color-surface, --vectis-color-text-muted, --vectis-color-accent, --vectis-radius-interactive, --vectis-focus-ring-color.

A component consumes the accent token, never a specific shade like a precise indigo. This is what makes the library fully customizable: modify the value of the accent role, and all components adapt without ever needing to override their CSS (buttons, badges, selected elements, etc.).
The focus ring also has a dedicated role rather than being a simple derivative of the accent. This choice responds to different contrast requirements: the accent color must ensure text legibility (often white), while the focus ring must be immediately visible against the page background. Therefore, be sure to re-adapt these two tokens in parallel.

Overriding a token is done via a simple CSS declaration: it inserts naturally into your stylesheets, whether on :root for the entire application, on a class for a targeted area, or under [data-theme='dark'] to vary by theme. Its value can be a color, another token, or a calc() function.
In the example below, six accent tokens are reassigned to a coral shade, the focus ring is adjusted to guarantee good contrast on every background, and the radius of the controls and of the chips is tied to the pill token. Nine CSS declarations are enough, without modifying a single component.

css
/* Any selector at all. This one is on the panel below. */
.coral {
  --vectis-color-accent: oklch(64% 0.16 32);
  --vectis-color-accent-hover: oklch(58% 0.16 32);
  --vectis-color-accent-active: oklch(52% 0.15 32);
  --vectis-color-accent-text: oklch(48% 0.15 32);
  /* Mixed towards the surface, so both tints follow whichever theme is showing. */
  --vectis-color-accent-surface: color-mix(in oklch, var(--vectis-color-accent) 14%, var(--vectis-color-surface));
  --vectis-color-accent-border: color-mix(in oklch, var(--vectis-color-accent) 40%, var(--vectis-color-surface));
  /* The focus ring is a role of its own, not the accent under another name: the accent
     carries white text, the ring has to be seen against the page. This one value clears 3:1
     on both grounds, so it needs no dark counterpart. */
  --vectis-focus-ring-color: oklch(58% 0.16 32);
  --vectis-radius-interactive: var(--vectis-radius-pill);
  --vectis-radius-chip: var(--vectis-radius-pill);
}

/* Text needs a lighter step on a dark ground: the one role that has to differ. */
[data-theme='dark'] .coral {
  --vectis-color-accent-text: oklch(78% 0.13 32);
}
Beta

Solid button, outline button, chip, field, text area: none of these components specify hardcoded colors or radii. They therefore all automatically inherit this redefinition, and the behavior would be exactly the same for any other component placed in this panel. The text area is the interesting one: taller than a control, it takes the corner a control of the same size takes, and stays in line with the field above it rather than turning into an ellipse.

All colors are expressed in OKLCH, for two reasons: perceptual lightness is strictly identical from one palette to another for the same shade level, and blending hues produces natural transitions without passing through grayish tones.
Vectis UI automatically calculates hovered, tinted, or disabled states for your roles thanks to the color-mix() function. If you provide a value originating from another color space (where lightness perception differs), these calculated variations risk losing consistency. Keeping your custom values in OKLCH guarantees a perfectly predictable visual rendering.

Vectis UI includes only five foundational palettes to cover essential needs without bloating the CSS: gray for surfaces, texts, and borders, indigo for accentuation, and red, green, and amber for error, success, and warning states. Including additional unused palettes would unnecessarily add CSS variables across all your pages.
Adding a custom hue is therefore up to the application. You simply need to declare your 11 shades of CSS variables and bind the desired semantic role to them. Integration is immediate, without any compilation step or waiting for a new library version.

Every semantic token, with what it controls and its default value in both themes, is listed on the Design tokens page.

CSS Layers

The library styles are structured into four cascade layers (@layer), ordered as follows: vectis.reset, vectis.tokens, vectis.components, and vectis.utilities. In CSS, layers are evaluated before selector specificity, and unlayered styles take precedence over all layers. Consequently, any CSS written unlayered in your project will natively override the library's, regardless of your selector's specificity.

css
/* The order the library declares, for reference. */
@layer vectis.reset, vectis.tokens, vectis.components, vectis.utilities;

/* Your rule is in no layer, so it wins over all four.
   One class, no !important, nothing added to buy specificity. */
.v-button {
  text-transform: uppercase;
}

This behavior is a deliberate architectural choice that eliminates the need for usual workarounds: no use of !important, no need to over-specify your selectors (by adding an ID or chaining classes), and no superfluous containers injected into the DOM solely to increase specificity. A simple class name is enough to override any component style, while keeping stylesheets readable and maintainable.

Caution: Do not insert your own rules directly into @layer vectis.components. Since layer names are global, the browser would merge your rules within the library's layer: priority would then depend solely on the order of appearance in the code instead of guaranteeing the application of your overrides. Write your overrides unlayered or, if your application uses its own @layer structure, make sure to declare your layers after the library's.

Modern CSS and Your Build

Vectis UI ships the CSS it needs and nothing older: :dir(), color-mix(), OKLCH colors, anchor positioning. Your bundler minifies those sheets along with the rest of your application, and a minifier aimed at browsers below the ones the library supports does not simply drop what they cannot read. It rewrites it.

One of those rewrites changes what a rule means instead of approximating what it does. Lightning CSS, the default minifier of Vite 8 and of Parcel, replaces :dir(rtl) with a list of :lang() selectors as soon as its targets predate Chrome 120. That list matches on the language of the page, where the library flips on its direction, so the rule never applies at all on an <html dir="rtl" lang="en"> page. Eleven components stop mirroring: the arrows of the pagination, the tabs, the breadcrumb, the menu, the calendar, the date picker and the carousel, the overlay corner of the badge, the direction of travel of the circular progress, the clipped text copy of the linear progress and the wave of the skeleton loader. Nothing throws, and a development server shows none of it, since only a production build minifies.

Vite derives build.cssTarget from build.target, whose default value stands for browsers far below what the library requires. Naming the supported floor there is the whole fix, and it keeps the output smaller as well: lowered, every OKLCH color of the palette is emitted twice, once as an sRGB fallback and once in another color space.

ts
// vite.config.ts: the browsers Vectis UI is written for
export default defineConfig({
  build: { cssTarget: ['chrome134', 'edge134', 'safari26', 'firefox147'] },
})

// nuxt.config.ts: the same value, one level down
export default defineNuxtConfig({
  vite: { build: { cssTarget: ['chrome134', 'edge134', 'safari26', 'firefox147'] } },
})