Theming HELiX Components
apps/docs/src/content/docs/guides/theming Click to copy apps/docs/src/content/docs/guides/theming HELiX components are styled entirely through CSS custom properties. No class names to override, no !important battles, no Shadow DOM piercing required. Override the right token at the right tier, and every component updates automatically.
Token Architecture
Section titled “Token Architecture”HELiX uses a three-tier token cascade that flows from raw values down to per-component properties:
Primitive Tokens raw values — palette ramps (--hx-color-primary-{50…950}), spacing steps (--hx-space-{0…64}), font-size scale. ↓Semantic Tokens role-bound — --hx-color-action-primary-bg, --hx-color-surface-default, --hx-color-text-primary. ↓Component Tokens scoped — --hx-button-bg, --hx-card-padding.Inside every component’s styles, there is a private fallback chain:
/* Inside hx-button shadow DOM */.button--primary { --hx-button-bg: var(--hx-color-action-primary-bg);}.button { background-color: var(--hx-button-bg);}--hx-button-bg is the component token (easily overridden per-element). The button’s variant CSS
sets it from --hx-color-action-primary-bg, the semantic action surface. Override at the semantic
tier for brand-wide changes; override at the component tier when one variant needs to vary in
isolation.
Which tier to override?
Section titled “Which tier to override?”| Goal | Override tier | Example |
|---|---|---|
| Brand-wide colour swap | Brand (via registry) | HelixBrandRegistry.register('mybrand', { '--hx-color-primary-700': '…', … }) |
| Semantic role retune | Semantic | --hx-color-action-primary-bg |
| One component’s look | Component | --hx-button-bg |
| Full palette swap | Brand (via registry) | Register a complete primary + secondary 22-token ramp set |
| Dark mode surfaces | Use <hx-theme theme="dark"> | The dark adopted stylesheet swaps semantic references |
Do:
- Use the brand registry (
HelixBrandRegistry.register()) for brand-wide colour swaps — register the 22-token primary + secondary ramp set and<hx-theme brand="…">applies it to the entire subtree. - Override semantic role tokens (
--hx-color-action-primary-bg,--hx-color-surface-default,--hx-color-text-primary, etc.) for theme-wide tweaks that aren’t a brand swap. - Override component tokens (
--hx-button-bg,--hx-card-padding) for targeted per-component changes. - Reference semantic tokens in component-token overrides so the cascade stays intact.
Don’t:
- Override individual primitive ramp stops from consuming applications — register a brand instead so the full ramp updates atomically.
- Use
!important— it breaks every downstream override. - Hardcode raw values in component-token overrides (use token references instead).
Global Theming
Section titled “Global Theming”Apply your brand to every HELiX component by overriding semantic tokens on :root:
For brand colour swaps, prefer HelixBrandRegistry.register() (covered in the next section) — it
updates the entire primary + secondary ramp atomically with a single call. The CSS-override
pattern below is for retuning specific semantic roles (status messages, surface tints, typography,
spacing) outside the brand ramp surface:
/* your-brand-theme.css — overrides semantic + spatial tokens only. */
:root { /* Semantic action / surface / text roles. These are the canonical override handles (the fabricated --hx-color-primary / --hx-color-on-primary handles do not exist). */ --hx-color-action-primary-bg: var(--hx-color-primary-700); --hx-color-action-primary-bg-hover: var(--hx-color-primary-800); --hx-color-action-primary-bg-active: var(--hx-color-primary-900); --hx-color-text-on-primary: var(--hx-color-neutral-0);
/* Surface layers */ --hx-color-surface-default: var(--hx-color-neutral-0); --hx-color-surface-raised: var(--hx-color-neutral-50); --hx-color-surface-sunken: var(--hx-color-neutral-100);
/* Text */ --hx-color-text-primary: var(--hx-color-neutral-900); --hx-color-text-secondary: var(--hx-color-neutral-700); --hx-color-text-muted: var(--hx-color-neutral-700); --hx-color-text-inverse: var(--hx-color-neutral-0);
/* Borders */ --hx-color-border-default: var(--hx-color-neutral-200); --hx-color-border-subtle: var(--hx-color-neutral-100);
/* Status text — error and success are canonical; warning text falls back to --hx-color-text-on-warning (there is no flat --hx-color-warning-text token). */ --hx-color-error-text: var(--hx-color-error-700); --hx-color-success-text: var(--hx-color-success-800);
/* Typography (canonical names: -sans, -mono, -serif) */ --hx-font-family-sans: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; --hx-font-size-md: 1rem; --hx-line-height-normal: 1.5;
/* Shape (canonical: --hx-border-radius-*) */ --hx-border-radius-sm: 0.25rem; --hx-border-radius-md: 0.375rem; --hx-border-radius-lg: 0.5rem; --hx-border-radius-full: 9999px;
/* Spacing (canonical: --hx-space-N, not --hx-spacing-{xs,sm,md,lg,xl}) */ --hx-space-1: 0.25rem; --hx-space-2: 0.5rem; --hx-space-4: 1rem; --hx-space-6: 1.5rem; --hx-space-8: 2rem;}Load your override stylesheet after the HELiX token bundle:
<!-- HELiX ships tokens via npm (@helixui/tokens). The CDN path follows the package layout: --><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@helixui/tokens@latest/dist/tokens.css" /><link rel="stylesheet" href="/your-brand-theme.css" />Before (Apex default brand): every interactive surface paints from the action token layer
(--hx-color-action-primary-bg → --hx-color-primary-700 → Apex #0F6363).
After (your semantic overrides applied): every component that consumes the action token layer
(hx-button, hx-checkbox, hx-radio, hx-switch, hx-tabs, hx-badge primary, etc.) reads
the new value automatically. For a full brand-ramp swap (changing the primary hue itself), register
a brand via the brand registry — that updates every consumer of the primary/secondary ramps in one
call rather than overriding semantic roles piecemeal.
Scoped Theming
Section titled “Scoped Theming”CSS custom properties cascade through the DOM. Any token set on a parent element flows into all descendant HELiX components — including those with Shadow DOM — because custom properties are inherited by default.
Using data-theme attributes
Section titled “Using data-theme attributes”<body> <!-- Default theme --> <hx-button>Brand button</hx-button>
<!-- Scoped overrides — author-defined data-attribute, applied via the CSS below. There are no built-in 'clinical' / 'admin' presets in HELiX; data-theme is just a CSS hook you author and own. --> <section data-app-theme="patient-portal"> <hx-button>Portal button</hx-button> <hx-card> <hx-text-input label="Patient ID" /> </hx-card> </section></body>[data-app-theme='patient-portal'] { --hx-color-action-primary-bg: #1a7f64; --hx-color-action-primary-bg-hover: #148f72; --hx-color-surface-default: #f8f9fa; --hx-font-family-sans: 'Source Sans Pro', sans-serif; --hx-border-radius-md: 2px; --hx-shadow-sm: none;}Using hx-theme
Section titled “Using hx-theme”HELiX ships an hx-theme utility component that applies a registered brand or a mode preset to
its subtree without requiring per-section CSS:
<!-- hx-theme attributes: theme : 'light' | 'dark' | 'high-contrast' | 'auto' brand : a brand id registered via HelixBrandRegistry.register() motion : motion-mode preset (reduce / allow / auto) density : density preset (comfortable / compact / spacious) There is no `name` attribute and no built-in 'clinical' theme.--><hx-theme brand="apex" theme="auto" density="comfortable"> <hx-button>Patient portal button</hx-button> <hx-card>Patient information</hx-card></hx-theme>Register a brand before referencing it via the brand attribute:
import { HelixBrandRegistry } from '@helixui/tokens';
HelixBrandRegistry.register('patient-portal', { '--hx-color-primary-50': '#…', // … all 22 primary + secondary ramp tokens});See the Brand Registry foundation for the full registration contract and the shipped reference brands.
Scope to CSS classes
Section titled “Scope to CSS classes”.portal-section { --hx-color-action-primary-bg: #1a7f64; --hx-color-action-primary-bg-hover: #166c55;}
.admin-section { --hx-color-action-primary-bg: #7c3aed; --hx-color-action-primary-bg-hover: #6d28d9;}<div class="portal-section"> <hx-button>Portal button</hx-button></div>
<div class="admin-section"> <hx-button>Admin button</hx-button></div>Per-Component Overrides
Section titled “Per-Component Overrides”Override component-level tokens on the element directly (or with a CSS selector) to customize a single component without affecting any others.
/* Override only hx-button */hx-button { --hx-button-bg: var(--hx-color-secondary); --hx-button-color: var(--hx-color-on-secondary); --hx-button-border-radius: 0; --hx-button-padding-x: var(--hx-space-6);}
/* Per-instance override with inline style *//* <hx-button style="--hx-button-bg: navy;">Special</hx-button> */Token reference table — most commonly overridden component tokens:
| Component | Token | Controls |
|---|---|---|
hx-button | --hx-button-bg | Background color |
hx-button | --hx-button-color | Text color |
hx-button | --hx-button-border-radius | Corner radius |
hx-button | --hx-button-padding-x | Horizontal padding |
hx-button | --hx-button-padding-y | Vertical padding |
hx-card | --hx-card-bg | Background color |
hx-card | --hx-card-padding | Inner padding |
hx-card | --hx-card-border-radius | Corner radius |
hx-card | --hx-card-shadow | Box shadow |
hx-text-input | --hx-text-input-bg | Input background |
hx-text-input | --hx-text-input-border-color | Border color |
hx-text-input | --hx-text-input-border-radius | Corner radius |
hx-text-input | --hx-text-input-focus-ring | Focus ring style |
hx-badge | --hx-badge-bg | Background color |
hx-badge | --hx-badge-color | Text color |
Finding a component’s tokens: Every component lists its CSS custom properties in the Storybook docs panel under “CSS Custom Properties.” You can also inspect the source’s JSDoc for @cssprop annotations.
Do:
/* Reference a semantic token so the cascade stays intact */hx-button { --hx-button-bg: var(--hx-color-secondary);}Don’t:
/* Hardcoded value — won't update in dark mode or when the theme changes */hx-button { --hx-button-bg: #6b7280;}CSS Parts
Section titled “CSS Parts”HELiX components expose internal elements via ::part() for styling that cannot be achieved through CSS custom properties alone — things like letter spacing, text-transform, or box-decoration-break.
/* Style the internal button element of hx-button */hx-button::part(button) { letter-spacing: 0.05em; text-transform: uppercase;}
/* Style the input element inside hx-text-input */hx-text-input::part(input) { font-family: 'Courier New', monospace;}
/* Style the surface of hx-card */hx-card::part(base) { outline: 2px dashed var(--hx-color-border-default);}Common CSS parts by component:
| Component | Part | Element |
|---|---|---|
hx-button | button | The <button> or <a> element |
hx-button | prefix | Prefix slot wrapper |
hx-button | suffix | Suffix slot wrapper |
hx-card | base | Root surface element |
hx-card | header | Header region |
hx-card | body | Body region |
hx-card | footer | Footer region |
hx-text-input | base | Outer wrapper |
hx-text-input | input | The <input> element |
hx-text-input | label | The <label> element |
hx-badge | base | Badge container |
Find the full list for each component in Storybook under the “CSS Parts” tab.
Do:
/* Use ::part() for styling exposed surface properties */hx-button::part(button) { border-radius: 0; letter-spacing: 0.05em;}Don’t:
/* Don't use ::part() to override layout or structure */hx-button::part(button) { display: grid; /* unexpected layout side effects */ position: absolute; /* breaks stacking context */}Don’t attempt to target unexposed internals:
/* This does nothing — shadow DOM is encapsulated */hx-button button { background: red;}
/* Use the CSS custom property API instead */hx-button { --hx-button-bg: red;}Dark Mode
Section titled “Dark Mode”HELiX dark mode is controlled via a data-theme="dark" attribute on <html> (or any ancestor element) and automatically activates based on prefers-color-scheme: dark unless overridden.
System preference (automatic)
Section titled “System preference (automatic)”@media (prefers-color-scheme: dark) { :root:not([data-theme='light']) { --hx-color-surface-default: #111827; --hx-color-surface-raised: #1f2937; --hx-color-surface-sunken: #0d1117; --hx-color-text-primary: #f9fafb; --hx-color-text-secondary: #d1d5db; --hx-color-text-muted: #9ca3af; --hx-color-border-default: #374151; --hx-color-border-subtle: #1f2937; /* Keep primary legible on dark backgrounds */ --hx-color-action-primary-bg: #60a5fa; }}Manual toggle
Section titled “Manual toggle”/* Light mode explicit */:root[data-theme='light'] { --hx-color-surface-default: #ffffff; --hx-color-text-primary: #111827;}
/* Dark mode explicit */:root[data-theme='dark'] { --hx-color-surface-default: #111827; --hx-color-text-primary: #f9fafb; --hx-color-border-default: #374151; /* Lighten primary for dark background contrast */ --hx-color-action-primary-bg: #60a5fa;}function setTheme(theme) { document.documentElement.setAttribute('data-theme', theme); localStorage.setItem('hx-theme', theme);}
// Restore on load — prevents flash of wrong themeconst saved = localStorage.getItem('hx-theme');const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;if (saved) { setTheme(saved);} else if (prefersDark) { setTheme('dark');}
// Toggle button exampledocument.querySelector('#theme-toggle').addEventListener('click', () => { const current = document.documentElement.getAttribute('data-theme'); setTheme(current === 'dark' ? 'light' : 'dark');});Before (light mode): White surfaces, dark text, blue primary.
After (dark mode active): Near-black surfaces, light text, lightened primary for contrast.
Contrast check: After applying custom dark colors, verify ratios with DevTools accessibility inspector or axe. HELiX components will not re-validate your custom values — that responsibility belongs to the consuming team.
Brand Customization
Section titled “Brand Customization”A complete example switching HELiX from its default blue palette to a purple/violet brand:
:root { /* Primary ramp */ --hx-color-action-primary-bg: #7c3aed; --hx-color-action-primary-bg-hover: #6d28d9; --hx-color-action-primary-bg-active: #5b21b6; --hx-color-text-on-primary: #ffffff;
/* Secondary */ --hx-color-secondary: #ec4899; --hx-color-on-secondary: #ffffff;
/* Accent (for highlights, tags, badges) */ --hx-color-accent: #f59e0b; --hx-color-on-accent: #000000;
/* Focus ring matches primary */ --hx-color-border-focus: #7c3aed;
/* Brand typography */ --hx-font-family-sans: 'Plus Jakarta Sans', -apple-system, sans-serif;
/* Rounded corners for a friendly feel */ --hx-border-radius-sm: 0.5rem; --hx-border-radius-md: 0.75rem; --hx-border-radius-lg: 1rem; --hx-border-radius-full: 9999px;
/* Subtle shadows */ --hx-shadow-sm: 0 1px 3px rgba(124, 58, 237, 0.1); --hx-shadow-md: 0 4px 6px rgba(124, 58, 237, 0.15);}
/* Dark mode variant */:root[data-theme='dark'] { --hx-color-action-primary-bg: #a78bfa; --hx-color-action-primary-bg-hover: #8b5cf6; --hx-color-border-focus: #a78bfa; --hx-color-surface-default: #1e1b4b; --hx-color-surface-raised: #312e81; --hx-color-text-primary: #f5f3ff;}Token reference — primary / secondary / accent:
| Token | Purpose |
|---|---|
--hx-color-action-primary-bg | Resting primary action surface (button bg, badge, active nav) |
--hx-color-action-primary-bg-hover | Hover state of primary interactive surfaces |
--hx-color-action-primary-bg-active | Active / pressed state of primary surfaces |
--hx-color-text-on-primary | Text / icon color on primary surfaces |
--hx-color-action-secondary-fg | Secondary action foreground (outline / ghost variant text) |
--hx-color-action-secondary-border | Secondary outline border |
--hx-color-action-secondary-bg-hover | Secondary hover background |
--hx-color-action-danger-bg | Destructive primary surface (red action) |
--hx-color-action-danger-bg-hover | Destructive hover background |
--hx-color-action-danger-bg-active | Destructive active / pressed background |
--hx-color-action-ghost-fg | Ghost-button foreground |
--hx-color-action-ghost-bg-hover | Ghost-button hover background |
Healthcare Theme Example
Section titled “Healthcare Theme Example”Healthcare applications have mandatory contrast requirements (WCAG 2.2 AA minimum; AAA on canonical primary surfaces — see HELiX’s aaa-verdicts.json for the P0 cert posture). This theme applies calming clinical blues with high-contrast overrides suitable for patient-facing and clinician interfaces.
:root { /* Clinical blues — calming, trustworthy */ --hx-color-action-primary-bg: #1d4ed8; /* 4.6:1 on white */ --hx-color-action-primary-bg-hover: #1e40af; /* 5.8:1 on white */ --hx-color-action-primary-bg-active: #1e3a8a; --hx-color-text-on-primary: #ffffff;
/* Accessible error — avoids pure red for colorblindness users */ --hx-color-error: #b91c1c; /* 5.9:1 on white */ --hx-color-on-error: #ffffff;
/* Amber warning — avoids yellow/green confusion */ --hx-color-warning: #b45309; /* 4.6:1 on white */
/* Teal success — distinct from error green */ --hx-color-success: #0f766e; /* 5.1:1 on white */
/* High-contrast text */ --hx-color-text-primary: #111827; /* 16:1 on white */ --hx-color-text-secondary: #1f2937; /* 13:1 on white */ --hx-color-text-muted: #4b5563; /* 7:1 on white — minimum AA */
/* 3px focus ring for motor-impaired users */ --hx-focus-ring-width: 3px; --hx-focus-ring-offset: 2px; --hx-color-border-focus: #1d4ed8;
/* Touch targets: 44x44px minimum (WCAG 2.5.5) */ --hx-size-touch-target: 2.75rem;
/* Neutral shape — utilitarian, not decorative */ --hx-border-radius-sm: 2px; --hx-border-radius-md: 4px; --hx-border-radius-lg: 6px;}
/* Enforce touch target minimums on interactive components */hx-button,hx-checkbox,hx-radio,hx-switch { min-height: var(--hx-size-touch-target);}
/* Motion sensitivity — critical for patients with vestibular disorders */@media (prefers-reduced-motion: reduce) { * { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }}
/* High-contrast mode support */@media (forced-colors: active) { hx-button::part(button) { border: 2px solid ButtonText; }}Accessibility requirements checklist for healthcare:
| Requirement | Token to set | Minimum |
|---|---|---|
| Normal text contrast | --hx-color-text-primary on surface | 4.5:1 |
| Large text contrast | --hx-color-text-secondary on surface | 3:1 |
| UI component contrast | --hx-color-border-default on surface | 3:1 |
| Focus ring visibility | --hx-color-border-focus, --hx-focus-ring-width | Visible at 200% zoom |
| Touch targets | --hx-size-touch-target | 44×44px |
| Error states | --hx-color-error | Never rely on color alone |
Run axe or Lighthouse after applying any custom theme. With the default tokens, HELiX components meet WCAG 2.2 AA across the board and WCAG 2.2 AAA on the P0 surface (see
packages/hx-library/aaa-verdicts.json). Custom overrides are your team’s responsibility to validate against the same posture.
Drupal Integration
Section titled “Drupal Integration”Apply HELiX design tokens in a Drupal theme through the library system.
Defining a theme library
Section titled “Defining a theme library”In mytheme.libraries.yml:
# Global HELiX tokens — attach to every pagehelix-tokens: css: base: css/helix-tokens.css: {} dependencies: - core/drupalSettings
# Brand theme overrides — loaded after tokenshelix-brand: css: theme: css/brand-theme.css: {} dependencies: - mytheme/helix-tokens
# Component scripts — attach per-region, not globallyhelix-components: js: js/helix-components.js: { scope: footer, minified: false } dependencies: - mytheme/helix-tokensAttaching libraries in templates
Section titled “Attaching libraries in templates”Attach the token and theme libraries globally in html.html.twig:
{{ attach_library('mytheme/helix-tokens') }}{{ attach_library('mytheme/helix-brand') }}Or attach conditionally in block or node templates:
{# Only on the clinical portal block #}{% if block.id == 'clinical_portal' %} {{ attach_library('mytheme/helix-clinical') }}{% endif %}Global CSS structure
Section titled “Global CSS structure”mytheme/ css/ helix-tokens.css — Base HELiX token overrides (primitives, semantics) brand-theme.css — Brand color, typography, spacing overrides dark-mode.css — Dark mode token overrides clinical-theme.css — Scoped clinical/high-contrast overridescss/brand-theme.css:
/* Drupal brand theme — loaded after HELiX defaults */
:root { --hx-color-action-primary-bg: #0066cc; --hx-font-family-sans: 'Source Sans Pro', sans-serif; --hx-border-radius-md: 0.25rem;}Applying scoped themes via Drupal fields
Section titled “Applying scoped themes via Drupal fields”Use a Drupal field or block template to output a data-theme attribute:
{# paragraph--hero.html.twig #}<div{{ attributes.setAttribute('data-theme', paragraph.field_theme.value) }}> {{ content }}</div>[data-theme='dark-hero'] { --hx-color-surface-default: #111827; --hx-color-text-primary: #f9fafb; --hx-color-action-primary-bg: #60a5fa;}Do’s and don’ts for Drupal:
| Do | Don’t |
|---|---|
Load token overrides as a base CSS file in mytheme.libraries.yml | Inline token overrides in Twig templates (duplicates per-page) |
Scope dark or alternate themes via data-theme on a wrapper | Override component shadow DOM with .mytheme hx-button button (doesn’t work) |
Use mytheme/helix-tokens as a dependency for all helix libraries | Load HELiX JS before token CSS is available |
Let hx-theme handle scoped theming in Twig when using the component | Use !important to force Drupal’s default styles onto components |
Quick Reference
Section titled “Quick Reference”| Scenario | Approach |
|---|---|
| Apply brand colors everywhere | Override semantic tokens on :root |
| Clinical section with different palette | Scope via data-theme or hx-theme on wrapper |
| One button looks different | Set component token on the element — --hx-button-bg |
| Change the internal border radius of an input | hx-text-input::part(input) { border-radius: … } |
| Dark mode via system preference | @media (prefers-color-scheme: dark) block |
| Dark mode via toggle button | document.documentElement.setAttribute('data-theme', 'dark') |
| Load brand theme in Drupal | Define a library in mytheme.libraries.yml, attach in html.html.twig |
For practical brand recipes (compact mode, custom fonts, full dark theme), see the Theming Recipes guide.