Skip to content
HELiX

Twig Integration Fundamentals

apps/docs/src/content/docs/drupal/twig-templates/fundamentals Click to copy
Copied! 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.


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.

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>
ConcernRendered ByWhen
HTML structure (tags, attributes)Drupal / TwigServer-side, during page render
Slot contentDrupal / TwigServer-side
Shadow DOM layoutBrowser / HELiXClient-side, after script loads
Component stylesBrowser / HELiXClient-side, encapsulated in Shadow DOM
Event handlersBrowser / HELiXClient-side
Complex properties (objects, arrays)Drupal Behavior JSClient-side, via customElements.whenDefined()

{# 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>
{# 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>
{# 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>

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>

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

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>

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.

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

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>

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:

mytheme/js/behaviors/hx-data-table.js
(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.


{# 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>
{# 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>
{# 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>

{# 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>
{# 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>
{# 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>
{# 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>