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.