Askr documentation
UI & Components

Theme Tokens, Modes, and Direction

Theme Tokens, Modes, and Direction: anatomy, keyboard behavior, state, and theming in Askr.

Example

import { Button, ThemeScope, ThemeToggle } from '@askrjs/themes/components';

<ThemeScope defaultTheme="system" storageKey="app-theme">
  <Button>Save project</Button>
  <ThemeToggle aria-label="Change color theme" />
</ThemeScope>

Purpose

Tokens are the single source of truth for color, spacing, radius, and every other visual value in the default theme, so changing an app's look is mostly a matter of overriding CSS custom properties rather than component code. Modes layer on top of tokens to switch between light, dark, system, and the shipped cat-themed presets, and a small `Direction` component handles setting the `dir` attribute on a scoped subtree.

Install and import

Pull in the default theme with `@import "@askrjs/themes/default";` in your app stylesheet, and add `@import "@askrjs/themes/presets";` if you want the additional cat theme family. For the JavaScript side, `import { ThemeScope, ThemeToggle, ThemePicker, theme } from '@askrjs/themes/theme'`, and `import { Direction } from '@askrjs/themes/components'`.

Live examples

Wrap your app in `<ThemeScope><ThemeToggle>{({ nextTheme }) => nextTheme}</ThemeToggle></ThemeScope>` to get a working light/dark/system toggle with zero extra state management. After importing the presets layer, set `data-theme="tabby"` (or `ginger`, `tuxedo`, `calico`, `torty`) on an ancestor element to switch to one of the cat theme presets.

Anatomy

`ThemeScope` establishes a reactive scope that tracks the current theme and persists it under a `storageKey`; the `theme()` function reads and writes that same scope from anywhere inside it. Token overrides are plain CSS — `:root { --ak-color-primary: purple; --ak-radius-md: 12px; }` — and apply globally or per-subtree depending on where you write the selector. `Direction` is a separate, much simpler piece: it just wraps its children, sets the `dir` attribute, and emits a `data-slot="direction"` hook.

State model

`theme()` returns a `ThemeScopeValue` with `theme()` to read the current value, `setTheme()` to change it, the available `themes` list, and the `storageKey` it persists under. That's the entire state surface — there's no separate dark-mode flag to manage independently, since dark is just one more value in the same `ThemeName` union alongside `light`, `system`, and the cat preset names.

Keyboard and accessibility

`ThemeToggle` is built on the real Button component (it accepts `ButtonNativeProps` minus `children` and `onPress`), so it's a genuine, keyboard-operable button by default rather than a custom clickable div. It intentionally ships with no built-in icons — you supply `lightIcon`, `darkIcon`, and `systemIcon` yourself, and the rendered content is wrapped in `data-slot="theme-toggle-content"` so mixed icon-and-text compositions stay stylable.

Styling and tokens

The token contract spans color, typography, spacing, radius, borders, shadows, focus, motion, icons, layout, and z-index, all under the `--ak-*` prefix — for example `--ak-color-primary`, `--ak-space-*`, `--ak-radius-*`, and `--ak-focus-*`. Icon sizing specifically resolves through `--ak-icon-size-sm|md|lg|xl` and `--ak-icon-stroke-width-sm|md|lg|xl`, which lets you retune icon scale globally without touching individual components.

API

`ThemeName` is `"light" | "dark" | "system" | CatThemeName | (string & {})`, where `CAT_THEME_NAMES` is the readonly tuple `["tabby", "ginger", "tuxedo", "calico", "torty"]`. `ThemeScopeProps` takes `children`, `defaultTheme`, `themes`, and `storageKey`; `ThemeToggleProps` adds `lightIcon`, `darkIcon`, `systemIcon`, and a `themes` list on top of the Button props it extends; `ThemePickerProps` wraps a native `<select>` minus `value`, `defaultValue`, and `onChange`. `DEFAULT_THEME_OPTIONS` and `CAT_THEME_OPTIONS` are the shipped option lists for each.

Edge cases

`Direction` is a thin wrapper that sets `dir` and a `data-slot` hook — it is not a full bidi engine, so getting mirrored layouts right in RTL still depends on how logical properties are used throughout your own CSS. Also watch `storageKey` collisions: if two apps sharing a domain both use the default key, their theme preferences will clobber each other in storage, so set an explicit `storageKey` per app.

See Customization for the broader selector and override contract these tokens participate in, and Structures for how layout tokens specifically feed into the Block system.