Twig Integration Fundamentals
apps/docs/src/content/docs/drupal/twig-templates/fundamentals Click to copy apps/docs/src/content/docs/drupal/twig-templates/fundamentals HELiX web components are native HTML custom elements. In Drupal Twig templates they work exactly like standard HTML tags — Drupal renders the markup server-side, and the HELiX JavaScript hydrates the components client-side when it loads. This document covers the mental model, basic usage patterns, and the structural rules you need before writing your first template.
The Integration Model
Section titled “The Integration Model”Web Components Are HTML
Section titled “Web Components Are HTML”Custom elements are part of the HTML specification. A browser that has not yet loaded the HELiX JavaScript will still parse <hx-card> as a valid (but un-upgraded) HTML element. All child content — slots, text, nested elements — is available immediately in the DOM.
{# Drupal renders this markup during the server-side template pass #}<hx-card variant="default" elevation="raised"> <span slot="heading">Patient Information</span> <p>John Doe — Cardiology — MRN 00412309</p></hx-card>When the @helixui/library script executes, the browser upgrades every <hx-card> instance it finds, attaching the Shadow DOM and component behaviour. Content that was already in the Light DOM (the slot content) moves into the appropriate slots automatically.
Progressive Enhancement Philosophy
Section titled “Progressive Enhancement Philosophy”Because slot content lives in the Light DOM before JavaScript loads, HELiX components follow progressive enhancement by default:
- Content is indexable — search engines see all text without executing JavaScript.
- Accessible before hydration — screen readers can traverse slot content even if the Shadow DOM has not attached.
- No flash of unstyled content — component styles are encapsulated; there is no FOUC from external stylesheets.
- Works without JavaScript — essential information is readable even when JS fails entirely.
{# Good: content is visible immediately #}<hx-card variant="featured"> <span slot="heading">Appointment Confirmed</span> <p>Dr. Smith — March 28, 2026 at 2:30 PM</p> <div slot="footer">Cardiology — Room 412</div></hx-card>
{# Bad: card is empty until JavaScript runs #}<hx-card variant="featured" data-appointment-id="789"></hx-card>Server-Side vs. Client-Side Rendering
Section titled “Server-Side vs. Client-Side Rendering”| Concern | Rendered By | When |
|---|---|---|
| HTML structure (tags, attributes) | Drupal / Twig | Server-side, during page render |
| Slot content | Drupal / Twig | Server-side |
| Shadow DOM layout | Browser / HELiX | Client-side, after script loads |
| Component styles | Browser / HELiX | Client-side, encapsulated in Shadow DOM |
| Event handlers | Browser / HELiX | Client-side |
| Complex properties (objects, arrays) | Drupal Behavior JS | Client-side, via customElements.whenDefined() |
Basic Component Usage in Twig
Section titled “Basic Component Usage in Twig”Simple Button
Section titled “Simple Button”{# templates/components/cta-button.html.twig #}<hx-button variant="{{ variant|default('primary') }}" hx-size="{{ size|default('md') }}" {% if disabled %}disabled{% endif %}> {{ button_text }}</hx-button>In a node template:
{# templates/node/node--article.html.twig #}<article{{ attributes }}> {{ title_prefix }} <h1{{ title_attributes }}>{{ label }}</h1> {{ title_suffix }}
<div{{ content_attributes }}> {{ content.body }}
{% if content.field_cta_text %} <hx-button variant="primary" hx-size="lg" type="button"> {{ content.field_cta_text.0['#context'].value }} </hx-button> {% endif %} </div></article>Alert Banner
Section titled “Alert Banner”{# Inline alert from a block field — hx-alert is hidden until `open` is set, so include it on the upgraded host when the alert should be visible. #}<hx-alert open variant="{{ content.field_alert_type.0['#markup']|default('info') }}" {% if content.field_dismissible.0['#markup'] == '1' %}dismissible{% endif %}> {{ content.field_alert_message }}</hx-alert>Card with Multiple Slots
Section titled “Card with Multiple Slots”{# templates/node/node--story.html.twig #}<hx-card variant="featured" elevation="floating">
{# image slot #} {% if content.field_featured_image|render|trim %} <div slot="image"> {{ content.field_featured_image }} </div> {% endif %}
{# heading slot #} <span slot="heading">{{ label }}</span>
{# default slot: body content #} <div class="story__body"> {{ content.body }} </div>
{# footer slot #} {% if content.field_author or content.field_date %} <div slot="footer"> {% if content.field_author %} <span>By {{ content.field_author.0['#context'].value }}</span> {% endif %} {% if content.field_date %} <time datetime="{{ content.field_date.0['#markup'] }}"> {{ content.field_date.0['#markup']|date('F j, Y') }} </time> {% endif %} </div> {% endif %}
{# actions slot #} {% if content.field_cta_link|render|trim %} <div slot="actions"> <hx-button variant="secondary" hx-size="md"> Read Full Story </hx-button> </div> {% endif %}
</hx-card>Attribute Passing Patterns
Section titled “Attribute Passing Patterns”String Attributes
Section titled “String Attributes”String attributes map directly to component reflected properties:
{# Static value #}<hx-button variant="secondary">Click me</hx-button>
{# Dynamic from Twig variable #}<hx-button variant="{{ button_variant }}">{{ button_label }}</hx-button>
{# From Drupal field #}<hx-button variant="{{ node.field_button_style.value }}"> {{ node.field_button_text.value }}</hx-button>
{# With default fallback #}<hx-button variant="{{ variant|default('primary') }}">Action</hx-button>
{# Inline conditional #}<hx-button variant="{% if is_featured %}primary{% else %}secondary{% endif %}"> Submit</hx-button>The hx- Prefix Convention
Section titled “The hx- Prefix Convention”HELiX uses hx- prefixed attribute names where native HTML already reserves the plain name:
{# hx-size — avoids conflict with the native `size` attribute on <input> #}<hx-button hx-size="lg" variant="primary">Large Button</hx-button><hx-badge hx-size="sm" variant="secondary">New</hx-badge>
{# hx-href — makes the whole card interactive; fires an hx-click event instead of performing default browser navigation. Pair with hx-label so the card surfaces an accessible name to assistive tech. #}<hx-card hx-href="/patient/{{ node.id }}" hx-label="Open patient {{ label }}" variant="default"> <span slot="heading">{{ label }}</span> Click anywhere on this card to fire hx-click.</hx-card>Always use hx-size rather than size. hx-card exposes its own hx-href attribute (not the native href) and activates via click or Enter — there is no Space-key activation on hx-card, only on form-control components like hx-button that map to a native focusable element. The card dispatches hx-click rather than navigating; if you want browser navigation, handle it in a Drupal behavior by reading event.detail.href:
once('mytheme:hx-card-nav', 'hx-card[hx-href]', context).forEach((card) => { card.addEventListener('hx-click', (e) => { window.location.href = e.detail.href; });});Boolean Attributes
Section titled “Boolean Attributes”Boolean attributes are controlled by presence (truthy) or absence (falsy). Never set them to the string "false":
{# Correct: conditional attribute presence. `disabled` is reflected on hx-button; `required` is a form-control concept — apply it to inputs (hx-text-input, hx-checkbox, hx-select, etc.), not to hx-button, which has no `required` in its public API. #}<hx-button variant="primary" {% if is_disabled %}disabled{% endif %}> Submit Form</hx-button>
<hx-text-input name="email" label="Email" {% if is_required %}required{% endif %}></hx-text-input>
{# Correct: ternary produces empty string when false #}<hx-button variant="primary" {{ user.is_guest ? 'disabled' : '' }}> Save Changes</hx-button>
{# WRONG: the presence of disabled="false" still disables the button #}<hx-button disabled="false">This is still disabled</hx-button>Drupal Attributes Object
Section titled “Drupal Attributes Object”HELiX components work with Drupal’s create_attribute() pattern:
{% set card_attributes = create_attribute() %}{% set card_attributes = card_attributes .addClass('patient-card') .setAttribute('data-entity-id', node.id) .setAttribute('data-entity-type', 'node')%}
<hx-card variant="featured" elevation="raised" {{ card_attributes }}> <span slot="heading">{{ label }}</span> {{ content.body }}</hx-card>Content Projection and Slots
Section titled “Content Projection and Slots”Slots are the HTML-native mechanism for passing content into a web component’s Shadow DOM. In Twig, content is assigned to a slot by adding a slot="name" attribute to the element you want to project.
Default Slot
Section titled “Default Slot”Content without a slot attribute goes to the unnamed default slot:
<hx-card variant="default"> {# Everything here goes to the default slot #} <p>This is the main card body content.</p> <p>Multiple elements can project to the default slot.</p></hx-card>Named Slots
Section titled “Named Slots”<hx-card variant="featured"> {# Named slot: image #} <img slot="image" src="/images/hero.jpg" alt="Hero image">
{# Named slot: heading #} <h2 slot="heading">Patient Name</h2>
{# Default slot: body #} <p>Medical history details...</p>
{# Named slot: footer #} <div slot="footer"> <time>Last visit: 2026-02-15</time> </div>
{# Named slot: actions #} <div slot="actions"> <hx-button variant="primary">View Record</hx-button> <hx-button variant="secondary">Print</hx-button> </div></hx-card>Conditional Slot Rendering
Section titled “Conditional Slot Rendering”Render a slot only when its content exists:
<hx-card> <span slot="heading">{{ title }}</span>
{{ body }}
{% if footer_content %} <div slot="footer">{{ footer_content }}</div> {% endif %}</hx-card>Complex Properties From Twig
Section titled “Complex Properties From Twig”HTML attributes are always strings. Components without an attribute-converter expect their object/array-typed properties to be set in JavaScript — for those, pass the data as a JSON-encoded data- attribute and hydrate via a Drupal Behavior.
Some components (notably hx-data-table) ship attribute converters that parse JSON directly from columns, rows, and similar attributes — for those you can render the JSON inline from Twig with e('html_attr')-style escaping, no behavior required:
{# templates/components/data-table.html.twig hx-data-table supports `columns` and `rows` JSON-string attributes plus a `label` for accessible naming. Render both inline; no Drupal Behavior needed. #}<hx-data-table id="patient-table-{{ node.id }}" label="{{ 'Patient records'|t }}" columns="{{ columns|json_encode|e('html_attr') }}" rows="{{ rows|json_encode|e('html_attr') }}"></hx-data-table>
{# If you need a no-JS fallback table, render it as a sibling so the upgraded component does not project the table into its default slot. #}<noscript> <table> <thead> <tr> {% for col in columns %} <th>{{ col.label }}</th> {% endfor %} </tr> </thead> <tbody> {% for row in rows %} <tr> {% for col in columns %} <td>{{ row[col.field] }}</td> {% endfor %} </tr> {% endfor %} </tbody> </table></noscript>For components that do not expose JSON attribute converters, fall back to the data-attribute + behavior hydration pattern:
(function (Drupal, once) { 'use strict';
Drupal.behaviors.hxDataTable = { attach(context) { once('helixui:data-table-init', 'hx-data-table[data-drupal-columns]', context).forEach( (table) => { customElements.whenDefined('hx-data-table').then(() => { const columnsJson = table.getAttribute('data-drupal-columns'); if (columnsJson) { try { table.columns = JSON.parse(columnsJson); } catch (e) { console.error('[HELiX] Failed to parse columns JSON', e); } } }); }, ); }, };})(Drupal, once);See the Behaviors documentation for the complete pattern.
Drupal-Specific Patterns
Section titled “Drupal-Specific Patterns”Node Templates
Section titled “Node Templates”{# templates/node/node--article--full.html.twig #}<article{{ attributes.addClass('article', 'article--full') }}> {{ title_prefix }} {{ title_suffix }}
<hx-card variant="featured" elevation="floating">
{% if content.field_featured_image|render|trim %} <div slot="image">{{ content.field_featured_image }}</div> {% endif %}
<h1 slot="heading"{{ title_attributes }}>{{ label }}</h1>
<div slot="footer" class="article__meta"> <time datetime="{{ node.created.value|date('c') }}"> {{ node.created.value|date('F j, Y') }} </time> {% if content.field_read_time|render|trim %} <span>{{ content.field_read_time }} min read</span> {% endif %} </div>
<div class="article__body">{{ content.body }}</div>
{% if content.field_cta_link|render|trim %} <div slot="actions"> <hx-button variant="primary" hx-size="lg"> {{ content.field_cta_link.0['#title'] }} </hx-button> </div> {% endif %}
</hx-card></article>Views Templates
Section titled “Views Templates”{# templates/views/views-view-unformatted--patient-list.html.twig Interactive-card pattern: hx-href makes the whole card open the record; pair it with hx-label for the accessible name, and **do not** nest action buttons inside the actions slot — hx-card flags that combination as an ARIA anti-pattern. Render extra action links as siblings if needed. #}<div{{ attributes.addClass('patient-list') }}> {% for row in rows %} {% set patient = row.content['#row']._entity %}
<hx-card variant="default" elevation="raised" hx-href="{{ path('entity.node.canonical', {'node': patient.id}) }}" hx-label="Open patient record for {{ patient.label }}" > {% if patient.field_photo.entity %} <img slot="image" src="{{ file_url(patient.field_photo.entity.uri.value) }}" alt="{{ patient.field_photo.alt }}" /> {% endif %}
<span slot="heading">{{ patient.label }}</span>
<div class="patient__details"> {% if patient.field_department.entity %} <hx-badge variant="secondary"> {{ patient.field_department.entity.name.value }} </hx-badge> {% endif %} </div>
{% if patient.field_last_visit.value %} <time slot="footer" datetime="{{ patient.field_last_visit.value|date('c') }}"> Last visit: {{ patient.field_last_visit.value|date('M j, Y') }} </time> {% endif %} </hx-card> {% endfor %}</div>Block Templates
Section titled “Block Templates”{# templates/block/block--alert-banner.html.twig #}<div{{ attributes.addClass('block', 'block-alert') }}> {{ title_prefix }} {% if label %} <h2{{ title_attributes }}>{{ label }}</h2> {% endif %} {{ title_suffix }}
{% block content %} {# hx-alert is hidden until `open` is set — include it on the block-rendered alert so the banner is visible after upgrade. #} <hx-alert open variant="{{ content.field_alert_type.0['#markup']|default('info') }}" {% if content.field_dismissible.0['#markup'] == '1' %}dismissible{% endif %} > {{ content.field_alert_message }} </hx-alert> {% endblock %}</div>Best Practices
Section titled “Best Practices”Always Provide Fallback Content
Section titled “Always Provide Fallback Content”{# Good: content is accessible before JS loads #}<hx-card variant="featured"> <span slot="heading">Patient Information</span> <p>John Doe — Age 45 — Cardiology</p></hx-card>
{# Bad: empty card until JS runs #}<hx-card variant="featured" data-patient-id="123"></hx-card>Check Field Existence Before Rendering
Section titled “Check Field Existence Before Rendering”{# Good: guard before projecting into a slot #}{% if content.field_featured_image|render|trim %} <div slot="image"> {{ content.field_featured_image }} </div>{% endif %}
{# Bad: always renders the slot wrapper even when empty #}<div slot="image"> {{ content.field_featured_image }}</div>Preserve Drupal’s Attribute System
Section titled “Preserve Drupal’s Attribute System”{# Good: Drupal attributes on the semantic wrapper #}<article{{ attributes.addClass('patient-node') }}> <hx-card variant="default"> {{ content }} </hx-card></article>
{# Bad: discards Drupal's contextual and accessibility attributes #}<article class="patient-node"> <hx-card variant="default"> {{ content }} </hx-card></article>Keep Logic in Twig, Not JavaScript
Section titled “Keep Logic in Twig, Not JavaScript”{# Good: derive variant from field value in Twig #}{% set variant = node.field_priority.value == 'urgent' ? 'primary' : 'secondary' %}<hx-button variant="{{ variant }}"> {{ node.field_priority.value == 'urgent' ? 'Urgent Action' : 'Standard Action' }}</hx-button>
{# Bad: defer simple string logic to a Drupal Behavior #}<hx-button id="action-btn" data-priority="{{ node.field_priority.value }}"> Action</hx-button>