Dark mode is currently in an experimental phase and is not guaranteed to meet DGA compliance standards. Use in production at your own discretion.
Theme Switcher
Everything lives in one attribute on the root element: data-theme is a space-separated token list holding the mode (dark, with light as the default) and an optional theme name, so data-theme="dark crimson" is dark mode on the Crimson palette. Give any control a data-theme-value and it becomes a switcher: clicking it writes that theme token into data-theme (keeping the dark token), saves the choice, and syncs aria-current. Pick a theme and every component below updates.
<div class="nds-dropmenu">
<button class="nds-btn nds-secondary-outline nds-dropmenu-trigger">
<span class="nds-label">Theme</span>
</button>
<div class="nds-dropmenu-menu nds-theme-menu" hidden>
<div class="nds-dropmenu-scroll">
<button class="nds-btn nds-subtle nds-dropmenu-item" data-theme-value="">
<span class="nds-label">Default (DGA)</span>
</button>
<button class="nds-btn nds-subtle nds-dropmenu-item" data-theme-value="crimson">
<span class="nds-label">Crimson</span>
</button>
<button class="nds-btn nds-subtle nds-dropmenu-item" data-theme-value="corporate">
<span class="nds-label">Corporate</span>
</button>
<button class="nds-btn nds-subtle nds-dropmenu-item" data-theme-value="sunset">
<span class="nds-label">Sunset</span>
</button>
</div>
</div>
</div>
Dark Mode
Add data-theme-toggle to any button or switch and it is wired automatically: the click flips the dark token (keeping any active theme), persists the choice, swaps the moon and sun icon, and plays a circular reveal. Dark works on every palette.
<button class="nds-btn nds-subtle nds-icon-only"
data-theme-toggle aria-pressed="false" aria-label="Toggle dark mode">
<i class="nds-icon nds-hgi-moon-02" aria-hidden="true"></i>
</button>
<div class="nds-form-container nds-switch-container" data-theme-toggle>
<div class="nds-form-header" data-feedback-target>
<label for="dark-switch">
<span class="nds-label">Dark Mode</span>
<span class="nds-info">Switch between light and dark themes</span>
</label>
</div>
<div class="nds-form-control">
<div class="nds-switch nds-neutral">
<input type="checkbox" id="dark-switch" class="nds-switch-input" role="switch" aria-label="Dark mode">
<div class="nds-switch-track"><div class="nds-switch-thumb"></div></div>
</div>
</div>
</div>
Custom Palette
Define a complete theme from one to three seed colours. The OKLCH engine derives every ramp step from your seed with CSS relative colour, oklch(from var(--brand-primary) L C H): it keeps the seed's hue, sets a perceptually-even lightness, and scales the chroma, so the ramp stays balanced for any colour. Set the seeds plus the data-palette flag on the root element and the full light palette, the brand-tint alphas, a temperature-matched neutral scale, and the dark variant are all generated. Without the flag the page stays on the frozen DGA default. The engine needs CSS relative colour (Chrome 119, Safari 16.4, Firefox 128; mid-2024); older browsers fall back to the DGA default, so ship a stylesheet theme if you must brand them.
<!-- Seeds inline on the root element -->
<html data-palette style="--brand-primary: #7c3aed; --brand-secondary: #ec4899; --neutral-tint: 0.4;">
/* Add data-palette to <html>; only --brand-primary is required. */
:root[data-palette] {
--brand-primary: #7c3aed;
--brand-secondary: #ec4899;
--neutral-tint: 0.4;
}
// Set the seeds, then flip the data-palette flag.
const root = document.documentElement;
root.style.setProperty('--brand-primary', '#7c3aed');
root.style.setProperty('--brand-secondary', '#ec4899');
root.style.setProperty('--neutral-tint', '0.4');
root.setAttribute('data-palette', '');
Stylesheet Themes
For exact, contrast-checked colours, the widest browser support, or styling beyond colour (custom hero layouts, patterns, injected content), ship a theme as its own stylesheet that overrides the colour tokens at :root. In production you load it server-side as a render-blocking <link> after the NDS critical CSS. Override the full set of themeable tokens below; the semantic and component tokens re-resolve from them.
/* DGA values shown; replace with your brand ramp. Status + base stay on DGA. */
:root {
/* Primary ramp (anchor your brand colour at the 600 step) */
--colors-primary-25: #f7fdf9;
--colors-primary-50: #f3fcf6;
--colors-primary-100: #dff6e7;
--colors-primary-200: #b8eacb;
--colors-primary-300: #88d8ad;
--colors-primary-400: #54c08a;
--colors-primary-500: #25935f;
--colors-primary-600: #1b8354;
--colors-primary-700: #166a45;
--colors-primary-800: #14573a;
--colors-primary-900: #104631;
--colors-primary-950: #092a1e;
/* Brand-tint alphas (chips, table selection, footer) */
--colors-primary-alpha-10: #1b835419;
--colors-primary-alpha-20: #1b835433;
--colors-primary-alpha-30: #1b83544c;
--colors-primary-alpha-40: #1b835466;
--colors-primary-alpha-50: #1b83547f;
--colors-primary-alpha-60: #1b835499;
--colors-primary-alpha-70: #1b8354b2;
--colors-primary-alpha-80: #1b8354cc;
--colors-primary-alpha-90: #1b8354e5;
/* Secondary ramp */
--colors-secondary-25: #fffef7;
--colors-secondary-50: #fffef2;
--colors-secondary-100: #fffce6;
--colors-secondary-200: #fcf3bd;
--colors-secondary-300: #fae996;
--colors-secondary-400: #f7d54d;
--colors-secondary-500: #f5bd02;
--colors-secondary-600: #dba102;
--colors-secondary-700: #b87b02;
--colors-secondary-800: #945c01;
--colors-secondary-900: #6e3c00;
--colors-secondary-950: #472400;
/* Tertiary ramp (anchor at the 500 step) */
--colors-tertiary-25: #fefcff;
--colors-tertiary-50: #f9f5fa;
--colors-tertiary-100: #f2e9f5;
--colors-tertiary-200: #e1cce8;
--colors-tertiary-300: #ccadd9;
--colors-tertiary-400: #a57bba;
--colors-tertiary-500: #80519f;
--colors-tertiary-600: #6d428f;
--colors-tertiary-700: #532d75;
--colors-tertiary-800: #3d1d5e;
--colors-tertiary-900: #281047;
--colors-tertiary-950: #16072e;
--colors-tertiary-alpha-10: #80519f19;
--colors-tertiary-alpha-20: #80519f33;
/* Neutral ramp (grayscale; adds 750 + 850 steps) */
--colors-neutral-25: #fcfcfd;
--colors-neutral-50: #f9fafb;
--colors-neutral-100: #f3f4f6;
--colors-neutral-200: #e5e7eb;
--colors-neutral-300: #d2d6db;
--colors-neutral-400: #9da4ae;
--colors-neutral-500: #6c727e;
--colors-neutral-600: #4d5761;
--colors-neutral-700: #384250;
--colors-neutral-750: #2b3643;
--colors-neutral-800: #1f2a37;
--colors-neutral-850: #18212f;
--colors-neutral-900: #111927;
--colors-neutral-950: #0c111b;
/* Deep brand surfaces (hero / footer / image overlay) */
--background-primary-strong: var(--colors-primary-900);
--background-primary-light: var(--colors-primary-50);
--img-overlay-color: var(--colors-neutral-950);
}
/* Dark-mode corrections; double the :root if this sheet loads first. */
:root:root[data-theme~="dark"] {
--background-card: #1f2a37;
--background-footer: #0c111b;
}
<link rel="stylesheet" href="assets/themes/my-brand.css">
Built-in Features
Toggles and switchers wire themselves: drop in data-theme-toggle or data-theme-value controls and they work, no setup code.
The chosen mode and theme are saved to localStorage and restored on every page, so the choice follows the user across visits.
A saved mode or theme re-applies before the page is visible, so there is no colour flash on first load or return visit.
Switching mode or theme expands the new look outward from the clicked control in a circular ripple, with an instant swap where View Transitions are unsupported.
A built-in dark mode that works on every palette, with an automatic moon and sun icon swap on any toggle control.
Set one to three seed colours and a neutral-tint dial; the OKLCH engine derives the full ramp and the dark variant. Dark follows the seeds with no extra work.
Ship a full theme as its own stylesheet that overrides the colour tokens at the root, for bespoke palettes and visuals beyond the generated ramp.
Read or flip the mode with NDS.Theme.get/set/toggle, or apply a custom palette by setting --brand-* and the data-palette flag.
Usage Guidelines
Best Practices
- Most government sites need no setup: the DGA default is active out of the box. Add theming only when you need dark mode or your own brand colours
- Place a dark-mode button toggle in the Top Bar so it is reachable from every page; use the switch toggle on settings or preferences pages
- Reach for a custom palette (
data-palette+--brand-primary) when you want your brand colour fast and dark mode handled for you - Reach for a stylesheet theme when you need exact, contrast-checked colours, support for older browsers, or styling beyond colour (layouts, patterns, injected content); in production load it server-side as a render-blocking
<link>, rather than injecting at runtime - Use
NDS.Theme.toggle()/NDS.Theme.set()for mode changes instead of writingdata-themeby hand, so the active theme token is preserved - Reference semantic tokens (
--background-card,--text-default) in your own component CSS so it follows both mode and theme automatically - Do not hardcode colours in your CSS: hardcoded values respond to neither dark mode nor themes
- For a custom palette, check WCAG contrast (4.5:1 text, 3:1 UI) on your primary against white. The seed anchors the 600 step exactly, but the derived steps are perceptual approximations and are not contrast-guaranteed
- Scope your own dark overrides with
:root[data-theme~="dark"]: the word selector matches even when a theme token is also present, such as"dark crimson" - Test every component in both modes and across your themes whenever you add a surface
Data Attributes
| Attribute | Description |
|---|---|
data-theme-toggle | Place on any button or switch container to register it as a dark-mode toggle. Keeps aria-pressed, the moon and sun icon, and the checkbox state in sync. Flips only the dark token, preserving any active theme. |
data-theme | Set on the root element. A space-separated token list: the mode (dark; light is the default, no token) plus an optional theme name. Matched per token by the ~= word selector, so they coexist. |
data-theme-value | Set on a switcher item. On click the matching theme token is written into data-theme (preserving the dark token), the choice is saved, and aria-current syncs. An empty string restores the DGA default. |
data-palette + --brand-* | Set on the root element to activate the runtime OKLCH ramp from your inline seed variables (--brand-primary required; -secondary, -tertiary, --neutral-tint, --nds-font-brand, --font-weight-{regular,medium,semibold,bold} optional). With no flag the page is pure DGA. |
data-seed-* | Place on a switcher item (alongside data-theme-value) to apply a custom palette on click: data-seed-primary (required), plus optional data-seed-secondary, data-seed-tertiary, data-seed-tint, data-seed-font, data-seed-weight-{regular,medium,semibold,bold} (for a brand font that reads lighter or heavier than IBM Plex at the same nominal weight). The switcher sets the matching --brand-* seeds plus data-palette, then persists the palette so it restores on the next visit. |
data-theme-css | On a switcher item, the URL of a stylesheet theme's CSS, so a live multi-theme picker can load it on demand and unload it on switch-away. A single-theme production site links the stylesheet server-side instead. |
data-theme-js | On a switcher item (alongside data-theme-css), the URL of a JS event-pack for a stylesheet theme. Loaded once on first activation and re-executed on each switch-in so the theme can wire its own behaviour. Read by nds-theme.js at both click-time and reconciliation. |
CSS Custom Properties
Set these on the root element (with data-palette) for a custom palette, or at :root in a stylesheet theme.
| Property | Default | Description |
|---|---|---|
--brand-primary | DGA green | Seed for the primary colour family (anchors at the 600 step). Drives the OKLCH ramp. |
--brand-secondary | derived | Seed for the secondary family. Auto-derived from primary if omitted. |
--brand-tertiary | derived | Seed for the tertiary family (anchors at the 500 step). Auto-derived if omitted. |
--neutral-tint | 1 | Gray temperature dial (0 = true gray). Warms or cools the gray scale toward the primary hue. |
--nds-font-brand | IBM Plex Sans Arabic | Theme typeface stack, e.g. 'Cairo', sans-serif. The font must be loaded by the page. |
--font-weight-regular | 400 | Regular weight for the theme font. Set per-theme in _register.scss when the brand font reads lighter than IBM Plex at the same nominal weight (corporate and sunset themes use 300). |
--font-weight-medium | 500 | Medium weight for the theme font. |
--font-weight-semibold | 600 | Semibold weight for the theme font. |
--font-weight-bold | 700 | Bold weight for the theme font. |
--background-primary-strong | --colors-primary-900 | Deep brand surface used by the hero and footer. |
--background-primary-light | --colors-primary-25 | Light brand surface token set by the OKLCH ramp and also by full-override themes (such as Hajj). Used for subtle tinted backgrounds. |
--img-overlay-color | --colors-primary-950 | Colour of the translucent overlay on hero images. |
--colors-* | DGA palette | The full colour ramps (primary, secondary, tertiary, neutral, status). Override these directly in a stylesheet theme for exact values. |
JavaScript API
The NDS.Theme module initializes automatically and owns both axes. Use it to read or flip the dark and light mode; predefined themes are applied by the switcher (or by writing the data-theme token), and a custom palette by setting the seeds plus data-palette.