Skip to content

Also available as Markdown

Customize

The primary way to restyle var-ui is a recipe-shaped override: call theme.componentStyles(recipe, override) after createDesignTheme. Overrides use base / variants / compoundVariants with full CSS properties, autocomplete variant names from the recipe, and mint custom tokens that participate in light and dark mode — with no class names in your theme code.

Cascade layers keep overrides winning without specificity fights: recipes live in components, consumer overrides in overrides.

One theme call

import { badge, button, createDesignTheme } from '@var-ui/core';
import { DesignSystemProvider } from '@var-ui/react';

export const acme = createDesignTheme({
  name: 'acme',
  // Partial — deep-merged onto the built-in default palette
  tokens: {
    color: {
      accent: {
        default: 'oklch(55% 0.2 290)',
        hover: 'oklch(48% 0.2 290)',
      },
    },
  },
  colorMode: {
    dark: {
      accent: {
        default: 'oklch(72% 0.16 290)',
        hover: 'oklch(78% 0.14 290)',
      },
    },
  },

  // Custom tokens — string leaves are mode-invariant; { light, dark } follow color mode
  extend: {
    brand: {
      glow: {
        light: '0 0 0 3px oklch(90% 0.08 290)',
        dark: '0 0 16px oklch(70% 0.18 290)',
      },
    },
  },
});

// Per recipe: `(t) => override` or a plain object. `t` is built-ins + extend refs.
// Style blocks get CSS property IntelliSense; variant keys come from the recipe.
acme.componentStyles(button, (t) => ({
  base: {
    borderRadius: '999px',
    boxShadow: t.brand.glow,
    '&:hover': { boxShadow: 'none' },
  },
  variants: {
    tone: {
      // keys autocomplete: neutral | accent | success | warning | danger | info
      accent: { textTransform: 'uppercase' },
    },
    appearance: {
      filled: {},
    },
  },
  compoundVariants: [
    {
      variants: { tone: 'accent', appearance: 'filled', size: 'lg' },
      style: { letterSpacing: '0.08em' },
    },
  ],
}));

acme.componentStyles(badge, {
  base: { borderRadius: '999px' },
});

// Apply
<DesignSystemProvider customTheme={acme}>{/* app */}</DesignSystemProvider>
  • acme.className — theme surface class (theme-var-ui-acme)
  • acme.tokens.brand.glow — typed var(--var-ui-brand-glow) ref

Try the Acme (V7 + V8 demo) palette in the vite example app to see this live.

Self-hosted fonts

Register @font-face rules with fonts on createDesignTheme (or on a from preset). Pair with tokens.fontFamily stacks that reference the same family names. Use defineFonts to generate stacks from face definitions:

import { createDesignTheme, defineFonts, groteskMono } from '@var-ui/core';

const brand = defineFonts({
  body: {
    face: {
      family: 'Acme Sans',
      src: "url('/fonts/acme-sans.woff2') format('woff2')",
      fontWeight: '400 700',
      fontDisplay: 'swap',
    },
    fallback: 'system-ui, sans-serif',
  },
});

export const acme = createDesignTheme({
  name: 'acme',
  fonts: brand.fonts,
  tokens: { fontFamily: brand.tokens.fontFamily },
});

Host font files under your app’s public/fonts/ directory. Root-relative url('/fonts/…') works with Astro, Vite, and Next static serving.

Split across files

Hoist extend into a leaf module and type factories with DesignThemeTokens — avoid typeof theme.tokens in override files (that cycles through the theme).

// acme.extend.ts
import type { DesignThemeTokens } from '@var-ui/core';

export const acmeExtend = {
  brand: {
    glow: { light: '…', dark: '…' },
  },
} as const;

export type AcmeTokens = DesignThemeTokens<typeof acmeExtend>;

// button.ts
import { button } from '@var-ui/core';
import type { OverrideConfigFor } from '@var-ui/core';
import type { AcmeTokens } from './acme.extend';

export const buttonOverride = (t: AcmeTokens) =>
  ({
    base: { boxShadow: t.brand.glow },
  }) satisfies OverrideConfigFor<typeof button>;

// theme.ts
import { button, createDesignTheme } from '@var-ui/core';
import { acmeExtend } from './acme.extend';
import { buttonOverride } from './button';

export const acme = createDesignTheme({
  name: 'acme',
  extend: acmeExtend,
});

acme.componentStyles(button, buttonOverride);

Composable primitives

theme.componentStyles(recipe, override) compiles to TypeStyles styles.override under the theme class. For app-global or ad-hoc restyles, call styles.override directly:

import { styles, extendTokens, button } from '@var-ui/core';

// App-global restyle — no theme class prefix
styles.override(button, { base: { borderRadius: '999px' } }, { layer: 'overrides' });

// Theme-scoped (same mechanism componentStyles uses internally)
styles.override(
  button,
  { variants: { tone: { danger: { fontWeight: 700 } }, appearance: { filled: {} } } },
  { selectorPrefix: `.${acme.className}`, layer: 'overrides' },
);

// Custom tokens without a theme (global :root + dark mode rules)
const brand = extendTokens('brand', {
  glow: { light: '…', dark: '…' },
});

When to drop down a tier

NeedUse
Bulk restyle / new brand tokensextend + componentStyles (this page)
One property the recipe already exposes as a varSet the CSS custom property in an override block or stylesheet
Nested themes fighting over the same recipePrefer component vars (inheritance), or styles.scope() / @scope
Hand-written CSS against a public classTarget the stable base/slot class (e.g. .var-ui-button)

Attribute mode means variant state is data-* on the element (data-tone="accent" and data-appearance="filled"), not a separate class. Overrides still target the right selectors via TypeStyles styles.override — you never write those selectors yourself in the theme config.

Nested themes

componentStyles overrides use a descendant selector prefix (.theme-var-ui-acme .var-ui-button). If two themed regions nest and both override the same recipe, prefer component CSS variables (Tier 1) or styles.scope() for proximity-correct CSS. See TypeStyles’ component override contract for details.