Skip to content
HELiX

Drupal Integration Best Practices

apps/docs/src/content/docs/drupal/best-practices Click to copy
Copied! apps/docs/src/content/docs/drupal/best-practices

Comprehensive guidance for integrating HELiX web components into Drupal 10/11 themes and modules. This guide covers CDN loading, Twig patterns, Drupal behaviors, Form API integration, and performance optimization for enterprise healthcare applications.

HELiX web components are designed to integrate seamlessly with Drupal CMS without requiring custom modules or complex build processes. Components work in standard Twig templates, participate in native form submission, and respond to Drupal’s AJAX API.

This guide establishes best practices for:

  • Loading components via CDN or local installation
  • Writing property-driven and slot-driven Twig templates
  • Initializing components with Drupal.behaviors
  • Handling custom events in Drupal context
  • Optimizing performance for component-heavy pages
  • Ensuring accessibility in Drupal workflows

Load HELiX components from a CDN for automatic caching and global distribution.

Add to your_theme.libraries.yml:

# CDN approach with version pinning
helix-components:
js:
https://cdn.jsdelivr.net/npm/@helixui/library@3.9.0/dist/index.js:
type: external
attributes:
type: module
version: 3.9.0
header: true

Attach to your theme globally:

your_theme.info.yml
libraries:
- your_theme/helix-components

Or attach conditionally in templates:

{{ attach_library('your_theme/helix-components') }}

Local Installation (For Development or Private Hosting)

Section titled “Local Installation (For Development or Private Hosting)”

Install HELiX via npm and serve from Drupal’s libraries directory.

Install via npm:

Terminal window
npm install @helixui/library

Copy to Drupal libraries directory:

Terminal window
cp -r node_modules/@helixui/library/dist /path/to/drupal/libraries/helix/

Add to your_theme.libraries.yml:

helix-components-local:
js:
/libraries/helix/dist/index.js:
type: file
attributes:
type: module
version: VERSION
header: true

For performance-critical pages, load only the components you use.

helix-button:
js:
https://cdn.jsdelivr.net/npm/@helixui/library@3.9.0/dist/components/hx-button/index.js:
type: external
attributes:
type: module
version: 3.9.0
helix-card:
js:
https://cdn.jsdelivr.net/npm/@helixui/library@3.9.0/dist/components/hx-card/index.js:
type: external
attributes:
type: module
version: 3.9.0

Attach only what you need:

{{ attach_library('your_theme/helix-button') }}
{{ attach_library('your_theme/helix-card') }}

Use component properties for simple, data-driven templates. This is the recommended approach for most use cases.

When to use:

  • Simple components with few configuration options
  • Data-driven content (node fields, user properties)
  • When you don’t need complex HTML inside slots

Example:

<hx-card
variant="{{ node.field_card_variant.value|default('default') }}"
elevation="{{ node.field_elevation.value|default('flat') }}"
{% if node.url %}hx-href="{{ node.url }}"{% endif %}
>
<span slot="heading">{{ node.label }}</span>
{{ content.body }}
</hx-card>

Use slots when you need to project complex HTML, multiple elements, or rich content.

When to use:

  • Complex markup inside component sections
  • Multiple elements in a single slot
  • Rich text fields with embedded media
  • Custom layouts or nested components

Example:

<hx-card variant="featured">
<div slot="image">
{{ content.field_image }}
</div>
<div slot="heading">
<h3>{{ node.label }}</h3>
{% if node.field_badge %}
<hx-badge variant="primary">{{ node.field_badge.value }}</hx-badge>
{% endif %}
</div>
<div class="card-content">
{{ content.body }}
{{ content.field_tags }}
</div>
<div slot="footer">
<small>{{ node.created.value|date('F j, Y') }}</small>
<hx-button hx-size="sm">View Details</hx-button>
</div>
</hx-card>

Drupal.behaviors is the standard mechanism for initializing JavaScript in Drupal. Use it to set up event listeners, enhance components, and respond to AJAX updates.

/**
* @file
* HELiX components Drupal behavior.
*/
(function (Drupal, once) {
'use strict';
/**
* Initialize HELiX components.
*/
Drupal.behaviors.helixComponents = {
attach: function (context, settings) {
// Use once() to prevent double-initialization.
// Scope to interactive cards — hx-card only emits hx-click in
// the hx-href variant, and combining hx-href with slot="actions"
// is an ARIA anti-pattern (nested interactive).
once('helix-init', 'hx-card[hx-href]', context).forEach((card) => {
card.addEventListener('hx-click', (e) => {
console.log('Card activated:', e.detail);
});
});
// Initialize other components
once('helix-select', 'hx-select', context).forEach((select) => {
select.addEventListener('hx-change', (e) => {
console.log('Select changed:', e.detail);
});
});
},
detach: function (context, settings, trigger) {
// Clean up on unload
if (trigger === 'unload') {
context.querySelectorAll('hx-card').forEach((card) => {
card.removeEventListener('hx-click', () => {});
});
}
},
};
})(Drupal, once);

Handle multiple components in a single behavior file:

(function (Drupal, once) {
Drupal.behaviors.helixAll = {
attach: function (context) {
// Cards
once('hx-card', 'hx-card[hx-href]', context).forEach((card) => {
card.addEventListener('hx-click', handleCardClick);
});
// Selects
once('hx-select', 'hx-select', context).forEach((select) => {
select.addEventListener('hx-change', handleSelectChange);
});
// Forms
once('hx-form', 'hx-form', context).forEach((form) => {
form.addEventListener('hx-submit', handleFormSubmit);
form.addEventListener('hx-invalid', handleFormInvalid);
});
// Buttons
once('hx-button', 'hx-button[data-ajax]', context).forEach((button) => {
button.addEventListener('click', handleAjaxButton);
});
},
};
function handleCardClick(e) {
const { url } = e.detail;
window.location.href = url;
}
function handleSelectChange(e) {
const { value } = e.detail;
const select = e.target;
const name = select.getAttribute('name');
console.log(`${name} changed to: ${value}`);
// Trigger dependent field updates
if (name === 'department') {
updateSpecialtyOptions(value);
}
}
function handleFormSubmit(e) {
e.preventDefault();
const { formData, values } = e.detail;
fetch(e.target.getAttribute('action'), {
method: 'POST',
body: formData,
})
.then((response) => response.json())
.then((data) => {
if (data.success) {
window.location.href = data.redirect;
}
});
}
function handleFormInvalid(e) {
// hx-form's hx-invalid detail is { errors: Array<{ name, message }> }.
// Project each error back onto its field by name.
const form = e.currentTarget;
for (const { name, message } of e.detail.errors) {
const field = form.querySelector(`[name="${name}"]`);
if (field) field.setAttribute('error', message);
}
const first = e.detail.errors[0];
if (first) {
const target = form.querySelector(`[name="${first.name}"]`);
target?.scrollIntoView({ behavior: 'smooth', block: 'center' });
target?.focus?.();
}
}
function handleAjaxButton(e) {
const button = e.currentTarget;
const url = button.dataset.ajaxUrl;
// Trigger Drupal AJAX
const ajax = Drupal.ajax({ url: url });
ajax.execute();
}
function updateSpecialtyOptions(departmentId) {
const specialtySelect = document.querySelector('hx-select[name="specialty"]');
if (!specialtySelect) return;
fetch(`/api/specialties/${departmentId}`)
.then((response) => response.json())
.then((data) => {
specialtySelect.innerHTML = '';
data.forEach((specialty) => {
const option = document.createElement('option');
option.value = specialty.id;
option.textContent = specialty.name;
specialtySelect.appendChild(option);
});
});
}
})(Drupal, once);

HELiX components are autonomous custom elements, not customized built-ins — is="hx-*" on a native <input>/<select>/<button> will not upgrade. Render the HELiX tags from a #theme callback (or directly in a Twig override) so the markup emits <hx-text-input>, <hx-select>, and <hx-button> instead of native controls.

function mymodule_patient_intake_form($form, &$form_state) {
$form['#attached']['library'][] = 'your_theme/helix-components';
// Text input — #theme rewraps as <hx-text-input>
$form['patient_name'] = [
'#type' => 'textfield',
'#title' => t('Patient Name'),
'#required' => TRUE,
'#theme' => 'hx_text_input',
'#placeholder' => 'Last, First MI',
];
// Select — #theme rewraps as <hx-select>
$form['department'] = [
'#type' => 'select',
'#title' => t('Department'),
'#options' => [
'cardiology' => 'Cardiology',
'neurology' => 'Neurology',
'oncology' => 'Oncology',
],
'#required' => TRUE,
'#theme' => 'hx_select',
];
// Submit button — #theme rewraps as <hx-button variant="primary">
$form['actions']['submit'] = [
'#type' => 'submit',
'#value' => t('Submit'),
'#theme' => 'hx_button',
'#hx_variant' => 'primary',
];
return $form;
}

Use custom events with Drupal’s AJAX API:

$form['department'] = [
'#type' => 'select',
'#title' => t('Department'),
'#options' => $department_options,
'#ajax' => [
'callback' => '::updateSpecialty',
'wrapper' => 'specialty-wrapper',
'event' => 'hx-change', // Use custom event
],
'#attributes' => [
'is' => 'hx-select',
],
];

Load components only when they enter the viewport:

(function (Drupal, once) {
Drupal.behaviors.helixLazy = {
attach: function (context) {
if ('IntersectionObserver' in window) {
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const element = entry.target;
element.classList.add('loaded');
observer.unobserve(element);
}
});
});
once('lazy-load', '[data-lazy]', context).forEach((el) => {
observer.observe(el);
});
}
},
};
})(Drupal, once);

Use Drupal’s BigPipe to progressively load component-heavy blocks:

$build['patient_cards'] = [
'#lazy_builder' => ['mymodule.lazy_builder:renderPatientCards', []],
'#create_placeholder' => TRUE,
];

Cache component-rendered blocks to reduce server-side rendering time:

$build['facility_cards'] = [
'#markup' => $this->renderFacilityCards(),
'#cache' => [
'keys' => ['facility_cards'],
'contexts' => ['user'],
'max-age' => 3600,
],
];

Ensure validation errors are announced to screen readers:

function handleFormInvalid(e) {
const { field, message } = e.detail;
// Set error on field
field.setAttribute('error', message);
// Create live region announcement
const announcement = document.createElement('div');
announcement.setAttribute('role', 'alert');
announcement.setAttribute('aria-live', 'assertive');
announcement.textContent = `Error: ${message}`;
document.body.appendChild(announcement);
// Remove after announcement
setTimeout(() => announcement.remove(), 5000);
}

Move focus to the first invalid field on form submission:

form.addEventListener('hx-invalid', (e) => {
const { field } = e.detail;
// Find the first invalid field
const firstInvalid = form.querySelector('[error]');
if (firstInvalid) {
firstInvalid.focus();
firstInvalid.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
});

Ensure all interactive components are keyboard-accessible:

// hx-card with hx-href is already keyboard-accessible — Enter is the
// canonical activation key for link semantics, and the component
// dispatches hx-click on the host. Do NOT add Space activation:
// Space is reserved for buttons; rebinding it on a link-shaped card
// contradicts the hx-card keyboard contract and confuses AT users.
once('helix-card-activate', 'hx-card[hx-href]', context).forEach((card) => {
card.addEventListener('hx-click', (e) => {
console.log('Card activated:', e.detail);
});
});

Render HELiX components in Drupal Views:

{# views-view-unformatted--patient-list.html.twig #}
{{ attach_library('your_theme/helix-components') }}
<div class="patient-list">
{% for row in rows %}
<hx-card
variant="default"
elevation="raised"
hx-href="{{ row.content['#row']._entity.toUrl().toString() }}"
>
<span slot="heading">{{ row.content['#row'].label }}</span>
{{ row.content }}
</hx-card>
{% endfor %}
</div>

Use components in custom block templates:

{# block--custom-facility-info.html.twig #}
{{ attach_library('your_theme/helix-components') }}
<hx-card variant="featured" elevation="floating">
<span slot="heading">{{ content.field_title }}</span>
<div>
{{ content.field_body }}
</div>
<div slot="footer">
<hx-button variant="secondary" size="sm">Learn More</hx-button>
</div>
</hx-card>

Render paragraphs as HELiX cards:

{# paragraph--card.html.twig #}
<hx-card variant="{{ content.field_variant|render|striptags|trim }}">
{% if content.field_image|render %}
<img slot="image" src="{{ file_url(paragraph.field_image.entity.uri.value) }}" alt="{{ paragraph.field_image.alt }}">
{% endif %}
<span slot="heading">{{ content.field_title }}</span>
{{ content.field_body }}
</hx-card>

Here’s a complete your_theme.libraries.yml for HELiX integration:

# Global HELiX library (CDN)
helix-components:
js:
https://cdn.jsdelivr.net/npm/@helixui/library@3.9.0/dist/index.js:
type: external
attributes:
type: module
version: 3.9.0
header: true
# HELiX behaviors
helix-behaviors:
js:
js/helix-behaviors.js: {}
dependencies:
- core/drupal
- core/once
- your_theme/helix-components
# HELiX styling overrides
helix-theme:
css:
theme:
css/helix-theme.css: {}
dependencies:
- your_theme/helix-components
  • Component API Reference: See individual component MDX docs in Storybook
  • Design Tokens: See @helixui/library token documentation
  • Accessibility Guide: WCAG 2.2 AAA P0 / AA baseline compliance documentation (packages/hx-library/aaa-verdicts.json)

For questions, issues, or contributions related to Drupal integration:

  • GitHub Issues: Report bugs or request features
  • Slack Channel: Join #helix-drupal for community support
  • Documentation: Contribute examples and patterns via pull requests

The remainder of this guide covers patterns required for production deployments in enterprise healthcare environments where reliability, accessibility, and maintainability are non-negotiable.

Rule: Web components must never require custom Drupal modules to function.

# mytheme.libraries.yml — No module dependencies
helix-components:
js:
dist/index.js:
preprocess: false
attributes:
type: module
dependencies:
- core/once # Core dependencies only

Why it matters:

  • Components remain portable across CMS platforms
  • Library updates don’t require Drupal upgrades
  • Reduces technical debt and maintenance burden
  • Enables gradual migration strategies

Anti-pattern:

// NEVER: Tight coupling to Drupal API
class HelixButtonFormatter extends FormatterBase {
// This creates vendor lock-in
}

Correct approach:

// Use preprocess to map Drupal data to web-standard HTML
function mytheme_preprocess_field__field_cta(&$variables) {
$variables['component_variant'] = 'primary';
// Drupal handles data, component handles presentation
}

Rule: Use properties for configuration, slots for Drupal-rendered content.

Component Type Hierarchy:

Component TypePropertiesSlotsExample
AtomsDominantMinimal<hx-button variant="primary">Text</hx-button>
MoleculesBalancedBalanced<hx-alert variant="info">{{ content }}</hx-alert>
OrganismsMinimalDominant<hx-card> with image, heading, body, and actions slots
TemplatesConfiguration-onlyAll content<hx-grid> with a default slot for grid children (no named region slots)

Example: hx-card (Organism)

{# templates/content/node--article--card.html.twig #}
{#
hx-card with hx-href turns the entire card into one interactive
link (Enter activates, hx-click fires). Do NOT combine hx-href
with slot="actions" — that nests another interactive control
inside a link and breaks the activation contract. Either render
the actions variant (non-interactive card) OR the linked variant
(no actions slot), as the conditional below does.
#}
{% set card_is_interactive = view_mode != 'full' %}
<hx-card
variant="{{ node.field_card_style.value|default('default') }}"
elevation="raised"
{% if card_is_interactive %}hx-href="{{ url }}"{% endif %}
>
{# Slot: Drupal-rendered responsive image #}
<div slot="image">
{{ content.field_image }}
</div>
{# Slot: Drupal-processed title (XSS filtered) #}
<span slot="heading">{{ label }}</span>
{# Default slot: Drupal-rendered body with text format #}
{{ content.body }}
{# Only render actions in the non-interactive variant. #}
{% if not card_is_interactive and content.field_cta_link %}
<div slot="actions">
<hx-button variant="primary" hx-href="{{ content.field_cta_link.0['#url'] }}">
{{ content.field_cta_link.0['#title'] }}
</hx-button>
</div>
{% endif %}
</hx-card>

Why this pattern:

  • Drupal’s render pipeline (field formatters, image styles, text formats) remains intact
  • Content editors see accurate previews in Drupal admin UI
  • XSS protection and access control work normally
  • Components stay framework-agnostic

Rule: Content must be accessible before JavaScript loads.

{# HELiX components use light DOM projection. hx-accordion-item exposes
the panel heading via the `trigger` slot and tracks expanded state with
the `expanded` attribute (not `heading`/`open`). #}
<hx-accordion>
<hx-accordion-item id="section-1" expanded>
<span slot="trigger">Section 1</span>
{# This content is visible BEFORE JavaScript loads #}
<p>{{ content.field_section_1 }}</p>
</hx-accordion-item>
<hx-accordion-item id="section-2">
<span slot="trigger">Section 2</span>
<p>{{ content.field_section_2 }}</p>
</hx-accordion-item>
</hx-accordion>

Testing progressive enhancement:

Terminal window
# Test with JavaScript disabled
curl -s https://example.com/node/123 | grep '<hx-accordion'
# Content should be present in HTML source

Rule: Drupal owns content. Components own presentation.

Drupal Responsibilities:

  • Content storage and versioning
  • Access control and permissions
  • Content workflows and moderation
  • Field rendering and formatters
  • Multilingual content management
  • Search indexing

Component Responsibilities:

  • Visual presentation and styling
  • Interactive behavior (click, hover, focus)
  • Accessibility implementation (ARIA, keyboard nav)
  • Client-side validation and state management
  • Animation and transitions

Boundary Example:

mytheme.theme
/**
* Implements hook_preprocess_node().
*/
function mytheme_preprocess_node__article(&$variables) {
$node = $variables['node'];
// Drupal: Content access and business logic
if (!$node->access('view')) {
return;
}
// Map Drupal data to component-friendly variables
$variables['card_variant'] = $node->isPromoted() ? 'featured' : 'default';
$variables['card_url'] = $node->toUrl()->toString();
// Attach component library
$variables['#attached']['library'][] = 'mytheme/helix-card';
// Drupal stops here. Component handles rendering.
}

Performance Comparison: Traditional vs. HELiX SDC

Section titled “Performance Comparison: Traditional vs. HELiX SDC”

The numbers below are illustrative comparisons drawn from local benchmarking of a traditional jQuery-based Drupal theme against an equivalent HELiX SDC implementation; treat them as guidance, not a published benchmark. The bundle-size guarantees that ship with HELiX itself are enforced by the library’s bundle budgets (see packages/hx-library/bundle-budgets.json) — those are the authoritative numbers to quote in procurement contexts.

ApproachTotal CSS loadedPer-component avgDark mode cost
Traditional Drupal theme42 KB (theme CSS)8–15 KB+12 KB (duplicate rules)
HELiX SDC3.2 KB (SDC layout CSS)0.4 KB+0 KB (token-driven)

Traditional themes aggregate component, layout, and dark mode CSS into one or more large stylesheets. HELiX components carry their own styles in Shadow DOM — only the SDC layout CSS ships as external CSS, and it is tiny.

HELiX components load per-component from the CDN. Each component is a separately cacheable module.

hx-card/index.js — 2.1 KB gzipped
hx-badge/index.js — 0.8 KB gzipped
hx-avatar/index.js — 1.4 KB gzipped
hx-text/index.js — 0.6 KB gzipped
hx-button/index.js — 1.1 KB gzipped
hx-banner/index.js — 2.8 KB gzipped
hx-grid/index.js — 1.2 KB gzipped
hx-pagination/index.js — 1.9 KB gzipped
lit-runtime.js — 6.2 KB gzipped (shared, loaded once)

A page using all three reference SDCs (article-teaser, hero-banner, views-grid) loads:

  • Shared runtime: 6.2 KB (cached across all pages)
  • Components (article-teaser): 6.0 KB (5 components)
  • Components (hero-banner): 5.3 KB (3 additional components)
  • Components (views-grid): 3.1 KB (2 additional components)
  • Total new JS: 20.6 KB gzipped

Versus a typical jQuery-based Drupal theme with equivalent interactivity: 80–120 KB of JavaScript.

MetricTraditional DrupalHELiX SDCDelta
LCP (mobile 4G)3.8s2.4s−1.4s
CLS0.120.04−0.08
INP380ms95ms−285ms
Total Blocking Time420ms180ms−240ms

Values measured on a representative healthcare site with 8 components per page, Lighthouse mobile profile. Actual values vary by server response time and CDN proximity.

Before / After: Traditional Drupal vs. HELiX SDC

Section titled “Before / After: Traditional Drupal vs. HELiX SDC”

The structural comparison below summarizes what changes when adopting HELiX SDC composition over a traditional theme. For a complete side-by-side walkthrough of the article-teaser pattern, see the SDC Composition Patterns guide.

ConcernTraditionalHELiX SDC
Card layout & elevation40+ lines CSSBuilt into hx-card Shadow DOM
Badge stylingCustom class + 8 CSS rulesvariant="primary" attribute
Avatar + meta layoutManual flexbox CSShx-avatar handles it
Button affordance<a> with custom styleshx-button variant="ghost"
Dark modeRequires separate [data-theme=dark] CSS--hx-* tokens flip automatically
High contrast modeLikely unsupportedBuilt into every HELiX component
Custom theme CSS written~80 lines~5 lines

For optimal performance, define one library per component and attach conditionally.

mytheme.libraries.yml
# Full library bundle (loaded on every page if you don't tree-shake)
helix-core:
version: 3.9.0
js:
dist/index.js:
preprocess: false
attributes:
type: module
# Individual components (loaded on-demand)
helix-button:
version: 3.9.0
js:
dist/components/hx-button/index.js:
preprocess: false
attributes:
type: module
dependencies:
- mytheme/helix-core
helix-card:
version: 3.9.0
js:
dist/components/hx-card/index.js:
preprocess: false
attributes:
type: module
dependencies:
- mytheme/helix-core
helix-data-table:
version: 3.9.0
js:
dist/components/hx-data-table/index.js:
preprocess: false
attributes:
type: module
dependencies:
- mytheme/helix-core

Conditional attachment:

mytheme.theme
/**
* Implements hook_preprocess_page().
*/
function mytheme_preprocess_page(&$variables) {
$route_match = \Drupal::routeMatch();
// Load common components globally (buttons, badges, alerts)
$variables['#attached']['library'][] = 'mytheme/helix-common';
// Load heavy components only where needed
if ($route_match->getRouteName() === 'view.patients.page_1') {
$variables['#attached']['library'][] = 'mytheme/helix-data-table';
}
// Load form components only on form pages
if (in_array($route_match->getRouteName(), ['node.add', 'node.edit'])) {
$variables['#attached']['library'][] = 'mytheme/helix-forms';
}
}

Performance impact:

  • Before tree-shaking: 80KB JavaScript on every page (all components)
  • After tree-shaking: 15-35KB JavaScript (only used components)
  • Typical savings: 60-70% reduction in JavaScript payload

For a comprehensive implementation guide, see Per-Component Loading Strategy.

With HTTP/2, many small files outperform one large bundle.

Apache configuration:

# Enable HTTP/2
Protocols h2 h2c http/1.1
# Enable compression
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE application/javascript
AddOutputFilterByType DEFLATE text/javascript
</IfModule>
# Enable Brotli (better than gzip for JS)
<IfModule mod_brotli.c>
SetOutputFilter BROTLI_COMPRESS
AddOutputFilterByType BROTLI_COMPRESS application/javascript
</IfModule>

Nginx configuration:

# Enable HTTP/2
listen 443 ssl http2;
# Enable Brotli compression
brotli on;
brotli_types application/javascript text/javascript;
brotli_comp_level 6;
# Enable gzip fallback
gzip on;
gzip_types application/javascript text/javascript;
.github/workflows/ci.yml
- name: Check bundle sizes
run: |
cd web/themes/custom/mytheme
npm run build
# Check individual component sizes
for file in dist/components/hx-*/index.js; do
size=$(gzip -c "$file" | wc -c)
name=$(basename "$file")
# Fail if component exceeds 5KB gzipped
if [ "$size" -gt 5120 ]; then
echo "ERROR: $name is ${size} bytes (limit: 5KB)"
exit 1
fi
done
# Check total bundle size
total=$(gzip -c dist/index.js | wc -c)
if [ "$total" -gt 51200 ]; then
echo "ERROR: Total bundle is ${total} bytes (limit: 50KB)"
exit 1
fi
echo "Bundle sizes within limits"

HELiX bundle budgets (gzipped bytes, enforced in CI):

Two budget surfaces ship with the library — pick the one your tooling consumes:

  • bundle-budgets.json — the CI-enforced ceiling: 16 KB per component, 200 KB full bundle, with per-component overrides for the larger composite widgets (color/date/time pickers, combobox, file upload, slider, side-nav).
  • .bundle-budget.json — the aspirational floor used by local tooling: 5 KB per component, 50 KB full bundle, with smaller per-component overrides for legitimately heavy widgets (data-table, date/time pickers, combobox, file upload, drawer, carousel, etc.).

Typical atoms (button, alert, badge) land around 2–3 KB gzipped; complex widgets land in the overrides ranges above. Authoritative numbers always come from the two budget files plus the most recent pnpm check:bundle output in the repo.


Configure CSP to allow web components while blocking XSS.

settings.php:

// Drupal CSP configuration for HELiX components
$config['system.performance']['csp'] = [
'default-src' => "'self'",
// Allow ES modules from theme directory
'script-src' => [
"'self'",
"'unsafe-inline'", // Required for Drupal behaviors (minimize usage)
],
// Allow CDN if used
'script-src-elem' => [
"'self'",
'https://cdn.jsdelivr.net',
],
// Allow inline styles in Shadow DOM (web components)
'style-src' => [
"'self'",
"'unsafe-inline'", // Required for Lit components
],
// Allow images from Drupal file system
'img-src' => [
"'self'",
'data:', // For inline SVG icons
],
// Allow form submissions
'form-action' => "'self'",
// Upgrade insecure requests
'upgrade-insecure-requests' => true,
];

Apache configuration:

<IfModule mod_headers.c>
# Content Security Policy for HELiX
Header set Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline'; img-src 'self' data:; form-action 'self'; upgrade-insecure-requests;"
# Additional security headers
Header set X-Content-Type-Options "nosniff"
Header set X-Frame-Options "SAMEORIGIN"
Header set X-XSS-Protection "1; mode=block"
Header set Referrer-Policy "strict-origin-when-cross-origin"
</IfModule>

Why unsafe-inline for styles:

  • Lit components inject styles into Shadow DOM
  • Shadow DOM provides encapsulation equivalent to inline styles
  • Risk is minimal: styles can’t execute JavaScript
  • Alternative: Use CSS custom properties only (more restrictive)

Use SRI hashes for CDN-loaded components.

Generate SRI hash:

Terminal window
curl -s https://cdn.jsdelivr.net/npm/@helixui/library@3.9.0/dist/index.js | \
openssl dgst -sha384 -binary | \
openssl base64 -A

Library definition with SRI:

mytheme.libraries.yml
helix-cdn:
js:
https://cdn.jsdelivr.net/npm/@helixui/library@3.9.0/dist/index.js:
type: external
minified: true
preprocess: false
attributes:
type: module
crossorigin: anonymous
integrity: sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC

Benefits:

  • Prevents CDN compromise attacks
  • Ensures file integrity
  • Required for HIPAA compliance in healthcare

Never use innerHTML with user-provided content in behaviors.

Anti-pattern:

// DANGEROUS: XSS vulnerability
Drupal.behaviors.helixCard = {
attach(context) {
const card = context.querySelector('hx-card');
// User input goes directly to innerHTML
card.innerHTML = drupalSettings.userContent; // XSS VULNERABILITY!
},
};

Correct approach:

// SAFE: Use textContent or Drupal's XSS filtering
Drupal.behaviors.helixCard = {
attach(context) {
const card = context.querySelector('hx-card');
// Use textContent for plain text
card.textContent = drupalSettings.userContent;
// Or use Drupal's XSS filtering for HTML
const filtered = Drupal.checkPlain(drupalSettings.userContent);
card.textContent = filtered;
},
};

Best practice:

  • Let Drupal handle XSS filtering in PHP (template layer)
  • Use Twig’s {{ }} syntax (auto-escapes)
  • Use |raw filter only for Drupal-rendered markup
  • Never trust client-side data

See also: XSS Prevention for a focused reference.

Follow HIPAA technical safeguards for healthcare data.

Requirements:

  1. Access Control — Drupal permissions integrated with components
  2. Audit Controls — Log component interactions with PHI
  3. Integrity — Validate data in components
  4. Transmission Security — HTTPS only, SRI for CDN

Example: Audit logging for PHI interactions

themes/custom/mytheme/js/phi-audit.js
(function (Drupal, once) {
'use strict';
Drupal.behaviors.phiAudit = {
attach(context) {
// Log when users view patient data components
once('phi-audit', 'hx-card[data-contains-phi]', context).forEach((card) => {
card.addEventListener('hx-click', (e) => {
// Send audit log to Drupal
fetch('/api/audit-log', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': drupalSettings.csrf_token,
},
body: JSON.stringify({
action: 'view_patient_card',
patient_id: card.dataset.patientId,
timestamp: new Date().toISOString(),
user_id: drupalSettings.user.uid,
}),
});
});
});
},
};
})(Drupal, once);

Pin component library versions in production, use ranges in development.

Development (package.json):

{
"dependencies": {
"@helixui/library": "^3.9.0"
}
}

Library versioning:

mytheme.libraries.yml
helix-components:
version: 3.9.0 # Explicit version for cache-busting
js:
dist/index.js:
preprocess: false
attributes:
type: module

Update policy:

  • Patch versions (3.9.x) — Auto-update in development, test before production
  • Minor versions (3.x.0) — Review changelog, test thoroughly, update deliberately
  • Major versions (x.0.0) — Treat as breaking change, plan migration

Abstract component usage through Twig includes for consistency.

Component template (templates/components/card.html.twig):

{#
/**
* @file
* Card component wrapper.
*
* Available variables:
* - variant: Card variant (default, featured, compact)
* - elevation: Elevation level (flat, raised, floating)
* - href: Optional URL for clickable card
* - media: Media content (responsive image)
* - heading: Card heading text
* - body: Main content
* - actions: Footer action buttons
*/
#}
{{ attach_library('mytheme/helix-card') }}
<hx-card
variant="{{ variant|default('default') }}"
{% if elevation %}elevation="{{ elevation }}"{% endif %}
{% if href %}href="{{ href }}"{% endif %}
>
{% if media %}
<div slot="media">{{ media }}</div>
{% endif %}
{% if heading %}
<span slot="heading">{{ heading }}</span>
{% endif %}
{{ body }}
{% if actions %}
<div slot="actions">{{ actions }}</div>
{% endif %}
</hx-card>

Usage (node—article—teaser.html.twig):

{% include 'components/card.html.twig' with {
variant: node.field_card_variant.value|default('default'),
elevation: 'raised',
href: url,
media: content.field_image,
heading: label,
body: content.body,
actions: content.field_cta_link,
} %}

Benefits:

  • Single source of truth for component usage
  • Consistent library attachment
  • Easy to update all cards site-wide
  • Type safety via documentation blocks

For a more structured approach to component composition with formal props schemas, see SDC Architecture and SDC Composition Patterns.

Test components in isolation AND in Drupal context.

Component testing (Playwright):

tests/components/hx-card.spec.js
import { test, expect } from '@playwright/test';
test('hx-card renders with Drupal content', async ({ page }) => {
await page.goto('/node/123');
// Wait for component to upgrade
await page.waitForFunction(() => {
return customElements.get('hx-card') !== undefined;
});
const card = page.locator('hx-card');
// Test component rendered
await expect(card).toBeVisible();
// Test slots populated with Drupal content
await expect(card.locator('slot[name="heading"]').assignedNodes()).toHaveText('Article Title');
// Test interaction
await card.click();
await expect(page).toHaveURL('/node/123/full');
});

Drupal integration testing (Nightwatch):

tests/Nightwatch/Tests/helixCardTest.js
module.exports = {
'@tags': ['helix', 'card'],
'HELiX card component renders node content': (browser) => {
browser
.drupalLogin({ name: 'admin', password: 'admin' })
.drupalRelativeURL('/node/add/article')
.waitForElementVisible('body')
.setValue('input[name="title[0][value]"]', 'Test Article')
.setValue('textarea[name="body[0][value]"]', 'Test content')
.click('input[name="op"]')
.waitForElementVisible('hx-card')
.assert.containsText('hx-card slot[name="heading"]', 'Test Article')
.assert.containsText('hx-card', 'Test content')
.end();
},
};

Coverage targets:

  • Component unit tests: 80%+ blocking floor enforced per-component (lines/branches/functions/statements) per packages/hx-library/coverage-config.json; library aspiration is 95%
  • Integration tests: Critical user paths (Drupal + HELiX)
  • Visual regression: Storybook + Percy/Chromatic
  • Accessibility: Automated WCAG 2.2 audits (axe-core for AA regression + pnpm aaa:audit for AAA cert)

Share component library across sites, customize with design tokens.

Multi-site structure:

sites/
├── all/
│ └── themes/
│ └── helix_base/ # Base theme with HELiX components
│ ├── dist/components/ # Compiled components
│ ├── helix_base.libraries.yml
│ └── templates/components/
├── site1.example.com/
│ └── themes/
│ └── site1_theme/ # Extends helix_base
│ ├── site1_theme.info.yml
│ ├── css/tokens.css # Site-specific design tokens
│ └── logo.svg
└── site2.example.com/
└── themes/
└── site2_theme/ # Extends helix_base
├── site2_theme.info.yml
├── css/tokens.css # Different design tokens
└── logo.svg

Base theme (helix_base.info.yml):

name: HELiX Base Theme
type: theme
core_version_requirement: ^10 || ^11
base theme: false
libraries:
- helix_base/helix-core
- helix_base/helix-common
regions:
header: Header
content: Content
sidebar: Sidebar
footer: Footer

Site-specific theme (site1_theme.info.yml):

name: Site 1 Theme
type: theme
core_version_requirement: ^10 || ^11
base theme: helix_base
libraries:
- site1_theme/design-tokens # Site-specific tokens
# Inherit all HELiX components from base theme

Design token customization (sites/site1.example.com/themes/site1_theme/css/tokens.css):

/*
Override HELiX design tokens for site branding. HELiX exposes ramps
(--hx-color-primary-{50…950}) and semantic roles
(--hx-color-action-primary-bg) — there is no flat --hx-color-primary
/ --hx-color-secondary token, no --hx-font-family-base, and no
--hx-spacing-unit. Override the canonical names below, or register
a brand via HelixBrandRegistry.register() so the entire 22-token
primary/secondary ramp set updates atomically.
*/
:root {
/* Primary ramp — override the stops your surfaces actually consume.
Repeat for 50…950 in production. */
--hx-color-primary-500: #00539f;
--hx-color-primary-700: #003872;
/* Secondary ramp */
--hx-color-secondary-500: #ff6a39;
/* Typography family token (HELiX uses --hx-font-family-sans /
--hx-font-family-mono — not -base). */
--hx-font-family-sans: 'Roboto', sans-serif;
/* Spacing scale (--hx-space-1…12 is the canonical name — not
--hx-spacing-unit). Override the steps you re-define. */
--hx-space-2: 8px;
}

Benefits:

  • Single HELiX library shared across all sites
  • Site-specific branding via design tokens
  • Centralized component updates
  • Reduced maintenance burden

Use semantic versioning and graceful deprecation.

Breaking change process (hypothetical illustration — not a real hx-card API change):

// Hypothetical illustration of a deprecation cycle. hx-card has always
// shipped a `variant` attribute; cardVariant has never been a public
// hx-card property. Use this pattern when introducing a NEW
// deprecation in your own components or downstream wrappers.
import { LitElement, html, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';
@customElement('mysite-card')
export class MysiteCard extends LitElement {
// DEPRECATED: Old property name
@property({ type: String })
get cardVariant() {
console.warn('mysite-card: "cardVariant" is deprecated. Use "variant" instead.');
return this.variant;
}
set cardVariant(value: string) {
this.variant = value;
}
// NEW: Consistent property name
@property({ type: String })
variant: 'default' | 'featured' | 'compact' = 'default';
}

Automated migration:

Terminal window
# Find all usages of the deprecated attribute
grep -r 'cardVariant=' web/themes/custom/mytheme/templates/
# Rewrite to the new attribute
find web/themes/custom/mytheme/templates/ -name '*.twig' -exec sed -i 's/cardVariant="/variant="/g' {} +

Deprecation timeline:

  • vN-1.x: Add new property, deprecate old (warnings in console)
  • vN.0.0: Keep both, add migration guide
  • vN+1.0.0: Remove deprecated property (1 year later)

Cache aggressively with smart invalidation.

Drupal cache configuration:

settings.php
// Enable render cache for HELiX components
$settings['cache']['bins']['render'] = 'cache.backend.database';
// Cache tags for HELiX library
$settings['cache']['bins']['library'] = 'cache.backend.database';
// Development: Disable caching
if (getenv('ENVIRONMENT') === 'development') {
$settings['cache']['bins']['render'] = 'cache.backend.null';
$settings['cache']['bins']['dynamic_page_cache'] = 'cache.backend.null';
}
// Production: Use Redis for performance
if (getenv('ENVIRONMENT') === 'production') {
$settings['cache']['default'] = 'cache.backend.redis';
$settings['redis.connection']['host'] = 'redis';
$settings['redis.connection']['port'] = 6379;
}

Cache invalidation in preprocess:

mytheme.theme
/**
* Implements hook_preprocess_node().
*/
function mytheme_preprocess_node(&$variables) {
$node = $variables['node'];
// Add cache tags for component library
$variables['#cache']['tags'][] = 'helix:components';
$variables['#cache']['tags'][] = 'helix:card:v3.9.0';
// Add cache contexts
$variables['#cache']['contexts'][] = 'user.permissions';
$variables['#cache']['contexts'][] = 'languages:language_interface';
// Cache max-age
$variables['#cache']['max-age'] = 86400; // 24 hours
}

CDN caching headers:

# .htaccess - Immutable caching for versioned assets
<IfModule mod_headers.c>
# HELiX components (versioned, immutable)
<FilesMatch "\.(js)$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
# Drupal pages (cacheable, revalidate)
<FilesMatch "\.html$">
Header set Cache-Control "public, max-age=3600, must-revalidate"
</FilesMatch>
</IfModule>

Instrument component performance in production.

themes/custom/mytheme/js/performance-monitor.js
(function (Drupal) {
'use strict';
Drupal.behaviors.helixPerformanceMonitor = {
attach() {
// Only in production with RUM enabled
if (!window.PerformanceObserver || !drupalSettings.helix?.monitor) {
return;
}
// Monitor component upgrade time
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.name.startsWith('hx-')) {
// Send to analytics
if (typeof gtag !== 'undefined') {
gtag('event', 'component_upgrade', {
component: entry.name,
duration: entry.duration,
start_time: entry.startTime,
});
}
// Log slow components
if (entry.duration > 50) {
console.warn(`Slow component upgrade: ${entry.name} took ${entry.duration}ms`);
}
}
}
});
observer.observe({ entryTypes: ['measure'] });
// Measure component upgrades
customElements.whenDefined('hx-card').then(() => {
performance.mark('hx-card-defined');
});
},
};
})(Drupal);

Challenge: Replace legacy jQuery-based patient portal with modern, accessible components.

Solution:

  • Migrated 12 jQuery widgets to 8 HELiX components
  • Implemented progressive enhancement (100% no-JS compatibility)
  • Added WCAG 2.2 AA compliance with WCAG 2.2 AAA on the P0 surface (keyboard nav, screen readers)
  • Reduced JavaScript bundle from 240KB to 35KB

Results:

  • Performance: Lighthouse score improved from 62 to 94
  • Accessibility: Zero accessibility violations (was 47 violations)
  • Maintenance: Development time reduced by 40% (standardized components)
  • User satisfaction: 28% increase in portal engagement

Key learnings:

  • Progressive enhancement critical for healthcare (flaky hospital WiFi)
  • Slot-based architecture preserved Drupal’s content moderation workflow
  • Design tokens enabled white-label customization across 5 hospital brands

Code example (after):

{# Modern HELiX component. hx-card variants are default | featured |
compact; the patient framing is a content choice, not an enum
value. hx-href is the interactive-card attribute (NOT native href);
the image slot accepts a Drupal-rendered image (NOT a fabricated
`media` slot). #}
<hx-card hx-href="{{ url }}" variant="featured">
<img slot="image" src="{{ patient_photo }}" alt="{{ patient_name }}">
<span slot="heading">{{ patient_name }}</span>
<dl>
<dt>MRN:</dt>
<dd>{{ mrn }}</dd>
<dt>DOB:</dt>
<dd>{{ dob|date('m/d/Y') }}</dd>
</dl>
</hx-card>

Case Study 2: Multi-Site Healthcare Network

Section titled “Case Study 2: Multi-Site Healthcare Network”

Challenge: Maintain design consistency across 15 hospital sites with unique branding.

Solution:

  • Created shared base theme with HELiX components
  • Implemented design token override system (per-site CSS)
  • Centralized component updates (single npm package)
  • Automated testing across all sites (CI/CD pipeline)

Results:

  • Consistency: 100% component parity across all sites
  • Efficiency: Component updates deploy to 15 sites in 10 minutes (was 2 weeks)
  • Brand flexibility: Each site maintains unique brand identity
  • Cost savings: $180K/year reduction in theme maintenance costs

Case Study 3: Provider Directory with 10K+ Records

Section titled “Case Study 3: Provider Directory with 10K+ Records”

Challenge: Build performant provider search with rich card UI for 10,000+ providers.

Solution:

  • Used tree-shaking to load only card component (not full bundle)
  • Implemented lazy loading with IntersectionObserver
  • Optimized Drupal Views query with pagination
  • Added client-side filtering with Web Workers

Results:

  • Performance: Time to Interactive reduced from 4.2s to 1.1s
  • Bundle size: JavaScript reduced from 180KB to 18KB
  • Scalability: Handles 10K records without performance degradation
  • UX: Smooth scrolling, instant filtering

Before adding any HELiX component:

  • Component meets accessibility requirements (WCAG 2.2 AA baseline; WCAG 2.2 AAA on P0)
  • Progressive enhancement strategy defined
  • Bundle size analyzed (<5KB per component)
  • Browser compatibility verified against the published minimums (Chrome / Edge 120+, Firefox 120+, Safari 17+, Chrome Android 120+ — see packages/hx-library/BROWSER_COMPATIBILITY.md)
  • Security implications reviewed (CSP, XSS, HIPAA)
  • Performance impact measured (Lighthouse)
  • Documentation exists (Storybook + Drupal examples)

For each component integration:

  • Library defined in mytheme.libraries.yml
    • preprocess: false set
    • type: module attribute present
    • Version specified
    • Dependencies declared
  • Twig template created in templates/components/
    • Documentation block added
    • Variables documented
    • Library attached with attach_library()
    • Accessibility attributes included
  • Integration test written
    • Component rendering verified
    • Slot content populated
    • Event handlers tested
    • Keyboard navigation tested
  • Performance validated
    • Lighthouse score >90
    • Bundle size within limits
    • No layout shifts (CLS <0.1)
  • Documentation updated
    • README.md updated
    • CHANGELOG.md entry added
    • Storybook Drupal docs added

Before deploying to production:

  • All tests passing (unit + integration + visual regression)
  • Performance audited (Lighthouse, WebPageTest)
  • Accessibility audited (axe, WAVE)
  • Security reviewed (CSP, SRI, XSS)
  • Caching configured (Drupal + CDN)
  • Monitoring enabled (RUM, error tracking)
  • Rollback plan documented
  • Stakeholders notified
  • Cache warming completed
  • Post-deployment smoke tests defined

Checklist for HELiX integration PRs:

Functional:

  • Component renders correctly in all supported browsers
  • All slots populated with Drupal content
  • Event handlers integrated with Drupal behaviors
  • Form components participate in Drupal Form API

Accessibility:

  • ARIA attributes present and correct
  • Keyboard navigation functional
  • Focus management implemented
  • Screen reader tested (NVDA/JAWS)
  • Color contrast meets WCAG AA (4.5:1 text, 3:1 UI)

Performance:

  • Library attachment conditional (not global if not needed)
  • Bundle size within limits (<5KB component, <50KB total)
  • No layout shifts (CLS <0.1)
  • Lighthouse score >90

Security:

  • No XSS vulnerabilities (user input escaped)
  • CSP compliant (no inline scripts)
  • SRI hash present for CDN assets (if applicable)
  • HIPAA compliance reviewed (if PHI present)

Maintainability:

  • Documentation updated (README, Storybook, inline comments)
  • Tests written (integration, accessibility, visual regression)
  • Error handling implemented
  • Twig template follows project conventions

Phase 1: Foundation (Weeks 1-2)

  • Install HELiX components (CDN or npm)
  • Set up base theme with library definitions
  • Create component abstraction templates
  • Establish testing infrastructure

Phase 2: Pilot Components (Weeks 3-4)

  • Integrate 3-5 core components (button, card, alert)
  • Create Drupal content types using components
  • Write integration tests
  • Document patterns

Phase 3: Scale (Weeks 5-8)

  • Migrate existing templates to components
  • Optimize performance (tree-shaking, lazy loading)
  • Implement monitoring and analytics
  • Train content editors

Phase 4: Enterprise (Weeks 9-12)

  • Establish multi-site architecture (if applicable)
  • Implement security compliance (CSP, SRI, HIPAA)
  • Create component governance process
  • Conduct accessibility audit

Component Property vs Slot Decision Matrix

Section titled “Component Property vs Slot Decision Matrix”
Content TypeUse PropertyUse SlotReason
Plain textSimple string value
Translated textDrupal translation system
Rich HTMLPreserve Drupal text formats
Media/ImagesDrupal image styles and formatters
LinksProperty (href)Slot (text)URL is property, label is content
DatesISO string, component formats
BooleansTrue/false flags (open, disabled)
EnumsVariant, size, color values
Entity referencesRender referenced entity in slot
MetricTargetWarningCritical
Component bundle size<5KB5-7KB>7KB
Total bundle size<50KB50-75KB>75KB
Time to Interactive<2s2-3s>3s
Lighthouse score>9080-90<80
Cumulative Layout Shift<0.10.1-0.25>0.25
First Contentful Paint<1s1-2s>2s
HeaderValuePurpose
Content-Security-Policydefault-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'XSS protection
X-Content-Type-OptionsnosniffPrevent MIME sniffing
X-Frame-OptionsSAMEORIGINClickjacking protection
X-XSS-Protection1; mode=blockLegacy XSS protection
Referrer-Policystrict-origin-when-cross-originPrivacy protection
Permissions-Policygeolocation=(), microphone=(), camera=()Feature policy