Skip to content
HELiX

Theming HELiX Components

apps/docs/src/content/docs/guides/theming Click to copy
Copied! 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.


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.

GoalOverride tierExample
Brand-wide colour swapBrand (via registry)HelixBrandRegistry.register('mybrand', { '--hx-color-primary-700': '…', … })
Semantic role retuneSemantic--hx-color-action-primary-bg
One component’s lookComponent--hx-button-bg
Full palette swapBrand (via registry)Register a complete primary + secondary 22-token ramp set
Dark mode surfacesUse <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).

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.


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.

<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;
}

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.

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

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:

ComponentTokenControls
hx-button--hx-button-bgBackground color
hx-button--hx-button-colorText color
hx-button--hx-button-border-radiusCorner radius
hx-button--hx-button-padding-xHorizontal padding
hx-button--hx-button-padding-yVertical padding
hx-card--hx-card-bgBackground color
hx-card--hx-card-paddingInner padding
hx-card--hx-card-border-radiusCorner radius
hx-card--hx-card-shadowBox shadow
hx-text-input--hx-text-input-bgInput background
hx-text-input--hx-text-input-border-colorBorder color
hx-text-input--hx-text-input-border-radiusCorner radius
hx-text-input--hx-text-input-focus-ringFocus ring style
hx-badge--hx-badge-bgBackground color
hx-badge--hx-badge-colorText 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;
}

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:

ComponentPartElement
hx-buttonbuttonThe <button> or <a> element
hx-buttonprefixPrefix slot wrapper
hx-buttonsuffixSuffix slot wrapper
hx-cardbaseRoot surface element
hx-cardheaderHeader region
hx-cardbodyBody region
hx-cardfooterFooter region
hx-text-inputbaseOuter wrapper
hx-text-inputinputThe <input> element
hx-text-inputlabelThe <label> element
hx-badgebaseBadge 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;
}

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.

@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;
}
}
/* 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 theme
const 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 example
document.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.


A complete example switching HELiX from its default blue palette to a purple/violet brand:

violet-brand-theme.css
: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:

TokenPurpose
--hx-color-action-primary-bgResting primary action surface (button bg, badge, active nav)
--hx-color-action-primary-bg-hoverHover state of primary interactive surfaces
--hx-color-action-primary-bg-activeActive / pressed state of primary surfaces
--hx-color-text-on-primaryText / icon color on primary surfaces
--hx-color-action-secondary-fgSecondary action foreground (outline / ghost variant text)
--hx-color-action-secondary-borderSecondary outline border
--hx-color-action-secondary-bg-hoverSecondary hover background
--hx-color-action-danger-bgDestructive primary surface (red action)
--hx-color-action-danger-bg-hoverDestructive hover background
--hx-color-action-danger-bg-activeDestructive active / pressed background
--hx-color-action-ghost-fgGhost-button foreground
--hx-color-action-ghost-bg-hoverGhost-button hover background

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.

healthcare-theme.css
: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:

RequirementToken to setMinimum
Normal text contrast--hx-color-text-primary on surface4.5:1
Large text contrast--hx-color-text-secondary on surface3:1
UI component contrast--hx-color-border-default on surface3:1
Focus ring visibility--hx-color-border-focus, --hx-focus-ring-widthVisible at 200% zoom
Touch targets--hx-size-touch-target44×44px
Error states--hx-color-errorNever 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.


Apply HELiX design tokens in a Drupal theme through the library system.

In mytheme.libraries.yml:

# Global HELiX tokens — attach to every page
helix-tokens:
css:
base:
css/helix-tokens.css: {}
dependencies:
- core/drupalSettings
# Brand theme overrides — loaded after tokens
helix-brand:
css:
theme:
css/brand-theme.css: {}
dependencies:
- mytheme/helix-tokens
# Component scripts — attach per-region, not globally
helix-components:
js:
js/helix-components.js: { scope: footer, minified: false }
dependencies:
- mytheme/helix-tokens

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 %}
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 overrides

css/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;
}

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>
mytheme/css/scoped-themes.css
[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:

DoDon’t
Load token overrides as a base CSS file in mytheme.libraries.ymlInline token overrides in Twig templates (duplicates per-page)
Scope dark or alternate themes via data-theme on a wrapperOverride component shadow DOM with .mytheme hx-button button (doesn’t work)
Use mytheme/helix-tokens as a dependency for all helix librariesLoad HELiX JS before token CSS is available
Let hx-theme handle scoped theming in Twig when using the componentUse !important to force Drupal’s default styles onto components

ScenarioApproach
Apply brand colors everywhereOverride semantic tokens on :root
Clinical section with different paletteScope via data-theme or hx-theme on wrapper
One button looks differentSet component token on the element — --hx-button-bg
Change the internal border radius of an inputhx-text-input::part(input) { border-radius: … }
Dark mode via system preference@media (prefers-color-scheme: dark) block
Dark mode via toggle buttondocument.documentElement.setAttribute('data-theme', 'dark')
Load brand theme in DrupalDefine 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.