Theming
How tokens, dark mode, the brand color and colorScheme fit together — one
mental model, four layers.
The mental model#
Astralis components never hard-code a color. They paint with role tokens — CSS variables named for a job, not a color:
/* what a component actually uses */
background: var(--astralis-color-surface-panel);
color: var(--astralis-color-label-base);
border-color: var(--astralis-color-stroke-subtle);Each role points at a primitive (a raw shade like gray-100), and the
pointer is different in light and dark. Theming — all of it — is re-pointing
those variables at runtime. Nothing re-renders; the browser just repaints.
| Layer | Examples | Job |
|---|---|---|
| Primitives | gray-100, blue-600, brand-500 | Raw shades — 11 palettes × 11 steps |
| Semantic roles | surface-panel, label-muted, stroke-subtle | Neutral UI jobs, light/dark aware |
| Palette roles | blue-solid, blue-subtle, blue-ring, … | Same 8-role vocabulary for every hue |
| Accent channel | accent-solid, accent-ring, … | A virtual palette — whatever colorScheme says |
The full palette and role reference lives on the Colors page; the raw scales on Design Tokens.
Dark mode#
Dark mode is one class — .astralis-dark on <html> — under which every
semantic role is re-declared with its dark value. The provider manages the
class for you:
<AstralisProvider defaultTheme="system">defaultThemeis"light","dark"or"system"(follows the OS, live).- The user's explicit choice persists to
localStorageand wins on the next visit. - Read or change it anywhere with the
useThemehook, or drop in the prebuilt Theme Toggle.
const { theme, resolvedTheme, setTheme } = useTheme();
// theme: "light" | "dark" | "system" resolvedTheme: "light" | "dark"Because components only ever reference roles, dark mode costs you nothing — anything you build from Astralis components and semantic tokens is dark-ready automatically.
Brand color — one hex, a whole theme#
Hand the provider a single color and the library derives everything else at runtime:
<AstralisProvider tokens={{ brandColor: "#8b5cf6" }}>Try it live:
One hex in, a full palette out — shades, hover states and a readable text color are all derived at runtime.
"use client";
import { useState } from "react";
import { Badge, Box, Button, HStack, Tag, Text, VStack, generateBrandTokens, useTheme } from "astralis-ui";
const PRESETS = [
{ name: "Default", color: undefined },
{ name: "Violet", color: "#8b5cf6" },
{ name: "Ocean", color: "#0284c7" },
{ name: "Emerald", color: "#059669" },
{ name: "Rose", color: "#e11d48" },
];
export function BrandTheming() {
const [brand, setBrand] = useState<string | undefined>("#8b5cf6");
const { resolvedTheme } = useTheme();
return (
<VStack gap="5" alignItems="stretch" w="full" maxW="md">
<HStack gap="2" wrap="wrap">
{PRESETS.map((preset) => (
<Button
key={preset.name}
size="xs"
variant={brand === preset.color ? "solid" : "outline"}
colorScheme="gray"
onClick={() => setBrand(preset.color)}
>
{preset.name}
</Button>
))}
<input
type="color"
aria-label="Pick a custom brand color"
value={brand ?? "#eab308"}
onChange={(event) => setBrand(event.target.value)}
/>
</HStack>
{/* The same vars the provider injects for tokens={{ brandColor }} */}
<Box p="5" rounded="xl" bg="subtle" style={generateBrandTokens(brand, resolvedTheme)}>
<VStack gap="3" alignItems="start">
<HStack gap="2" wrap="wrap">
<Button size="sm">Primary</Button>
<Button size="sm" variant="subtle">Subtle</Button>
<Button size="sm" variant="outline">Outline</Button>
</HStack>
<HStack gap="2">
<Badge>Badge</Badge>
<Tag>Tag</Tag>
</HStack>
<Text size="sm" color="muted">
One hex in, a full palette out — shades, hover states and a
readable text color are all derived at runtime.
</Text>
</VStack>
</Box>
</VStack>
);
}What happens under the hood:
- Your color becomes the
500step, and ten shades (50–900) are computed in OKLCH — a perceptual color space where lightness moves without shifting the hue, so a violet stays violet at both ends instead of washing toward grey. - The brand role tokens (
brand-solid,brand-subtle,brand-ring, …) are re-derived from those shades, with different recipes for light and dark. - Text-on-brand (
brand-contrast) is chosen automatically — black or white, whichever is readable on your color.
The demo above uses the same exported function the provider uses —
generateBrandTokens(color, resolvedTheme) — so you can scope a brand
override to any subtree by spreading its result into a style prop.
colorScheme — the accent channel#
Every themeable component takes a colorScheme prop:
<Button colorScheme="teal">Confirm</Button>Here's the trick: components are not styled per hue. They paint with a
single virtual palette — accent-solid, accent-subtle, accent-ring, and
so on. By default the accent channel points at your brand. colorScheme
simply adds one scope class (like astralis-accent-teal) that re-points all
eight accent variables at teal's role tokens for that subtree.
import { Button, Text, VStack, HStack } from "astralis-ui";
const variants = ["solid", "subtle", "surface", "outline", "text", "link"] as const;
const schemes = [
"brand", "gray", "red", "orange", "yellow", "green",
"teal", "blue", "cyan", "purple", "pink",
] as const;
export function ButtonColorSchemes() {
return (
<VStack gap="6" alignItems="stretch">
{variants.map((variant) => (
<VStack key={variant} gap="2" alignItems="stretch">
<Text as="span" size="xs" weight="medium" color="muted">
{variant}
</Text>
<HStack gap="2" wrap="wrap">
{schemes.map((scheme) => (
<Button key={scheme} size="xs" variant={variant} colorScheme={scheme}>
{scheme}
</Button>
))}
</HStack>
</VStack>
))}
</VStack>
);
}Two consequences worth knowing:
- Zero cost per hue. Eleven schemes ship as a few lines of variable re-binding each, not eleven copies of every component's CSS.
- Theme-aware for free. Each scheme points at role tokens that already
flip for dark mode, so
colorScheme="purple"looks right in both themes without any extra work.
Available schemes: brand, gray, red, orange, yellow, green,
teal, blue, cyan, purple, pink.
Overriding tokens yourself#
Every token is a documented CSS variable, so your own stylesheet can retune the system globally:
:root {
--astralis-border-radius-lg: 0.75rem; /* chunkier corners */
--astralis-color-surface-base: #fafaf9; /* warmer page background */
}
.astralis-dark {
--astralis-color-surface-base: #0c0a09;
}One caveat for subtree overrides: CSS variables resolve where they're
declared, so overriding a primitive (like --astralis-color-brand-500) on
a nested element won't reach components — they read role tokens that were
already resolved higher up. Override the role tokens themselves, or use
generateBrandTokens as shown above, which handles exactly this.