Skip to content
HELiX

Theming Quick Start

apps/docs/src/content/docs/extending/theming-quick-start Click to copy
Copied! apps/docs/src/content/docs/extending/theming-quick-start

HELiX components expose a structured set of --hx-* CSS custom properties that let you rebrand the entire library, theme individual page sections, and toggle dark or high-contrast modes — all without modifying component source or fighting Shadow DOM.

This guide walks you from zero to a complete brand theme file in the order most teams need it.


All --hx-* tokens belong to one of three tiers. Understanding this hierarchy is the single most important concept for effective theming.

Primitive tier --hx-color-primary-500: #2563eb (raw values)
Semantic tier --hx-color-text-primary: var(--hx-color-neutral-900) (meaning)
Component tier --hx-button-bg: var(--hx-color-primary-500) (per-component)
TierExamplesPurposeWhen to override
Primitive--hx-color-primary-500, --hx-space-4Raw brand values: hex colors, rem sizes, font namesReplace to swap the raw brand palette
Semantic--hx-color-text-primary, --hx-color-surface-defaultContextual meaning: text, surfaces, borders, focusReplace to theme a region (dark, high-contrast, brand)
Component--hx-button-bg, --hx-card-paddingPer-component overridesReplace for surgical customization of one component

When the browser paints an hx-button, it resolves the background like this:

hx-button background
--hx-button-bg (component tier, defined inside hx-button's shadow root)
var(--hx-color-action-primary-bg) (semantic tier — what variant rules consume)
var(--hx-color-primary-700) (primitive tier — the action token's default)
brand-specific hex (e.g. Apex #0F6363)

Brand swaps that override --hx-color-primary-700 (or any other stop in the ramp) propagate up through --hx-color-action-primary-bg and into every variant rule automatically. The 3.4.0 cascade aligned action.primary.bg → primary-700 so every brand clears WCAG 1.4.6 (7:1) on default buttons.

The key insight: components reference component-tier tokens with semantic or primitive fallbacks. Override at any level in the chain and all downstream consumers update automatically.

See Token Tiers for the full tier reference.


Semantic-Level Override: Library-Wide Rebranding

Section titled “Semantic-Level Override: Library-Wide Rebranding”

Override the primary color ramp at the semantic (primitive) level to rebrand every component at once. This is the correct approach for a full brand theme.

Why semantic level means everything updates

Section titled “Why semantic level means everything updates”

hx-button, hx-badge, hx-pagination, hx-checkbox, hx-text-input focus rings, hx-link, hx-tabs active state, hx-breadcrumb links — every component that consumes --hx-color-action-primary-bg, --hx-color-text-link, or another semantic token in the primary chain reflects the new values. You change one ramp; the entire primary surface follows. (Components that don’t consume primary — like neutral-only hx-dialog header styling — stay on their own ramp.)

The primary ramp runs from -50 (near-white tint) to -950 (near-black shade). Override all stops to ensure hover states, disabled states, and dark-mode variants all have intentional values rather than mismatched defaults.

/* my-brand.css — primary color ramp override */
:root {
/* Primitive tier: raw brand values */
--hx-color-primary-50: #eff6f8;
--hx-color-primary-100: #d0e9ef;
--hx-color-primary-200: #a3d3df;
--hx-color-primary-300: #6eb8c9;
--hx-color-primary-400: #3a9db4;
--hx-color-primary-500: #006e8a; /* primary action color */
--hx-color-primary-600: #005f78;
--hx-color-primary-700: #004f65;
--hx-color-primary-800: #003f52;
--hx-color-primary-900: #002f3e;
--hx-color-primary-950: #001f2a;
}

After this override, every component that uses var(--hx-color-primary-500) — directly or through a semantic fallback — switches to the new brand teal.

To also adjust semantic-tier aliases (text on primary, focus borders), add:

:root {
/* Primary ramp (from above) ... */
/* Semantic overrides that reference the primary ramp */
--hx-color-border-focus: var(--hx-color-primary-500);
--hx-color-text-link: var(--hx-color-primary-600);
--hx-color-text-link-hover: var(--hx-color-primary-700);
}

Component-Level Override: Surgical Customization

Section titled “Component-Level Override: Surgical Customization”

When you want to change one component without affecting others, override its component-tier token on the element selector.

The hx-button shadow root re-defines --hx-button-bg inside each variant rule (e.g. the primary selector sets --hx-button-bg: var(--hx-color-action-primary-bg, …)), so a host-level override of --hx-button-bg alone is shadowed by those internal rules. To recolor the resting state, target the semantic action token instead:

/* Recolor every primary hx-button via the semantic action token */
hx-button[variant='primary'] {
--hx-color-action-primary-bg: var(--hx-color-primary-800);
}

Use the component-tier --hx-button-bg when you need to drive a non-variant state from outside (e.g. for an experimental custom selector) — it works on variant="ghost"/"outline" which read from --hx-button-bg directly without an internal variant rule.

/* Override only danger buttons */
hx-button[variant='danger'] {
--hx-button-bg: #b91c1c;
}
/* Override buttons inside a specific section */
.sidebar hx-button {
--hx-button-bg: var(--hx-color-neutral-700);
}
<hx-button style="--hx-button-bg: #7c3aed;"> Schedule Appointment </hx-button>

Component-tier overrides follow standard CSS specificity. The most specific selector wins.

See Customization for the full component token catalog.


<hx-theme> is a zero-layout infrastructure component that injects a full set of --hx-* tokens into its subtree. Wrap any region of your page with it to scope a named theme.

<!-- The entire subtree gets dark-mode --hx-* tokens -->
<hx-theme theme="dark">
<hx-card>
<h3 slot="heading">Medication Summary</h3>
<hx-button>Administer</hx-button>
</hx-card>
</hx-theme>
ValueBehavior
lightStandard light-mode token set (default)
darkDark-mode semantic overrides applied on top of light primitives
high-contrastWCAG 7:1+ contrast token set for low-vision users
autoFollows prefers-color-scheme; resolves to light or dark at runtime

hx-theme has display: contents — it inserts no wrapper element into layout. There is no box, no margin, no padding.

When theme="auto", the actual resolved value (light or dark) is available via the effectiveTheme getter:

const themeEl = document.querySelector('hx-theme') as HTMLElement & {
effectiveTheme: 'light' | 'dark' | 'high-contrast';
};
console.log(themeEl.effectiveTheme); // 'dark' if OS is dark mode
<body>
<header>
<hx-theme theme="dark">
<nav>
<hx-button variant="ghost">Dashboard</hx-button>
<hx-button variant="ghost">Patients</hx-button>
</nav>
</hx-theme>
</header>
<main>
<!-- main content uses whatever tokens :root provides -->
<hx-card>...</hx-card>
</main>
</body>

Nest themes: dark sidebar inside a light page

Section titled “Nest themes: dark sidebar inside a light page”

Themes compose. Inner hx-theme elements override the outer scope cleanly because each injects its tokens into its own Shadow DOM’s adopted stylesheet.

<hx-theme theme="light">
<main>
<hx-card>Patient record panel</hx-card>
</main>
<hx-theme theme="dark">
<aside>
<!-- These components receive dark tokens,
even though the page root is light -->
<hx-card>Navigation sidebar</hx-card>
<hx-button>Log Out</hx-button>
</aside>
</hx-theme>
</hx-theme>

The inner hx-theme does not bleed into the outer scope. Switching the outer theme to high-contrast will not affect the inner dark region unless you change it explicitly.


When you cannot wrap a region with hx-theme — for example, if a CMS controls the DOM structure — apply overrides on a CSS class or container selector instead.

/* Only components inside .brand-section get these overrides */
.brand-section {
--hx-color-primary-500: #006e8a;
--hx-color-primary-600: #005f78;
--hx-color-border-focus: var(--hx-color-primary-500);
}
<div class="brand-section">
<hx-button>Book Appointment</hx-button>
<!-- teal -->
<hx-text-input label="Patient Name"></hx-text-input>
<!-- teal focus ring -->
</div>
<div>
<hx-button>Default Action</hx-button>
<!-- original blue -->
</div>

CSS custom properties inherit through the DOM. Any hx-* component inside .brand-section picks up the override automatically — even components added dynamically after the page loads.

/* Apply to a specific Drupal region */
.region-sidebar-first {
--hx-color-primary-500: #4f46e5;
--hx-color-surface-default: #f8f7ff;
}
/* Apply to a specific content type */
.node--type-clinical-alert {
--hx-color-primary-500: #b91c1c;
--hx-color-border-default: var(--hx-color-primary-500);
}

HELiX ships dark-mode tokens through hx-theme at the component level. For global dark mode in your application stylesheet — affecting all components not wrapped in an hx-theme — use either the attribute pattern or the media query pattern.

Apply data-theme="dark" to <html> (or any ancestor element) and override the semantic tier:

[data-theme='dark'] {
/* Text */
--hx-color-text-primary: var(--hx-color-neutral-100);
--hx-color-text-secondary: var(--hx-color-neutral-300);
--hx-color-text-muted: var(--hx-color-neutral-400);
--hx-color-text-link: var(--hx-color-primary-400);
/* Surfaces */
--hx-color-surface-default: var(--hx-color-neutral-900);
--hx-color-surface-raised: var(--hx-color-neutral-800);
--hx-color-surface-sunken: var(--hx-color-neutral-950);
/* Borders */
--hx-color-border-default: var(--hx-color-neutral-700);
--hx-color-border-subtle: var(--hx-color-neutral-800);
/* Body */
--hx-body-bg: var(--hx-color-surface-default);
--hx-body-color: var(--hx-color-text-primary);
/* Shadows — darken less against dark backgrounds */
--hx-shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.4);
--hx-shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.4), 0 2px 4px -2px rgb(0 0 0 / 0.3);
--hx-shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.5), 0 4px 6px -4px rgb(0 0 0 / 0.3);
}

Toggle it in JavaScript:

// Enable dark mode
document.documentElement.dataset.theme = 'dark';
// Return to light mode
delete document.documentElement.dataset.theme;
// Read current mode
const isDark = document.documentElement.dataset.theme === 'dark';

Method 2: prefers-color-scheme media query

Section titled “Method 2: prefers-color-scheme media query”

Automatic dark mode that respects the OS preference, with a light-mode escape hatch:

@media (prefers-color-scheme: dark) {
/* Apply unless the user has explicitly chosen light mode */
:root:not([data-theme='light']) {
--hx-color-text-primary: var(--hx-color-neutral-100);
--hx-color-text-secondary: var(--hx-color-neutral-300);
--hx-color-text-muted: var(--hx-color-neutral-400);
--hx-color-text-link: var(--hx-color-primary-400);
--hx-color-surface-default: var(--hx-color-neutral-900);
--hx-color-surface-raised: var(--hx-color-neutral-800);
--hx-color-surface-sunken: var(--hx-color-neutral-950);
--hx-color-border-default: var(--hx-color-neutral-700);
--hx-color-border-subtle: var(--hx-color-neutral-800);
--hx-body-bg: var(--hx-color-surface-default);
--hx-body-color: var(--hx-color-text-primary);
--hx-shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.4), 0 2px 4px -2px rgb(0 0 0 / 0.3);
}
}

The :not([data-theme='light']) guard means: if the user has explicitly set light mode via your theme toggle, the media query does not override their choice.

Use both together to support explicit user preference AND OS preference:

/* Explicit dark mode — highest priority */
[data-theme='dark'] {
/* dark overrides */
}
/* OS dark mode — applies when no explicit choice is set */
@media (prefers-color-scheme: dark) {
:root:not([data-theme='light']) {
/* same dark overrides */
}
}

See Theming for the full dark token reference.


theme="high-contrast" on hx-theme activates a WCAG 7:1+ contrast token set designed for low-vision users. This is a healthcare-specific accessibility feature.

High-contrast mode is not dark mode. It is an accessibility accommodation:

  • Black backgrounds (#000000), white text (#FFFFFF)
  • Yellow links (#FFFF00) with high luminance contrast
  • White borders (#FFFFFF) on all interactive elements
  • Yellow focus rings (#FFFF00) for maximum keyboard navigation visibility
  • Aims at WCAG 1.4.6 / WCAG 1.4.11 contrast targets (7:1 for normal body text where applicable). Some non-text and large-text pairings may meet the relaxed 4.5:1 or 3:1 floors instead of strict 7:1; verify your composition with a contrast checker.

Appropriate contexts:

  • Patient-facing portals where users may have low vision or cataracts
  • EHR interfaces used under bright clinical lighting
  • Any screen where elevated contrast is a legal or contractual requirement
<!-- Entire page in high-contrast mode -->
<hx-theme theme="high-contrast">
<main>
<hx-button>Submit Order</hx-button>
<hx-text-input label="Patient MRN"></hx-text-input>
</main>
</hx-theme>
const themeEl = document.querySelector('hx-theme');
if (themeEl) {
themeEl.setAttribute('theme', 'high-contrast');
}
function setAccessibilityMode(mode: 'standard' | 'high-contrast'): void {
const themeEl = document.querySelector('hx-theme') as HTMLElement;
if (!themeEl) return;
themeEl.setAttribute('theme', mode === 'high-contrast' ? 'high-contrast' : 'light');
// Persist the choice
localStorage.setItem('hx-preferred-theme', mode);
}
// Restore on page load
const saved = localStorage.getItem('hx-preferred-theme');
if (saved === 'high-contrast') {
setAccessibilityMode('high-contrast');
}

High-contrast mode is distinct from prefers-contrast: more media queries. If you also want to respect the OS accessibility preference, add:

@media (prefers-contrast: more) {
/* hx-theme cannot respond to this automatically — set it in JS */
}
if (window.matchMedia('(prefers-contrast: more)').matches) {
document.querySelector('hx-theme')?.setAttribute('theme', 'high-contrast');
}

The following is a production-ready brand theme file. It overrides all three tiers for a complete visual rebrand.

acme-health-theme.css
*
* Complete brand theme for Acme Health.
* Override at the primitive tier (color ramps, type scale)
* and semantic tier (text, surfaces, borders).
* Component tier is left to component-level overrides.
*/
:root {
/* ─── Primitive tier: Acme Health brand palette ─────────────────────────── */
/* Primary: Acme teal */
--hx-color-primary-50: #ecfaf8;
--hx-color-primary-100: #c9f2ed;
--hx-color-primary-200: #94e4da;
--hx-color-primary-300: #5fd1c3;
--hx-color-primary-400: #2dbdad;
--hx-color-primary-500: #00968a; /* main action color */
--hx-color-primary-600: #007d73;
--hx-color-primary-700: #006560;
--hx-color-primary-800: #004e4c;
--hx-color-primary-900: #003737;
--hx-color-primary-950: #002020;
/* Typography */
--hx-font-family-sans: 'Acme Sans', 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
--hx-font-family-mono: 'JetBrains Mono', ui-monospace, monospace;
/* Geometry */
--hx-border-radius-sm: 3px;
--hx-border-radius-md: 6px;
--hx-border-radius-lg: 10px;
/* ─── Semantic tier: light mode overrides ────────────────────────────────── */
--hx-color-text-link: var(--hx-color-primary-600);
--hx-color-text-link-hover: var(--hx-color-primary-700);
--hx-color-border-focus: var(--hx-color-primary-500);
--hx-color-focus-ring: var(--hx-color-primary-500);
}
/* ─── Dark mode overrides ──────────────────────────────────────────────────── */
[data-theme='dark'] {
--hx-color-text-primary: var(--hx-color-neutral-100);
--hx-color-text-secondary: var(--hx-color-neutral-300);
--hx-color-text-muted: var(--hx-color-neutral-400);
--hx-color-text-link: var(--hx-color-primary-300);
--hx-color-text-link-hover: var(--hx-color-primary-200);
--hx-color-surface-default: var(--hx-color-neutral-900);
--hx-color-surface-raised: var(--hx-color-neutral-800);
--hx-color-surface-sunken: var(--hx-color-neutral-950);
--hx-color-border-default: var(--hx-color-neutral-700);
--hx-color-border-subtle: var(--hx-color-neutral-800);
--hx-color-border-focus: var(--hx-color-primary-400);
--hx-color-focus-ring: var(--hx-color-primary-400);
--hx-body-bg: var(--hx-color-surface-default);
--hx-body-color: var(--hx-color-text-primary);
--hx-shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.4), 0 2px 4px -2px rgb(0 0 0 / 0.3);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme='light']) {
--hx-color-text-primary: var(--hx-color-neutral-100);
--hx-color-text-secondary: var(--hx-color-neutral-300);
--hx-color-surface-default: var(--hx-color-neutral-900);
--hx-color-surface-raised: var(--hx-color-neutral-800);
--hx-color-border-default: var(--hx-color-neutral-700);
--hx-color-border-focus: var(--hx-color-primary-400);
--hx-body-bg: var(--hx-color-surface-default);
--hx-body-color: var(--hx-color-text-primary);
--hx-shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.4), 0 2px 4px -2px rgb(0 0 0 / 0.3);
}
}

Import method 1: Plain CSS stylesheet (HTML / Drupal)

Section titled “Import method 1: Plain CSS stylesheet (HTML / Drupal)”

Add the theme file as a <link> before any HELiX component scripts. No build tools required. The library loads from a CDN — see CDN Distribution for the import-map context that lets the bare lit / @helixui/tokens imports resolve.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<!-- 1. Import map so bare specifiers resolve over the CDN. -->
<script type="importmap">
{
"imports": {
"@helixui/library": "https://cdn.jsdelivr.net/npm/@helixui/library@3.9.0/dist/index.js",
"@helixui/tokens": "https://cdn.jsdelivr.net/npm/@helixui/tokens@3.9.0/dist/index.js",
"@helixui/icons": "https://cdn.jsdelivr.net/npm/@helixui/icons@1.0.0/dist/index.js",
"@floating-ui/dom": "https://cdn.jsdelivr.net/npm/@floating-ui/dom@1.7.6/+esm",
"lit": "https://cdn.jsdelivr.net/npm/lit@3/+esm",
"lit/": "https://cdn.jsdelivr.net/npm/lit@3/"
}
}
</script>
<!-- 2. Load the HELiX component library over the CDN. -->
<script type="module">
import '@helixui/library';
</script>
<!-- 3. Load your brand theme — overrides the default :root tokens. -->
<link rel="stylesheet" href="/css/acme-health-theme.css" />
</head>
<body>
<hx-button>Book Appointment</hx-button>
</body>
</html>

The link order matters: the brand theme file must load after any HELiX baseline CSS but the <link> tag placement does not matter for CSS custom properties — they resolve at paint time, not load time. Loading it anywhere in <head> is safe.

Import method 2: Lit css tagged template (Lit component consumers)

Section titled “Import method 2: Lit css tagged template (Lit component consumers)”

When building Lit components that compose HELiX components, import the theme as a Lit css tagged template and spread it into static styles:

import { LitElement, html, css } from 'lit';
import { customElement } from 'lit/decorators.js';
// Import your brand theme as a Lit CSSResult
import { acmeHealthTheme } from './acme-health-theme.styles.js';
// acme-health-theme.styles.ts
// export const acmeHealthTheme = css`
// :host {
// --hx-color-primary-500: #00968a;
// --hx-font-family-sans: 'Acme Sans', 'Inter', sans-serif;
// /* ... all brand overrides scoped to :host */
// }
// `;
@customElement('acme-app-shell')
export class AcmeAppShell extends LitElement {
// Spread brand theme before component-specific styles
static override styles = [
acmeHealthTheme,
css`
:host {
display: block;
min-height: 100vh;
}
`,
];
override render() {
return html`
<hx-theme theme="light">
<slot></slot>
</hx-theme>
`;
}
}

The acme-health-theme.styles.ts file uses :host rather than :root because the tokens need to apply to the Shadow DOM scope of the shell component and cascade into its slotted content:

acme-health-theme.styles.ts
import { css } from 'lit';
export const acmeHealthTheme = css`
:host {
/* Primary ramp */
--hx-color-primary-50: #ecfaf8;
--hx-color-primary-500: #00968a;
--hx-color-primary-600: #007d73;
--hx-color-primary-950: #002020;
/* Typography */
--hx-font-family-sans: 'Acme Sans', 'Inter', -apple-system, sans-serif;
/* Geometry */
--hx-border-radius-md: 6px;
/* Semantic overrides */
--hx-color-text-link: var(--hx-color-primary-600);
--hx-color-border-focus: var(--hx-color-primary-500);
--hx-color-focus-ring: var(--hx-color-primary-500);
}
`;

Drupal teams frequently work without npm or a bundler. HELiX theming is fully compatible with this workflow because CSS custom properties are a native browser feature.

Load the HELiX library via CDN (or your Drupal library definition) and wrap a region with hx-theme:

{# page--clinical-portal.html.twig #}
<!DOCTYPE html>
<html{{ html_attributes }}>
<head>
<title>{{ head_title }}</title>
{{ head }}
{# HELiX via CDN — substitute with your Drupal library path; pin a version #}
<script type="module" src="https://cdn.jsdelivr.net/npm/@helixui/library@3.9.0/dist/index.js"></script>
{# Brand theme — a plain CSS file, no build step needed #}
<link rel="stylesheet" href="/themes/custom/acme_health/css/acme-health-theme.css" />
</head>
<body{{ attributes }}>
{# Wrap the entire page in a light theme provider #}
<hx-theme theme="light">
{{ page.header }}
{# Dark sidebar — nested inside the light page theme #}
<hx-theme theme="dark">
{{ page.sidebar_first }}
</hx-theme>
<main role="main">
{{ page.content }}
</main>
{{ page.footer }}
</hx-theme>
</body>
</html>

Method 2: Inline <style> block with CSS custom property overrides

Section titled “Method 2: Inline <style> block with CSS custom property overrides”

For per-template overrides without a separate CSS file, place a <style> block directly in the Twig template. This is appropriate for region-scoped overrides on a specific content type:

{# node--clinical-alert--full.html.twig #}
<style>
.clinical-alert-node {
/* Override primary color to clinical red for this node type */
--hx-color-primary-500: #b91c1c;
--hx-color-primary-600: #991b1b;
--hx-color-primary-400: #dc2626;
/* Tighten border radius for clinical density */
--hx-border-radius-md: 2px;
}
</style>
<div class="clinical-alert-node">
<hx-alert variant="error" open>
{{ content.field_alert_body }}
</hx-alert>
<hx-button>Acknowledge Alert</hx-button>
</div>

Method 3: Dark mode in a Drupal block template

Section titled “Method 3: Dark mode in a Drupal block template”

Apply dark mode to a specific block — no JavaScript required:

{# block--sidebar-nav.html.twig #}
<hx-theme theme="dark">
<div{{ attributes.addClass('block', 'block-sidebar-nav') }}>
{% if label %}
<h2{{ title_attributes }}>{{ label }}</h2>
{% endif %}
<nav>
{{ content }}
</nav>
</div>
</hx-theme>

Register the brand theme CSS as a Drupal library so it loads only where needed:

acme_health.libraries.yml
brand-theme:
css:
theme:
css/acme-health-theme.css: {}
dependencies:
- core/drupal
// In hook_preprocess_page() or your theme's preprocess functions
function acme_health_preprocess_page(array &$variables): void {
$variables['#attached']['library'][] = 'acme_health/brand-theme';
}

The CSS file loads as a standard stylesheet — no npm, no Webpack, no build pipeline required.


GoalWhere to overrideSelector
Rebrand the entire libraryPrimitive ramp:root
Theme a page regionSemantic tokens.my-section or hx-theme
Customize one component typeComponent tokenhx-button
Customize a single instanceComponent tokenhx-button[data-id="submit"] or inline style
Apply dark mode globallySemantic tokens[data-theme="dark"] or @media (prefers-color-scheme: dark)
Apply high-contrast modehx-theme attribute<hx-theme theme="high-contrast">
PatternTierExample
--hx-color-{palette}-{stop}Primitive--hx-color-primary-500
--hx-space-{step}Primitive--hx-space-4
--hx-font-family-{variant}Primitive--hx-font-family-sans
--hx-color-text-{role}Semantic--hx-color-text-primary
--hx-color-surface-{role}Semantic--hx-color-surface-default
--hx-color-border-{role}Semantic--hx-color-border-focus
--hx-{component}-{property}Component--hx-button-bg