Skip to content

Customize

The primary way to restyle var-ui is a recipe-shaped theme config: override 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 { 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-key: `(t) => override` or a plain object. `t` is built-ins + extend refs.
  // Style blocks get CSS property IntelliSense; variant keys come from the recipe.
  components: {
    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' },
        },
      ],
    }),
    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 type { AcmeTokens } from './acme.extend';

export const button = (t: AcmeTokens) => ({
  base: { boxShadow: t.brand.glow },
});

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

createDesignTheme({
  name: 'acme',
  extend: acmeExtend,
  components: { button },
});

Composable primitives

createDesignTheme({ components }) 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 createDesignTheme 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 tokenscomponents / extend (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

components 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.