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

ValueWhat it is
(unset) / lightThe default: blue-tinted page, translucent widget surfaces, soft shadows.
darkDeep blue-grey page; widgets become a translucent lightening; accents lift for contrast.
high-contrastFlat 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.

Sample surface

A normal link A visited link (points at this page, so it is genuinely :visited)

Info message

Uses the info semantic set — tint and text move together per theme.

3 active pending failed beta ARCHIVED

Visited links deliberately keep the link color: there is no separate visited token; the a rule colors both states via --soma-color-link.

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):

GroupTokens 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.