Documentation
Asgard
Getting started
Theming
Guides
Inputs & forms
Pickers
Data display
Layout & docking
Feedback & overlays
Navigation & chrome
Theming
Every Asgard component reads its colors from the nearest ThemeProvider. A theme is a plain object of
tokens (backgrounds, text, borders, accents, semantic colors, per-component overrides), so you can use a
built-in palette or supply your own.
ThemeProvider
The simplest, controlled usage: pass a theme object and the provider renders exactly that.
ThemeProvider,themes,ThemeCssVars,useThemeare exported from both the main entry@ekko/asgardand the@ekko/asgard/themesubpath, import them from either (theekko init rune -t asgardscaffold uses the main entry).
ThemeProvider has no persistence of its own, it renders the theme you feed it. Persisting the choice
(localStorage, a Mimir atom, an ekko:rune store) is the app's job; just feed the saved value back into
theme.
Theme your own markup (the CSS-vars bridge)
ThemeProvider themes Asgard components, it does not touch your own header, footer, or hand-written
SCSS. If you've hit "I switched theme and Asgard re-themed but my own page chrome didn't," this is why,
and <ThemeCssVars/> is the fix.
<ThemeCssVars/> mirrors the active theme onto document.documentElement as CSS custom properties (default
prefix --ekko-), so your own SCSS can read the same tokens and re-theme on every switch. It renders
nothing and is SSR-safe (the write is a no-op on the server and runs once mounted on the client). Place it
inside <ThemeProvider>:
Your app SCSS consumes the mirrored tokens with plain var(...):
When you change the theme prop, <ThemeCssVars/> rewrites the custom properties and every rule above
re-themes automatically, no extra wiring.
Custom prefix
Pass prefix to change the CSS-var namespace (e.g. to scope vars to your app):
Imperative escape hatch
For non-React callers, or to apply tokens once outside the component tree, use the imperative helpers
(both from @ekko/asgard):
applyThemeToRoot(theme, prefix?), sets the custom properties ondocument.documentElementnow (no-op
during SSR; default prefix --ekko-).
themeToCssVars(theme, prefix?), returns the flat{ "--ekko-background-primary": "#…", … }record
without touching the DOM, handy if you want to inject the vars yourself.
How tokens map to CSS-var names
Each dotted token path becomes a dash-joined name under the prefix; the name field is excluded. For
example:
| Token path | CSS variable |
|---|---|
background.primary | var(--ekko-background-primary) |
text.secondary | var(--ekko-text-secondary) |
accent.primaryHover | var(--ekko-accent-primary-hover) |
border.divider | var(--ekko-border-divider) |
semantic.error | var(--ekko-semantic-error) |
components.sidebar.background | var(--ekko-components-sidebar-background) |
opacity.hover | var(--ekko-opacity-hover) |
Built-in palettes
themes (from @ekko/asgard/theme) is a map of ready-made palettes:
There are 26 built-in themes. Each is either dark or light (the page background and every token follow it). The keys, grouped:
Dark (18): dark, dracula, monokai, nord, onedark, solarizedDark, gruvboxDark,
tokyoNight, ayuDark, materialDark, catppuccinMocha, githubDark, palenight, rosePine,
everforestDark, arctic, ubuntu, linuxMint.
Light (8): light, githubLight, solarizedLight, gruvboxLight, ayuLight, materialLight,
catppuccinLatte, everforestLight.
The source of truth is Object.keys(themes). Note the exact casing: onedark (not oneDark),
ayuDark/ayuLight (not ayu), materialDark/materialLight, everforestDark/everforestLight,
catppuccinMocha/catppuccinLatte. Using a key that isn't in the map yields undefined, which
ThemeProvider will not accept.
For a plain light/dark toggle, map your boolean to one dark key and one light key, the theme names themselves are not "dark"/"light" so pick a pair:
Switching theme is just changing which object you pass:
Reading the theme
Inside a component, useTheme() returns the active theme and setters:
useTheme() returns { theme, setTheme, themeName, setThemeName }. It must be called inside a
ThemeProvider.
Token reference
A theme is a typed object (Theme, from @ekko/asgard/theme). The token tree below is the source of
truth (src/theme/types.ts); the same paths are what <ThemeCssVars/> flattens into CSS variables.
background
| Field | Meaning |
|---|---|
background.primary | Main background |
background.secondary | Secondary background (panels, cards) |
background.tertiary | Tertiary background (hover states) |
background.elevated | Elevated elements (dropdowns, menus) |
text
| Field | Meaning |
|---|---|
text.primary | Main text |
text.secondary | Secondary text (labels, descriptions) |
text.disabled | Disabled text |
text.inverse | Text on colored backgrounds |
border
| Field | Meaning |
|---|---|
border.default | Default borders |
border.focus | Focused element borders |
border.divider | Dividers and separators |
accent
| Field | Meaning |
|---|---|
accent.primary | Primary accent (buttons, links) |
accent.primaryHover | Primary accent hover |
accent.primaryActive | Primary accent active/pressed |
accent.secondary | Secondary accent |
interactive
| Field | Meaning |
|---|---|
interactive.hover | Hover background |
interactive.active | Active/pressed background |
interactive.selected | Selected state |
interactive.focus | Focus ring/outline |
semantic
| Field | Meaning |
|---|---|
semantic.error | Error color |
semantic.warning | Warning color |
semantic.success | Success color |
semantic.info | Informational color |
components (per-component overrides)
| Field | Meaning |
|---|---|
components.sidebar.background | Sidebar background |
components.sidebar.itemHover | Sidebar item hover |
components.sidebar.itemActive | Sidebar item active |
components.sidebar.border | Sidebar border |
components.tab.background | Tab strip background |
components.tab.activeBackground | Active tab background |
components.tab.activeBorder | Active tab border |
components.tab.activeText | Active tab text |
components.tab.inactiveText | Inactive tab text |
components.tab.hoverBackground | Tab hover background |
components.tab.closeButtonHoverBackground | Tab close-button hover background |
components.toolbar.background | Toolbar/ribbon background |
components.toolbar.buttonHover | Toolbar button hover |
components.toolbar.buttonActive | Toolbar button active |
components.toolbar.groupBorder | Toolbar group border |
components.toolbar.groupLabel | Toolbar group label |
components.menu.background | Context menu background |
components.menu.itemHover | Menu item hover |
components.menu.separator | Menu separator |
components.menu.shadow | Menu shadow |
components.tooltip.background | Tooltip background |
components.tooltip.text | Tooltip text |
components.tooltip.border | Tooltip border |
components.tooltip.shadow | Tooltip shadow |
components.dropdown.background | Dropdown background |
components.dropdown.itemHover | Dropdown item hover |
components.dropdown.itemSelected | Dropdown selected item |
components.dropdown.border | Dropdown border |
components.scrollbar.thumb | Scrollbar thumb |
components.scrollbar.thumbHover | Scrollbar thumb hover |
components.scrollbar.track | Scrollbar track |
opacity
| Field | Meaning |
|---|---|
opacity.disabled | Disabled-element opacity (number) |
opacity.hover | Hover overlay opacity (number) |
opacity.backdrop | Backdrop/scrim opacity (number) |
Custom themes
A theme is just a typed object. Start from a built-in one and override what you need:
Nesting
Providers nest. Wrap a subtree in a second ThemeProvider to theme just that region, handy for a
preview pane or a differently-themed panel inside an otherwise neutral app.
Inputs hand you the value, not an event
Asgard inputs are controlled and, by design, call your onChange with the value directly, never a
DOM event. Write onChange={(v) => setX(v)}, not onChange={(e) => setX(e.target.value)}. This is uniform
across the whole suite. The exact payload per input:
| Component | onChange signature | Payload |
|---|---|---|
Button | onClick?: (e: MouseEvent) => void | Buttons use onClick and DO receive the DOM mouse event (they are not value inputs) |
TextBox | (value: string) => void | The input's string value |
Select | (value: string | number) => void | The selected option's value |
Slider | (value: number | number[]) => void | The number (or array for range sliders) |
Checkbox | (checked: boolean) => void | Checked state |
CheckboxGroup | (value: string[]) => void | Array of checked values |
Switch | (checked: boolean) => void | Toggle state |
Radio | (checked: boolean) => void | Checked state |
RadioGroup | (value: string) => void | Selected value |
ColorPicker | (color: Color) => void | The selected Color object |
Calendar | (value: Date | Date[] | null, selections?) => void | Selected date(s) |
TimePicker | (value: TimeValue) => void | The selected TimeValue |
DateTimeInput | (value: DateTimeValue) => void | The selected DateTimeValue |
All Asgard inputs hand your
onChangethe VALUE directly, writeonChange={(v) => setX(v)}, note.target.value. (Buttonis the exception: it isn't a value input, so itsonClickreceives the DOM mouse event as usual.)
Buttons and forms
<Button> renders a native <button type="button"> by default, so it will not submit a surrounding
form. To make a button submit (or reset) a form, set htmlType:
htmlType accepts 'button' (default), 'submit', or 'reset' and maps directly to the underlying
<button type=...>. Because the default is 'button', you don't need a guard against accidental
double-submits, an Asgard <Button> only submits when you ask it to.
Server-side rendering
Asgard components render on the server with ekko:rune and hydrate on the client. A few notes:
Selectrenders only its trigger button during SSR (the dropdown/listbox opens on interaction), so it is
SSR-safe and hydrates without layout shift.
- The CSS-vars bridge is SSR-safe:
<ThemeCssVars/>andapplyThemeToRootare no-ops on the server (no
document) and apply once the client mounts. SSR styling still comes from the components themselves and
your initial SCSS.
- Components that need a live DOM at mount, canvas-based pickers (
ColorPicker's eyedropper checks
window), and drag-and-drop surfaces (Upload), guard their browser-only paths and light up after
hydration. If you wrap such a feature yourself, gate it behind a mounted/isBrowser check rather than
reading window/document during render.