Theming — light / dark / system
Translately ships with three theme modes: light, dark, and system. Every screen and component is designed and tested in all three. The user’s choice persists across reloads and cross-tab windows; while the mode is system, a live OS preference change flips the UI without a reload.
Introduced by: T114 · Ships in v0.1.0 · Source: webapp/src/theme/ThemeProvider.tsx, webapp/src/components/ThemeToggle.tsx.
Related: application shell, webapp architecture.
Using it
The theme toggle is the sun / moon / monitor icon in the top bar, right-hand group. It cycles through the three modes on click:
Light → Dark → System → Light …
(☼) (☾) (◌) (☼)
Hovering the button reveals an aria-label such as Light theme — click for Dark theme. Keyboard users reach it with Tab and activate it with Enter or Space.
Three ways your preference gets picked up:
- Explicit choice — set light or dark. That choice stays until you change it or clear site data. Cross-tab: every other open Translately tab reflects your choice inside a hundred ms.
- System choice — the shell follows your OS / browser
prefers-color-scheme. Change your OS theme and the app flips live; no reload required. - First visit — defaults to system. No flash-of-light is shown before the preference is applied because the initial paint runs after the provider reads
localStorage.
Design tokens
All themed colours flow through design-system CSS custom properties declared on :root for light and overridden under .dark for dark. Components never reference hex or rgb values directly — always hsl(var(--…)).
Canonical tokens (from webapp/src/index.css):
| Token | Role |
|---|---|
--background / --foreground |
Page background / primary text |
--card / --card-foreground |
Surface for cards and dropdown menus |
--popover / --popover-foreground |
Floating UI (tooltip, popover, menu) |
--primary / --primary-foreground |
Brand accent (buttons, links) |
--secondary / --secondary-foreground |
Secondary button / tag background |
--muted / --muted-foreground |
Subdued surface / secondary text |
--accent / --accent-foreground |
Active nav, selected row |
--destructive / --destructive-foreground |
Error, delete, revoke |
--border / --input / --ring |
Borders, form input border, focus ring |
--radius |
Component corner radius |
Adding a new colour role means adding both a light and a dark value (and a Tailwind colors entry that points at it). Never add a token you only define for one theme.
Accessibility
- Contrast — every surface/foreground pair meets WCAG 2.1 AA: ≥ 4.5 : 1 for text, ≥ 3 : 1 for icons and UI chrome. Automated with
axe-coreinApp.test.tsxunder both themes. - Reduced motion — any component that animates (dropdown open/close, theme transition) respects
prefers-reduced-motion. Transitions collapse to 0 ms rather than being removed outright so focus handling stays correct. - Focus rings — driven by
--ring; always visible in both themes, always ≥ 3 : 1 against the focused element. - Forced-colors — Tokens fall back to system colours under
forced-colors: active(Windows High Contrast). Avoid burning colours into SVGs; usecurrentColoror token-referenced styles.
Persistence
localStorage.getItem('translately.theme')→"light" | "dark" | "system".- On mount, the provider reads the value. Invalid / absent →
"system". - On
set, it writes back immediately. storageevents propagate the value to every other tab: a theme change in one tab updates siblings without a reload.
If localStorage throws (Safari private mode, some extensions), the provider silently falls back to "system" and no persistence is attempted — the UI still works.
How light / dark actually switches
ThemeProvider:
- Reads the saved preference on mount.
- Resolves the effective mode (system → follows
matchMedia('(prefers-color-scheme: dark)')). - Toggles a
darkclass on<html>— Tailwind’sdark:variants do the rest. - Registers a
matchMedialistener so OS-level changes are reflected while the user’s preference issystem.
The one unusual detail: the provider flips <html class="dark"> rather than using data-theme="dark". This plays nicely with Tailwind’s default darkMode: 'class' configuration and with the @media (prefers-color-scheme: …) queries that the GitHub Pages site (docs/index.html) uses.
Keyboard shortcuts
None dedicated yet. The toggle is reachable via Tab/Shift+Tab. A ⌘K-discoverable “Theme: Light / Dark / System” command will come with the command palette later in Phase 1.
Screenshots
Light + dark captures of the top bar with the toggle in each of the three states land here when the first native screenshot pass runs; filenames will follow the convention theming-{light|dark}-{state}.png.
Extending
- Add a colour role — add both
--fooand--foo-foregroundon:rootand.dark; wire intotailwind.config.tsundertheme.extend.colors. - Add a new theme (e.g. brand-branded dark blue) — add a new class name alongside
dark, extendThemeProvider’sThemeunion, and add a token override block. Keep"system"as a pseudo-value, not a token. - Use a theme in a test — wrap under
<ThemeProvider>. SeeThemeProvider.test.tsxfor fake-localStorage + fake-matchMedia patterns.
Changelog
First introduced in Unreleased (Phase 1). Ships with v0.1.0.