AvesraAvesrabeta

Theming

Design tokens in OKLCH, mapped to Tailwind utilities. One stylesheet controls every component.

An Avesra theme is a set of --av-* variables in @avesra/styles. Each one is mapped to a Tailwind color through @theme inline, so --av-accent becomes bg-accent. Redefining the variable under [data-av-theme="dark"] — or under any container class — retints every component underneath it, with no rebuild and no per-component configuration.

There are three ways to get a theme:

  • Import the package. Tailwind first, then @avesra/styles — see Installation.
  • Pick a preset.default is Avesra. Docs showcase presets (sky, lavender, …) load from projects/docs/src/themes/. Switch with setDesignTheme() or the docs picker.
  • Override tokens. Redefine --av-* in your stylesheet. The guide below shows the file, line by line.

How it works

This is the whole consumer file. Paste it into your global CSS and every component is themed.

@import "tailwindcss";
@import "@avesra/styles";
import { provideAvesraTheme } from '@avesra/styles';

export const appConfig = {
  providers: [provideAvesraTheme({ mode: 'system', persist: true })],
};

Line by line

Six parts, in the order they matter. The last line of every entry says what breaks without it.

  1. 1. @import "tailwindcss";

    Tailwind first

    Pulls in the utility engine. Avesra tokens must load after this so bg-accent and friends exist.

    Without it — No Tailwind utilities resolve.

  2. 2. @import "@avesra/styles";

    Avesra tokens and CSS

    Loads default Avesra tokens, component CSS, variants, and the @theme inline bridge.

    Without it — Components apply classes, but the visuals never arrive.

  3. 3. @theme inline { --color-accent: var(--av-accent); }

    Tokens → utilities

    inline emits var(--av-accent) instead of baking the light value, so dark mode and presets still win at runtime.

    Without it — bg-accent does not exist, or dark mode keeps the light color.

  4. 4. [data-av-theme="dark"] { --av-background: … }

    Dark overrides

    The same token names get new values. No component checks which mode is active.

    Without it — Dark mode keeps the light palette.

  5. 5. provideAvesraTheme({ mode: "system", persist: true })

    Write attributes on <html>

    AvThemeService sets data-av-theme, light/dark classes, av-light / av-dark, and data-av-design-theme.

    Without it — The toggle in the header does nothing. Tokens stay on the default light root.

  6. 6. dark:bg-zinc-950

    Tailwind dark utilities

    @avesra/styles overrides the default dark: variant so utilities follow AvThemeService instead of prefers-color-scheme alone.

    Without it — dark: styles only follow the OS theme and ignore the in-app toggle.

Default Avesra values, light and dark. Only the values change — component classes stay the same.

:root,
.av-light,
[data-av-theme="light"] {
  --av-radius: 0.5rem;
  --av-background: oklch(97.02% 0.005 0);
  --av-foreground: oklch(21.03% 0.005 0);
  --av-surface: oklch(100% 0.0025 0);
  --av-muted: oklch(55.17% 0.01 0);
  --av-accent: oklch(0% 0 0);
  --av-accent-foreground: oklch(99.11% 0 0);
  --av-default: oklch(94% 0.005 0);
  --av-border: oklch(90% 0.005 0);
  --av-danger: oklch(0.573 0.2249 21.97);
}

.av-dark,
.dark,
[data-av-theme="dark"] {
  --av-background: oklch(12% 0.005 0);
  --av-foreground: oklch(99.11% 0.005 0);
  --av-surface: oklch(21.03% 0.01 0);
  --av-muted: oklch(70.5% 0.01 0);
  --av-accent: oklch(98.48% 0 0);
  --av-accent-foreground: oklch(15% 0 0);
  --av-default: oklch(27.4% 0.005 0);
  --av-border: oklch(28% 0.005 0);
  --av-danger: oklch(0.7044 0.1872 23.19);
}
@theme inline {
  --color-background: var(--av-background);
  --color-foreground: var(--av-foreground);
  --color-accent: var(--av-accent);
  --color-accent-foreground: var(--av-accent-foreground);
  --color-surface: var(--av-surface);
  --color-muted: var(--av-muted);
  --color-border: var(--av-border);
  --radius-md: calc(var(--av-radius) * 0.75);
  --radius-lg: var(--av-radius);
}

Why OKLCH and not HSL or HEX?

Lightness is perceptual: the same lightness looks equally light at any hue, so a palette stays balanced when you shift it. Interpolation stays clean — color-mix() and opacity modifiers do not drift toward grey the way HSL does.

Convention

Tokens come in pairs: a surface and the text that sits on it. The background suffix is dropped, so --av-accent is the surface and --av-accent-foreground is the text on top of it.

--av-accent: oklch(0% 0 0);
--av-accent-foreground: oklch(99.11% 0 0);

Every pair, rendered

These are real utilities on real elements — the contrast you see is the contrast you get.

Aa--av-background
Aa--av-foreground
Aa--av-accent
Aa--av-accent-foreground
Aa--av-surface
Aa--av-surface-foreground
Aa--av-default
Aa--av-default-foreground
Aa--av-danger
Aa--av-danger-foreground
Aa--av-success
Aa--av-success-foreground

Tokens or raw utility classes?

Prefer tokens. They react to dark mode and to scoped overrides on their own:

<div class="bg-background text-foreground">
  <button av-button>Themed by tokens</button>
</div>

Raw Tailwind colors work too, but you own every dark-mode variant by hand and lose scoped theming:

<div class="bg-zinc-50 text-zinc-950 dark:bg-zinc-950 dark:text-zinc-50">
  dark: follows AvThemeService when @avesra/styles is imported after Tailwind.
</div>

Token reference

Core tokens with their Default (Avesra) light values. Tailwind utilities map through @theme inline.

Base

TokenUtilityUsed forValue
--av-backgroundbg-backgroundApp canvasoklch(97.02% 0.005 0)
--av-foregroundtext-foregroundDefault textoklch(21.03% 0.005 0)
--av-mutedtext-mutedSecondary textoklch(55.17% 0.01 0)
--av-accentbg-accentPrimary actionoklch(0% 0 0)
--av-accent-foregroundtext-accent-foregroundText on accentoklch(99.11% 0 0)
--av-defaultbg-defaultQuiet filloklch(94% 0.005 0)
--av-dangerbg-dangerError and deleteoklch(0.573 0.2249 21.97)
--av-successbg-successSuccess statesoklch(0.6277 0.1604 153.06)
--av-warningbg-warningWarning statesoklch(0.8446 0.1525 80.6)

Surfaces

TokenUtilityUsed forValue
--av-surfacebg-surfaceCards and raised panelsoklch(100% 0.0025 0)
--av-overlaybg-overlayDialogs, menus, popoversoklch(100% 0.0015 0)

Form

TokenUtilityUsed forValue
--av-borderborder-borderDefault borderoklch(90% 0.005 0)
--av-field-backgroundbg-fieldInput filloklch(100% 0.0025 0)
--av-focusoutline / ringFocus ringoklch(0% 0 0)

Radius & scale

--av-radius is the single knob for corner rounding. The utilities below are derived from it, so changing one value reshapes every component. Default Avesra is 0.5rem.

--av-radius:0rem0.25rem0.5rem0.75rem1rem
Card
UtilityDefinition
rounded-smcalc(var(--av-radius) * 0.5)
rounded-mdcalc(var(--av-radius) * 0.75)
rounded-lgvar(--av-radius)
rounded-xlcalc(var(--av-radius) * 1.5)

Design presets

Presets change accent, surfaces, and radius through data-av-design-theme. Built-in Avesra tokens ship with the default preset only; the docs site loads additional showcase presets from projects/docs/src/themes/. The picker in the header uses the same AvThemeService.

DefaultSkyLavenderMintNetflixSpotifyCoinbaseAirbnbDiscordRabbit

Active: default

Dark mode

Dark mode is the [data-av-theme="dark"] / .av-dark block and nothing else. No component knows which mode is active: they all read the same token names, and the values change underneath them.

Two pieces make it work — dark: utilities (bound to .dark / [data-av-theme="dark"] by @avesra/styles ), token overrides under the same selectors, and AvThemeService puts them on <html> and persists the choice.

readonly theme = inject(AvThemeService);

theme.setMode('dark');        // 'light' | 'dark' | 'system'
theme.setDesignTheme('discord');
theme.setVibrantPalette(true);
theme.toggle();
theme.mode();
theme.colorScheme();
theme.designTheme();

background

Sample text

accent

Accent text

default

Default surface

Mode: dark · Scheme: dark · Design: default

import { Component, inject } from '@angular/core';
import { AvButtonComponent } from '@avesra/angular';
import { AvThemeService } from '@avesra/styles';

@Component({
  imports: [AvButtonComponent],
  template: `
    <div class="flex flex-wrap gap-2">
      <button av-button size="sm" (click)="theme.setMode('light')">Light</button>
      <button av-button size="sm" (click)="theme.setMode('dark')">Dark</button>
      <button av-button size="sm" (click)="theme.setMode('system')">System</button>
    </div>
  `,
})
export class ThemingModesDemo {
  readonly theme = inject(AvThemeService);
}

Customizing

Three things you are likely to want: a new color, a token overridden for one part of the app, and a theme that follows a route.

Adding a new color

Declare the raw value, override it under dark, then map it — the same three steps every built-in token goes through.

:root {
  --av-info: oklch(0.62 0.14 240);
  --av-info-foreground: oklch(0.98 0.01 240);
}

.av-dark,
.dark,
[data-av-theme="dark"] {
  --av-info: oklch(0.72 0.12 240);
  --av-info-foreground: oklch(0.16 0.04 240);
}

@theme inline {
  --color-info: var(--av-info);
  --color-info-foreground: var(--av-info-foreground);
}

Overriding tokens in one scope

Because @theme inline emits var(--av-accent), any container can redefine a token for its subtree.

.theme-brand {
  --av-accent: oklch(0.55 0.22 264);
  --av-accent-foreground: oklch(0.98 0.01 264);
  --av-radius: 1rem;
}
<section class="theme-brand">
  <button av-button>Brand button</button>
</section>

A theme per route

Put the override class on a layout host and everything rendered through its outlet inherits it.

@Component({
  selector: 'app-marketing-layout',
  imports: [RouterOutlet],
  host: { class: 'theme-brand' },
  template: `<router-outlet />`,
})
export class MarketingLayout {}

Other color formats

The raw tokens hold plain CSS colors, so RGB, HSL, and HEX all work. Keep the @theme inline mapping as it is.

:root {
  --av-accent: oklch(0% 0 0);
  --av-default: rgb(244 244 245);
  --av-muted: hsl(240 5% 45%);
  --av-border: #e5e5e5;
}

Troubleshooting

Every entry below is a real failure mode of this setup, with the line that causes it.

Toggling dark mode changes nothing.

Cause — provideAvesraTheme() is missing, so nothing writes data-av-theme on <html>.

Fix — Register the provider and call theme.setMode() or use the docs header toggle.

bg-accent and text-foreground do not exist.

Cause — @avesra/styles was imported before Tailwind, or not imported at all.

Fix — Keep @import "tailwindcss"; first, then @import "@avesra/styles";

Dark tokens exist, but the light colors stay on screen.

Cause — A local @theme block copied values instead of using @theme inline.

Fix — Map tokens with @theme inline so the output keeps var(--av-*).

A custom token works in one component and nowhere else.

Cause — The variable was declared in a component stylesheet instead of :root.

Fix — Declare --av-* on :root / [data-av-theme="dark"], then scope with a container class if needed.

Made with ❤ by SyntaxHertz. Open source and available on GitHub.