Themes
Summary
Three themes — light (the default),
dark and high-contrast —
switched by one attribute on <html> at
runtime: data-soma-theme. No rebuild, no alternate
stylesheet, no component-level changes: component CSS is
theme-blind and consumes --soma-* tokens only, so
a theme is nothing but a token block and a
custom theme is one CSS rule.
Theme is one of Soma's runtime presentation axes; the siblings
have their own docs: density
(comfortable / cosy / compact), direction (LTR / RTL; the
Right-to-left section below) and strings
(Soma.i18n(); the i18n
page). Use the corner buttons on any page in this sandbox to
cycle all of them and watch every component follow.
Themes
| Value | What it is |
|---|---|
(unset) / light | The default: blue-tinted page, translucent widget surfaces, soft shadows. |
dark | Deep blue-grey page; widgets become a translucent lightening; accents lift for contrast. |
high-contrast | Flat white page, ≥7:1 text everywhere, outline-ring "shadows", links underlined. Trades the airy look for legibility. |
Sample surface
Everything brand- and semantic-colored in one block, so switching themes shows the full blast radius: buttons (including hover, disabled and the link variant), links, focus rings, a message accent, and the badge families. Pick a theme — the whole sandbox recolors live, and the choice sticks across pages while you browse (same store the corner cycler uses). Theme switches animate via the View Transitions API where the browser supports it: a progressive enhancement, skipped under reduced motion.
HTML
Purely declarative. Set the attribute on the root, or on any subtree (tokens cascade, so everything inside follows):
<html data-soma-theme="dark"> <!-- whole page -->
<div data-soma-theme="high-contrast"> <!-- just this subtree -->
…
</div>
JavaScript
Soma.theme(); // → current theme name ('light' if unset)
Soma.theme('dark'); // switch
Soma.theme('high-contrast'); // switch
Soma.theme('light'); // back to the default (removes the attribute)
Soma.theme() just manages the attribute. Unknown
names are allowed, so a custom theme needs no JS registration:
Soma.theme('acme') simply sets
data-soma-theme="acme".
Custom themes
A theme is nothing but a CSS block overriding tokens under a
[data-soma-theme='name'] selector; the built-in
dark and high-contrast blocks in
src/css/_tokens.scss are the reference. A worked
example:
[data-soma-theme='acme'] {
color-scheme: light; /* keeps native controls/scrollbars matching */
/* Brand */
--soma-color-primary: #1f6f4a;
--soma-color-primary-fg: #ffffff;
--soma-color-primary-hover: #185c3d;
--soma-color-primary-active: #145034;
--soma-color-primary-subtle: #def0e7;
--soma-color-primary-subtle-fg: #145034;
--soma-color-focus: #1f6f4a;
--soma-focus-ring: 0 0 0 3px rgba(31, 111, 74, 0.35);
/* Page & text */
--soma-color-page: #d8ebdf;
--soma-page-background: linear-gradient(150deg, #e8f5ec 0%, #d8ebdf 45%, #c6e2d1 100%);
}
Anything omitted falls back to the light theme's values: they
are declared on :root, so a custom block only needs
the tokens it actually changes. Some tokens follow automatically:
--soma-color-link is defined as
var(--soma-color-primary), so overriding the primary
re-colors links too unless you override the link token yourself.
The theming surface — the tokens the built-in
dark block restates, and therefore the set a full
custom theme should review (58 custom properties, 59 with the
optional --soma-link-decoration, plus the
color-scheme declaration):
| Group | Tokens a theme may override |
|---|---|
| Scheme | color-scheme (a declaration, not a token): set light or dark so native form controls and scrollbars match. |
| Brand | --soma-color-primary, --soma-navbar-primary-bg/-fg (the branded navbar strip), -primary-fg, -primary-hover, -primary-active, -primary-subtle, -primary-subtle-fg, --soma-color-link, --soma-link-decoration (optional; none by default, underline in high-contrast), --soma-color-focus, --soma-focus-ring. |
| Page & text | --soma-color-page, --soma-page-background, --soma-color-text, -text-muted, -text-subtle, --soma-color-border, -border-strong. |
| Surfaces | --soma-surface, -surface-faint, -surface-solid, -surface-raised, -surface-overlay, -surface-inset, -surface-backdrop. |
| Shadows | --soma-shadow-1, -shadow-2, -shadow-3. |
| Semantic sets | Five sets (neutral, success, warning, danger, info), each with base fill, -hover, -active (state shades, color-mix-derived per theme), -fg, -subtle and -subtle-fg (e.g. --soma-color-success, --soma-color-success-hover, --soma-color-success-active, --soma-color-success-fg, --soma-color-success-subtle, --soma-color-success-subtle-fg). |
The static scales (type, spacing, radius, density) are deliberately not part of the surface: no theme changes them, so layout and rhythm stay identical across themes. Full token-by-token reference on the Design tokens page. One responsibility comes with the recipe: Soma's built-in themes are audited so accents hold AA contrast as text on their surfaces — a custom palette must keep that promise for its own values.
Following the OS contrast preference
Users who ask their OS for higher contrast surface it to the
page as the prefers-contrast: more media query,
a natural fit for the high-contrast theme. CSS alone cannot make
that mapping: a media query can't set an attribute, and Soma's
themes are attribute-driven token blocks. The bridge is a
three-line matchMedia listener in your page script:
// Opt-in recipe: follow the OS preference when no explicit theme is set.
const contrast = matchMedia('(prefers-contrast: more)');
const followOS = () => {
if (contrast.matches && !document.documentElement.hasAttribute('data-soma-theme')) {
Soma.theme('high-contrast');
}
};
followOS();
contrast.addEventListener('change', followOS);
This is a recipe, not a behaviour: Soma never switches
themes on its own; whether the OS preference should win
is your product's call, so the mapping stays opt-in. The
no-attribute guard keeps an explicit choice authoritative: a
saved user preference or a subtree pin sets
data-soma-theme, and the mapping then stays out of
the way. It is also deliberately one-way: once applied, the
attribute counts as an explicit choice; if your app stores its
own theme preference, run the recipe only while that store is
empty. (Distinct from forced colors, where Windows High
Contrast repaints unconditionally at the OS level; see the
Accessibility page.)
Density
Density (comfortable / cosy / compact via
data-soma-density) has its own page, with the mode
table, token mechanics, live switcher and the compact-mode
target-size guarantees: Density.
Right-to-left
Set dir="rtl" on <html> (or any
subtree) and every component mirrors: Soma's CSS uses logical
properties throughout, with explicit
:dir(rtl) overrides for the handful of things
logical properties can't express (the switch thumb's travel, the
drawer's slide direction, chevron glyphs, the date picker's
arrows). No extra classes, no separate stylesheet.
Strings
Soma ships 42 built-in locale packs for the strings its
components render themselves, with locale selection from
Soma.i18n({locale}) → <html lang>
→ English, per-key overrides, and register() for
custom packs. The full API, the key catalog, and a live locale
switcher are on the i18n page.
Adding a token
Any new colour-derived token must be declared in
all three theme blocks in
_tokens.scss. The e2e axe sweep runs every page in
every theme, so a token missing from one theme fails CI rather
than shipping.