Skip to main content

Dark Mode

How the token system adapts for dark mode without duplicating component code.

First-class theming with light/dark parity

Every ePassLive component ships with a light and dark variant by default. Themes are not an afterthought layered onto a light interface — both palettes are designed, audited, and tested as part of the system.

Theme Toggle

Switch between light and dark mode. Your preference is saved under the app-wide epasslive-theme key; docs-theme is kept only as a legacy fallback.

 

Token mapping

All 70 color tokens below are consumed from the generated truth (_dark-mode.generated.ts), rebuilt from globals.css by pnpm --filter storefront docs:dark. Swatches show the literal generated values side by side; the stable badge marks tokens that keep the same value in both themes.

Brand

CSS variableLabelLight ModeDark Mode
--ep-tealep-teal
#009FCAW:3.08
#009FCAstable
--ep-blueep-blue
#0066FFW:4.83
#0066FFstable
--ep-cyanep-cyan
#06B6D4W:2.43
#06B6D4stable
--ep-amberep-amber
#D97706W:3.19
#D97706stable
--ep-indigoep-indigo
#8B5CF6W:4.23
#8B5CF6stable
--ep-pinkep-pink
#EC4899W:3.53
#EC4899stable

Text

CSS variableLabelLight ModeDark Mode
--ep-text-headingep-text-heading
#1A2332W:15.78
#F0F0F0D:15.27
--ep-text-bodyep-text-body
#4B5563W:7.56
#D1D5DBD:11.81
--ep-text-mutedep-text-muted
#696969W:5.49
#939393D:5.67
--ep-text-inverseep-text-inverse
#FFFFFFW:1.00
#FFFFFFstable
--ep-text-tealep-text-teal
#00708AW:5.70
#009FCAD:5.65
--ep-text-linkep-text-link
#0066FFW:4.83
#009FCAD:5.65

Surface

CSS variableLabelLight ModeDark Mode
--ep-bgep-bg
#FFFFFFW:1.00
#1A1A1AD:1.00
--ep-bg-secondaryep-bg-secondary
#F8FAFCW:1.05
#1E293BD:1.19
--ep-bg-tertiaryep-bg-tertiary
#F1F5F9W:1.10
#0F172AD:1.03
--ep-bg-3ep-bg-3
#EEEEEAW:1.16
#2D3748D:1.45
--ep-bg-page-darkep-bg-page-dark
#0a0a0aW:19.80
#0a0a0astable
--ep-surface-cardep-surface-card
#F8FAFCW:1.05
#1E293BD:1.19
--ep-surface-elevatedep-surface-elevated
#FFFFFFW:1.00
#252525D:1.14
--ep-surface-navyep-surface-navy
#1A2332W:15.78
#1A2332stable

Border

CSS variableLabelLight ModeDark Mode
--ep-borderep-border
#E5E7EBW:1.24
#2D3748D:1.45
--ep-border-subtleep-border-subtle
#F3F4F6W:1.10
#1E293BD:1.19
--ep-border-strongep-border-strong
#D1D5DBW:1.47
#3B4759D:1.85

Semantic

CSS variableLabelLight ModeDark Mode
--ep-successep-success
#16A34AW:3.30
#22C55ED:7.64
--ep-warningep-warning
#D97706W:3.19
#FBBF24D:10.43
--ep-errorep-error
#DC2626W:4.83
#EF4444D:4.62
--ep-infoep-info
#0284C7W:4.10
#38BDF8D:8.12
--ep-semantic-errorep-semantic-error
#DC2626W:4.83
#EF4444D:4.62
--ep-semantic-warningep-semantic-warning
#D97706W:3.19
#FBBF24D:10.43

Status tints

CSS variableLabelLight ModeDark Mode
--ep-semantic-success-tintep-semantic-success-tint
#D1FAE5W:1.13
#123527D:1.30
--ep-semantic-success-fgep-semantic-success-fg
#047857W:5.48
#6EE7B7D:11.42
--ep-semantic-success-tint-borderep-semantic-success-tint-border
#A7F3D0W:1.28
#1D4E38D:1.82
--ep-semantic-warning-tintep-semantic-warning-tint
#FEF3C7W:1.11
#3A2A08D:1.26
--ep-semantic-warning-fgep-semantic-warning-fg
#92400EW:7.09
#FCD34DD:12.07
--ep-semantic-warning-tint-borderep-semantic-warning-tint-border
#FDE68AW:1.25
#55400FD:1.76
--ep-semantic-error-tintep-semantic-error-tint
#FEE2E2W:1.22
#3B1516D:1.08
--ep-semantic-error-fgep-semantic-error-fg
#B91C1CW:6.47
#FCA5A5D:9.17
--ep-semantic-error-tint-borderep-semantic-error-tint-border
#FECACAW:1.45
#571E1FD:1.33
--ep-semantic-info-tintep-semantic-info-tint
#DBEAFEW:1.22
#122A4DD:1.21
--ep-semantic-info-fgep-semantic-info-fg
#1D4ED8W:6.70
#93C5FDD:9.65
--ep-semantic-info-tint-borderep-semantic-info-tint-border
#BFDBFEW:1.42
#1E3A5FD:1.51

Status backgrounds

CSS variableLabelLight ModeDark Mode
--ep-semantic-success-bgep-semantic-success-bg
#064E3BW:9.72
#064E3Bstable
--ep-semantic-error-bgep-semantic-error-bg
#7F1D1DW:10.02
#7F1D1Dstable
--ep-semantic-info-bgep-semantic-info-bg
#003E99W:9.77
#003E99stable

Button primary

CSS variableLabelLight ModeDark Mode
--ep-btn-primary-bgep-btn-primary-bg
#00708AW:5.70
#00708Astable
--ep-btn-primary-hoverep-btn-primary-hover
#00566BW:8.26
#008095D:3.75

Neutral

CSS variableLabelLight ModeDark Mode
--ep-neutral-500ep-neutral-500
#6B7280W:4.83
#9CA3AFD:6.86

Chart series

CSS variableLabelLight ModeDark Mode
--chart-merchchart-merch
#F59E0BW:2.15
#F59E0Bstable
--chart-ticketchart-ticket
#0066FFW:4.83
#0066FFstable
--chart-packagechart-package
#8B5CF6W:4.23
#8B5CF6stable
--chart-passchart-pass
#009FCAW:3.08
#009FCAstable
--chart-freechart-free
#EC4899W:3.53
#EC4899stable

Barcode

CSS variableLabelLight ModeDark Mode
--ep-barcode-fgep-barcode-fg
#0A0A0AW:19.80
#FFFFFFD:17.40
--ep-barcode-bgep-barcode-bg
#FFFFFFW:1.00
#0A0A0AD:1.14
--ep-barcode-quiet-zoneep-barcode-quiet-zone
#FFFFFFW:1.00
#0A0A0AD:1.14

Overlay & scrims

CSS variableLabelLight ModeDark Mode
--ep-overlay-traceep-overlay-trace
rgba(255, 255, 255, 0.02)W:1.00
rgba(255, 255, 255, 0.02)stable
--ep-overlay-faintep-overlay-faint
rgba(255, 255, 255, 0.03)W:1.00
rgba(255, 255, 255, 0.03)stable
--ep-overlay-softep-overlay-soft
rgba(255, 255, 255, 0.05)W:1.00
rgba(255, 255, 255, 0.05)stable
--ep-overlay-mediumep-overlay-medium
rgba(255, 255, 255, 0.10)W:1.00
rgba(255, 255, 255, 0.10)stable
--ep-overlay-scrimep-overlay-scrim
rgba(10, 10, 10, 0.50)W:19.80
rgba(10, 10, 10, 0.50)stable
--ep-overlay-border-subtleep-overlay-border-subtle
rgba(255, 255, 255, 0.10)W:1.00
rgba(255, 255, 255, 0.10)stable
--ep-overlay-border-mediumep-overlay-border-medium
rgba(255, 255, 255, 0.15)W:1.00
rgba(255, 255, 255, 0.15)stable
--ep-overlay-text-strongep-overlay-text-strong
#FFFFFFW:1.00
#FFFFFFstable
--ep-overlay-text-mutedep-overlay-text-muted
rgba(255, 255, 255, 0.70)W:1.00
rgba(255, 255, 255, 0.70)stable
--ep-overlay-text-subtleep-overlay-text-subtle
rgba(255, 255, 255, 0.40)W:1.00
rgba(255, 255, 255, 0.40)stable
--ep-overlay-text-iconep-overlay-text-icon
rgba(255, 255, 255, 0.30)W:1.00
rgba(255, 255, 255, 0.30)stable
--ep-overlay-scrim-lightep-overlay-scrim-light
rgba(10, 10, 10, 0.30)W:19.80
rgba(10, 10, 10, 0.30)stable
--ep-gradient-scrimep-gradient-scrim
linear-gradient(to top, rgba(10, 10, 10, 0.85), rgba(10, 10, 10, 0.55) 55%, rgba(10, 10, 10, 0.15))W:19.80
linear-gradient(to top, rgba(10, 10, 10, 0.85), rgba(10, 10, 10, 0.55) 55%, rgba(10, 10, 10, 0.15))stable
--ep-qr-bgep-qr-bg
#FFFFFFW:1.00
#FFFFFFstable

Focus ring

CSS variableLabelLight ModeDark Mode
--ep-focus-ringep-focus-ring
0 0 0 2px #FFFFFF, 0 0 0 4px #009FCAW:3.08
0 0 0 2px #1A1A1A, 0 0 0 4px #009FCAD:5.65

Behaviors beyond color swaps

Some dark-mode adaptations go beyond swapping a hex value — they change how a value renders or resolves. They are documented here so they are not mistaken for bugs:

  • Shadow halo (EX-QA-PASS-7a): all five shadow tokens gain an inset white halo in dark mode so elevation stays visible on #1A1A1A surfaces.
  • Focus-ring inner flip: --ep-focus-ring flips its inner ring from #FFFFFF to #1A1A1A in dark mode, while the outer 4px teal ring stays stable.
  • Barcode full inversion: --ep-barcode-fg and --ep-barcode-bg swap foreground and background in dark mode so scanners always read dark bars on a light field; the tone prop can override this for printed badges.
  • Text-link swap: --ep-text-link switches from #0066FF to #009FCA in dark mode because blue on #1A1A1A reaches only 3.60:1 — below the AA threshold — while the teal passes at 5.65:1.
  • Semantic alias indirection: --ep-semantic-error carries an identical definition in both theme blocks, yet resolves differently through var() chains — the aliasing, not a duplicated value, carries the dark behavior.

Theme-stable non-color tokens

The remaining 60 tokens (radii, spacing, fonts, font sizes, transitions, z-index, motion, tap-min) are defined only in :root — theme-stable by inheritance, not by duplication.

TokenValueCategory
--ep-radius-none0radius
--ep-radius-sm0.25remradius
--ep-radius-md0.5remradius
--ep-radius-lg0.75remradius
--ep-radius-xl1remradius
--ep-radius-2xl1.5remradius
--ep-radius-full9999pxradius
--ep-space-10.25remspacing
--ep-space-20.5remspacing
--ep-space-30.75remspacing
--ep-space-41remspacing
--ep-space-51.25remspacing
--ep-space-61.5remspacing
--ep-space-82remspacing
--ep-space-102.5remspacing
--ep-space-123remspacing
--ep-space-164remspacing
--ep-space-205remspacing
--ep-space-246remspacing
--ep-space-xxs0.25remspacing
--ep-space-xs0.5remspacing
--ep-space-sm0.75remspacing
--ep-space-md1remspacing
--ep-space-lg1.5remspacing
--ep-space-xl2remspacing
--ep-space-xxl3remspacing
--ep-space-3xl4remspacing
--ep-space-4xl6remspacing
--ep-font-text-primaryvar(--font-inter), Inter, system-ui, -apple-system, sans-seriffont
--ep-font-text-secondaryvar(--font-display), "Space Grotesk", Inter, system-ui, sans-seriffont
--ep-font-text-codevar(--font-jetbrains), "JetBrains Mono", ui-monospace, monospacefont
--ep-font-sansvar(--font-inter), Inter, system-ui, -apple-system, sans-seriffont
--ep-font-displayvar(--font-display), "Space Grotesk", Inter, system-ui, sans-seriffont
--ep-font-monovar(--font-jetbrains), "JetBrains Mono", ui-monospace, monospacefont
--ep-font-size-xs0.75remfont-size
--ep-font-size-sm0.875remfont-size
--ep-font-size-base1remfont-size
--ep-font-size-lg1.125remfont-size
--ep-font-size-xl1.25remfont-size
--ep-font-size-2xl1.5remfont-size
--ep-font-size-3xl1.875remfont-size
--ep-font-size-4xl2.25remfont-size
--ep-transition-fast150ms cubic-bezier(0.4, 0, 0.2, 1)transition
--ep-transition-normal200ms cubic-bezier(0.4, 0, 0.2, 1)transition
--ep-transition-slow300ms cubic-bezier(0.4, 0, 0.2, 1)transition
--ep-z-base0z-index
--ep-z-dropdown30z-index
--ep-z-sticky40z-index
--ep-z-overlay50z-index
--ep-z-modal60z-index
--ep-z-popover65z-index
--ep-z-toast70z-index
--ep-z-skip-link100z-index
--ep-duration-fast150msmotion
--ep-duration-normal200msmotion
--ep-duration-slow300msmotion
--ep-ease-outcubic-bezier(0, 0, 0.2, 1)motion
--ep-ease-incubic-bezier(0.4, 0, 1, 1)motion
--ep-ease-in-outcubic-bezier(0.4, 0, 0.2, 1)motion
--ep-tap-min44pxtap-min

WCAG 2.2 contrast verification

Dark-mode text pairs recomputed at render time from the generated token values (light-side pairs live on the Colors page).

SurfaceText tokenRatioAA (≥4.5:1)AAA (≥7:1)
#1A1A1A — --ep-bg--ep-text-heading #F0F0F015.27:1✓ pass✓ pass
#1A1A1A — --ep-bg--ep-text-body #D1D5DB11.81:1✓ pass✓ pass
#1A1A1A — --ep-bg--ep-text-muted #9393935.67:1✓ pass— AA only
#1A1A1A — --ep-bg--ep-text-teal #009FCA5.65:1✓ pass— AA only
#1A1A1A — --ep-bg--ep-text-inverse #FFFFFF17.40:1✓ pass✓ pass
#1E293B — --ep-surface-card--ep-text-muted #9393934.76:1✓ pass— AA only
#1E293B — --ep-surface-card--ep-text-teal #009FCA4.75:1✓ pass— AA only

All pairs pass WCAG 2.2 AA (≥4.5:1 normal text). Exactly two pairs sit within 0.33 of the AA threshold: --ep-text-muted on #1E293B (4.76:1, 0.26 above) and --ep-text-teal on #1E293B (4.75:1, 0.25 above). Both were tightened in v3.1.2 (DS-CORR-00-03) to clear AA — watch them on regressions; no further low-contrast drift is permitted.

Implementation

Dark mode uses CSS custom properties. The data-theme="dark" attribute on the html element triggers the dark token values.

How the dark theme is triggered

  • The runtime trigger is the data-theme attribute on document.documentElement — not a class toggle. The .dark class is never set anywhere in the live shell (see theme-init-script.tsx).
  • The theme-init script runs before React hydration to prevent a flash of light content (FOUC).
  • The user preference persists in localStorage under the epasslive-theme key; the resolved theme drives every --ep-* token through the [data-theme="dark"] cascade.
  • Brand colors (teal, blue, cyan, amber, indigo, pink) and chart series are theme-stable: same hex in both modes by design.

Implementation checklist

  • Apply data-theme="dark" as early as possible to avoid a flash of light content (FOUC).
  • Use the dark: Tailwind variants that map to the same semantic tokens.
  • Audit shadows separately — the same --ep-shadow-md token resolves to a heavier alpha in dark mode.
  • Verify focus rings remain visible against dark surfaces.

What to avoid

  • Hardcoding colors. Always reach for a semantic token.
  • Branching on prefers-color-scheme inside a component. Branch on the token, not the theme.
  • Inverting grays automatically. The dark palette was hand-tuned to keep contrast above WCAG AA.
  • Forgetting any state. Test hover, focus-visible, disabled, and error in dark mode.

Architectural reality

  • Storefront marketing shell: theme-adaptive, not dark-only — every surface uses semantic tokens (bg-ep-bg, text-ep-text-heading…) that follow the html data-theme attribute. The toggle lives in SiteHeaderV2 and the app-wide ThemeProvider defaults to dark; the chrome (header, drawer, footer) follows the global theme on every route per the amended ADR-062 (2026-11-04).
  • Admin dashboard (packages/storefront/src/components/dashboard/): shares the app-wide ThemeProvider; ThemeContext re-publishes it to ThemeToggle and DashboardShell mirrors data-theme onto the .ep-admin wrapper.
  • Docs shell (DocsThemeToggle.tsx): writes data-theme on the html element globally — there is no .ep-docs scoped wrapper. The docs shell reads epasslive-theme first and uses docs-theme only as a legacy fallback; the toggle writes both keys.
  • Client portal (packages/client-portal): ships an independent PortalThemeToggle that persists under its own epasslive-portal-theme key.

Scoped theming

A subtree can go dark while the page stays light. The data-theme attribute on any element re-roots the token cascade for its whole subtree (ADR-062).

  • No storefront surface is pinned dark today: the homepage v2 lane (home + market pages) follows the global theme since 2026-11-04 (ADR-062 Amendment 2) — its colour roles are declared light/dark in home-v2.css — and the SearchSheet uses themeScope="inherit" (ADR-063). The mechanism documented here remains available for future brand bands.
  • The chrome follows the global theme: since the 2026-11-04 ADR-062 amendment, the site header, mobile drawer and footer left the band and follow the global theme on every route via the swapping --home-chrome-* layer in home-v2.css (docs/design/home-v2-deviations.md §1.1).
  • Contrast is verified per effective background (ADR-065): text on a surface inside a scoped root is measured against that root, whatever the page-level theme is.
Design System v4.1.0 | ePassLive