Theming
Enable light and dark themes, choose semantic colors, and apply an optional host accent.
3 min read
Semantic color tokens let a component keep the same classes in light and dark themes. Mount the shared theme provider, then choose colors by their purpose rather than their current appearance.
Enable theme switching
Installation mounts the provider with <AppShell theme>. Its default choice is system; ThemeProvider resolves the operating system preference and applies .dark to the document when needed. An explicit choice is saved under tale-theme in local storage.
import { ThemeSwitcher } from '@tale/ui/theme-switcher';
export function AppearanceControl() {
return <ThemeSwitcher />;
}Try the theme control in this page's header. Choose Dark, then Light, and compare the swatches below. Choose System to follow the operating system again.
Canonical family
bg-bg-basebg-bg-elevatedbg-bg-mutedbg-accent-basebg-success-bgbg-warning-bgbg-danger-bgbg-info-bg
HSL family
bg-backgroundbg-cardbg-mutedbg-primarybg-secondarybg-destructivebg-successbg-warning
ThemeSwitcher defaults to a menu. Its segmented variant presents the choices inline as one keyboard tab stop: use the left and right arrow keys to select System, Light, or Dark. Both variants provide 44px touch targets and must live inside the provider tree. The selected segment owns its background and border, so its highlight stays aligned if a host enlarges the controls. Use the component’s own sizing and colors rather than overriding its internal buttons or adding a separate selection marker.
Read the choice or the displayed result
import { useTheme } from '@tale/ui/theme';
export function ThemeSummary() {
const { theme, resolvedTheme, setTheme } = useTheme();
return (
<button type="button" onClick={() => setTheme('system')}>
Preference: {theme}; displayed theme: {resolvedTheme}
</button>
);
}theme is light, dark, or system. resolvedTheme is the resulting light or dark. Use the resolved value when selecting an image or chart palette. Querying prefers-color-scheme independently would ignore an explicit user choice.
The Tailwind dark: variant follows the .dark class. The provider also updates CSS color-scheme and briefly suppresses transitions during a switch. This avoids animating every color on the page at once.
Choose a token family consistently
The stylesheet exposes two supported families:
| Family | Example surface | Example secondary text |
|---|---|---|
| Canonical semantic tokens | bg-bg-base text-fg-base border-border-base | text-fg-muted |
| HSL-compatible aliases | bg-background text-foreground border-border | text-muted-foreground |
Follow the surrounding component's vocabulary. Both resolve through the shared stylesheet; neither requires a second set of light and dark classes at every call site. Colours maps common tokens to their uses.
When extending the token set, define the light and dark values together. Check the actual foreground/background pairing, including hover, disabled, error, and focus states. A semantic name does not by itself prove sufficient contrast.
Apply a host accent
AccentColorProvider supplies a runtime accent to components that opt into it, including route-tab indicators and selected sub-panel rows. They set it as the open item's text and icon color over a tint of itself (about 15%), so pass a shade that reads as text there: 4.5:1 on the page and on that tint, in each theme. A raw brand pick often falls short. #056CFF, for example, reads 4.45:1 on the light page and 3.6:1 on its tint, while the same hue at #0057d1 on light and #3388ff on dark reads on both:
import { AccentColorProvider } from '@tale/ui/accent-color';
import { useTheme } from '@tale/ui/theme';
import type { ReactNode } from 'react';
// The brand pick #056CFF, as a shade that reads as text in each theme.
const ACCENT_TEXT = { light: '#0057d1', dark: '#3388ff' } as const;
export function BrandAccent({ children }: { children: ReactNode }) {
const { resolvedTheme } = useTheme();
return (
<AccentColorProvider accentColor={ACCENT_TEXT[resolvedTheme]}>
{children}
</AccentColorProvider>
);
}The host supplies children and a validated shade for each theme; Tale's platform derives both from an organization's pick. Without the provider, participating components use their default treatment. The context does not recolor every component or replace all theme tokens; for example, Tabs uses its stylesheet classes directly.
Keep organization lookup and branding policy in the service. Review an accent on both themes before using it for a meaningful indicator.
Keep browser assets in sync
ThemeAssets, mounted inside the theme tree, updates the favicon and theme-color metadata to match an explicit theme choice. Your HTML must provide the elements it updates: favicon-light, favicon-dark, theme-color, and theme-color-dark.
If the page changes theme but its browser tab icon does not, inspect those IDs and the asset URLs. If only part of a page changes, look for hardcoded colors or an extra theme provider. Use the accessibility checks to verify contrast and reduced-motion behavior in the rendered page.