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.
defaultis Avesra. Docs showcase presets (sky, lavender, …) load fromprojects/docs/src/themes/. Switch withsetDesignTheme()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.
@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.
@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.
@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.
[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.
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.
dark:bg-zinc-950Tailwind 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.
--av-background--av-foreground--av-accent--av-accent-foreground--av-surface--av-surface-foreground--av-default--av-default-foreground--av-danger--av-danger-foreground--av-success--av-success-foregroundTokens 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
| Token | Utility | Used for | Value |
|---|---|---|---|
--av-background | bg-background | App canvas | oklch(97.02% 0.005 0) |
--av-foreground | text-foreground | Default text | oklch(21.03% 0.005 0) |
--av-muted | text-muted | Secondary text | oklch(55.17% 0.01 0) |
--av-accent | bg-accent | Primary action | oklch(0% 0 0) |
--av-accent-foreground | text-accent-foreground | Text on accent | oklch(99.11% 0 0) |
--av-default | bg-default | Quiet fill | oklch(94% 0.005 0) |
--av-danger | bg-danger | Error and delete | oklch(0.573 0.2249 21.97) |
--av-success | bg-success | Success states | oklch(0.6277 0.1604 153.06) |
--av-warning | bg-warning | Warning states | oklch(0.8446 0.1525 80.6) |
Surfaces
| Token | Utility | Used for | Value |
|---|---|---|---|
--av-surface | bg-surface | Cards and raised panels | oklch(100% 0.0025 0) |
--av-overlay | bg-overlay | Dialogs, menus, popovers | oklch(100% 0.0015 0) |
Form
| Token | Utility | Used for | Value |
|---|---|---|---|
--av-border | border-border | Default border | oklch(90% 0.005 0) |
--av-field-background | bg-field | Input fill | oklch(100% 0.0025 0) |
--av-focus | outline / ring | Focus ring | oklch(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| Utility | Definition |
|---|---|
rounded-sm | calc(var(--av-radius) * 0.5) |
rounded-md | calc(var(--av-radius) * 0.75) |
rounded-lg | var(--av-radius) |
rounded-xl | calc(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.
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.