Skip to content
HELiX

Twig Attributes Object

apps/docs/src/content/docs/drupal/twig-templates/attributes Click to copy
Copied! apps/docs/src/content/docs/drupal/twig-templates/attributes

Drupal’s attributes object is a Twig-level abstraction that manages HTML attributes for template elements. When you write {{ attributes }} in a template, Drupal prints a series of HTML attributes that it has assembled from context: CSS classes for the content type and view mode, data attributes for contextual links, ARIA attributes, and anything added by modules.

Discarding these attributes by writing a plain class="my-class" instead of {{ attributes.addClass('my-class') }} silently breaks contextual editing, module hooks, and accessibility features. This document covers how to work with Drupal’s attributes system when the element receiving those attributes is a HELiX web component.


{# Without attributes object — loses Drupal's contextual classes #}
<article class="patient-node">
<hx-card variant="featured">{{ content }}</hx-card>
</article>
{# With attributes object — preserves all Drupal functionality #}
<article{{ attributes.addClass('patient-node') }}>
<hx-card variant="featured">{{ content }}</hx-card>
</article>

The second form preserves:

  • .contextual-region — contextual editing overlays
  • RDF attributes for semantic web / schema.org, if the contributed RDF module is installed (RDF was removed from Drupal core in 10.x — these attributes only exist when the contrib module is enabled)
  • data-history-node-id — page history tracking
  • Module-added attributes from hooks
  • CSS hooks for theming and Layout Builder
  • ARIA attributes for accessibility

Attributes Objects Available by Template Type

Section titled “Attributes Objects Available by Template Type”
TemplateAvailable Objects
node.html.twigattributes, title_attributes, content_attributes
field.html.twigattributes, title_attributes, item.attributes
block.html.twigattributes, title_attributes
paragraph.html.twigattributes, content_attributes
views-view.html.twigattributes, title_attributes (rows is a render variable, not a separate attributes object)
html.html.twightml_attributes, attributes (the latter applies to <body>)
page.html.twigattributes (the page wrapper; the <html> / <body> attributes live in html.html.twig)

create_attribute() — Building New Attribute Objects

Section titled “create_attribute() — Building New Attribute Objects”

Use create_attribute() when you need a fresh attributes object for an inner element (like an hx-card) that is separate from the Drupal-provided attributes object.

{# Create a new, empty attributes object #}
{% set card_attrs = create_attribute() %}
{# Chain methods to build it up #}
{% set card_attrs = card_attrs
.addClass('patient-card')
.addClass('patient-card--featured')
.setAttribute('data-entity-id', node.id)
.setAttribute('data-entity-type', 'node')
%}
{# Apply to the HELiX component #}
<hx-card
variant="featured"
elevation="raised"
{{ card_attrs }}
>
<span slot="heading">{{ label }}</span>
{{ content.body }}
</hx-card>

Rendered HTML:

<hx-card
variant="featured"
elevation="raised"
class="patient-card patient-card--featured"
data-entity-id="123"
data-entity-type="node"
>
<span slot="heading">Patient Name</span>
...
</hx-card>

{# Single class #}
<hx-card {{ attributes.addClass('patient-card') }}>
{{ content }}
</hx-card>
{# Multiple classes in one call #}
<hx-card {{ attributes.addClass('patient-card', 'patient-card--featured') }}>
{{ content }}
</hx-card>
{# From a Twig array #}
{% set card_classes = ['patient-card', 'patient-card--elevated'] %}
<hx-card {{ attributes.addClass(card_classes) }}>
{{ content }}
</hx-card>
{# Add class based on field value #}
<hx-card {{
attributes.addClass(
'patient-card',
node.field_featured.value ? 'patient-card--featured' : 'patient-card--standard'
)
}}>
<span slot="heading">{{ label }}</span>
{{ content.body }}
</hx-card>
{# Build an array of classes conditionally #}
{% set card_classes = ['patient-card'] %}
{% if node.field_featured.value %}
{% set card_classes = card_classes|merge(['patient-card--featured']) %}
{% endif %}
{% if node.field_urgent.value %}
{% set card_classes = card_classes|merge(['patient-card--urgent']) %}
{% endif %}
<hx-card {{ attributes.addClass(card_classes) }}>
{{ content }}
</hx-card>
{# Append view mode as a BEM modifier #}
{% set view_mode_class = 'patient-card--' ~ view_mode|clean_class %}
<hx-card {{ attributes.addClass('patient-card', view_mode_class) }}>
<span slot="heading">{{ label }}</span>
{{ content }}
</hx-card>

The |clean_class filter converts underscores to hyphens and lowercases the string, so search_result becomes search-result.


Data attributes bridge Twig-rendered content to Drupal Behaviors:

{# Add entity identifiers for JavaScript #}
<hx-card {{
attributes
.setAttribute('data-entity-id', node.id)
.setAttribute('data-entity-type', 'node')
.setAttribute('data-entity-bundle', node.bundle)
.setAttribute('data-view-mode', view_mode)
}}>
{{ content }}
</hx-card>

Access in a Drupal Behavior:

(function (Drupal, once) {
'use strict';
Drupal.behaviors.hxPatientCard = {
attach(context) {
once('helixui:patient-card', 'hx-card[data-entity-bundle="patient"]', context).forEach(
(card) => {
const entityId = card.getAttribute('data-entity-id');
const viewMode = card.getAttribute('data-view-mode');
// use entityId and viewMode for further initialization
},
);
},
};
})(Drupal, once);

Set accessibility attributes that depend on rendered node data:

{# Accessible label incorporating the node title.
hx-card uses `aria-label` when the card is a non-interactive region; it
uses its own `hx-label` attribute when the card is interactive
(paired with `hx-href`). There is no `accessible-label` attribute. #}
<hx-card {{
attributes
.setAttribute('aria-label', 'Patient record for ' ~ label)
.setAttribute('role', 'article')
}}>
<span slot="heading">{{ label }}</span>
{{ content }}
</hx-card>
{# Live region for alerts — let hx-alert manage role/aria-live itself.
The component sets role="alert" + aria-live based on `variant`; opening
it via `open` is what makes it announce. Do not redeclare those ARIA
attributes on the host. #}
<hx-alert open variant="info" {{ attributes }}>
{{ content.field_alert_message }}
</hx-alert>

JSON Data Attributes for Complex Initialization

Section titled “JSON Data Attributes for Complex Initialization”

When a Drupal Behavior needs structured data, pass it as a JSON-encoded data- attribute:

{% set patient_data = {
id: node.id,
name: label,
department: node.field_department.entity.name.value,
last_visit: node.field_last_visit.value|date('Y-m-d')
} %}
<hx-card {{
attributes
.addClass('patient-card')
.setAttribute('data-patient', patient_data|json_encode)
}}>
<span slot="heading">{{ label }}</span>
{{ content }}
</hx-card>

Parse in the Behavior:

once('helixui:patient-data', 'hx-card[data-patient]', context).forEach((card) => {
const data = JSON.parse(card.getAttribute('data-patient'));
// initialize with data.id, data.name, etc.
});

Drupal may add an id attribute to nodes. When the same node appears multiple times on a page (e.g., in a View), duplicate IDs break HTML validity:

{# Remove the Drupal-generated ID from the component #}
{# Keep it on the semantic wrapper instead #}
<article{{ attributes }}>
<hx-card {{
create_attribute()
.addClass('patient-card')
.setAttribute('data-entity-id', node.id)
}}>
<span slot="heading">{{ label }}</span>
{{ content.body }}
</hx-card>
</article>

Or remove it explicitly:

<hx-card {{ attributes.removeAttribute('id').addClass('patient-card') }}>
{{ content }}
</hx-card>

Removing Drupal Classes Before Adding Custom Ones

Section titled “Removing Drupal Classes Before Adding Custom Ones”
{# Remove generic node classes and replace with BEM classes #}
<hx-card {{
attributes
.removeClass('node', 'node--type-patient', 'node--view-mode-full')
.addClass('patient-record', 'patient-record--expanded')
}}>
{{ content }}
</hx-card>

attributes.clone() — Modifying Without Mutating

Section titled “attributes.clone() — Modifying Without Mutating”

attributes is a mutable object. If you call .addClass() directly on it, you modify the original. Drupal’s Attribute class doesn’t expose a public clone() method on the Twig side — copy via a preprocess hook or by reconstructing from toArray():

{# Reconstruct from toArray() to keep the original attributes untouched. #}
{% set component_attrs = create_attribute(attributes.toArray()).addClass('patient-card') %}
<article{{ attributes }}>
{{ title_prefix }}
{{ title_suffix }}
<hx-card {{ component_attrs }}>
<span slot="heading">{{ label }}</span>
{{ content.body }}
</hx-card>
</article>

Or clone in PHP preprocess and pass the copy through as a separate variable:

function mytheme_preprocess_node(array &$variables): void {
$variables['card_attributes'] = clone $variables['attributes'];
}
<article{{ attributes }}>
<hx-card {{ card_attributes.addClass('patient-card') }}>
<span slot="heading">{{ label }}</span>
{{ content.body }}
</hx-card>
</article>
{# Build base attributes #}
{% set base_attrs = create_attribute()
.addClass('patient-card')
.setAttribute('data-entity-id', node.id)
%}
{# Build conditional attributes separately #}
{% set state_attrs = create_attribute() %}
{% if node.field_featured.value %}
{% set state_attrs = state_attrs.addClass('patient-card--featured') %}
{% endif %}
{% if node.field_urgent.value %}
{% set state_attrs = state_attrs
.addClass('patient-card--urgent')
.setAttribute('aria-label', 'Urgent patient: ' ~ label)
%}
{% endif %}
{# Merge and apply #}
<hx-card {{ base_attrs.merge(state_attrs) }}>
<span slot="heading">{{ label }}</span>
{{ content }}
</hx-card>

Keep the semantic HTML element for its structural meaning and accessibility. Drupal attributes go on the wrapper; component attributes are built separately.

{# Drupal attributes on <article>; HELiX attributes on <hx-card> #}
<article{{ attributes.addClass('patient-node') }}>
{{ title_prefix }}
{{ title_suffix }}
<hx-card
variant="featured"
elevation="raised"
{{ create_attribute()
.addClass('patient-card')
.setAttribute('data-entity-id', node.id)
}}
>
<h1 slot="heading"{{ title_attributes }}>{{ label }}</h1>
<div{{ content_attributes }}>{{ content.body }}</div>
</hx-card>
</article>

Option B: Direct Spreading onto the Component

Section titled “Option B: Direct Spreading onto the Component”

When a semantic wrapper adds no value, spread Drupal attributes directly onto the component:

{# All Drupal attributes land on hx-card #}
<hx-card
variant="default"
elevation="raised"
{{ attributes.addClass('patient-card') }}
>
<span slot="heading">{{ label }}</span>
{{ content.body }}
</hx-card>

This is simpler but loses the semantic <article> element. Use it when the component itself carries sufficient semantic meaning for the context.


{# templates/views/views-view-unformatted--patient-list.html.twig #}
<div{{ attributes.addClass('patient-list') }}>
{% for row in rows %}
{% set patient = row.content['#row']._entity %}
{# Build per-card attributes #}
{% set card_attrs = create_attribute()
.addClass('patient-list__item')
.setAttribute('data-patient-id', patient.field_patient_id.value)
.setAttribute('data-entity-id', patient.id)
%}
{% if patient.field_status.value == 'active' %}
{% set card_attrs = card_attrs.addClass('patient-list__item--active') %}
{% endif %}
{% if patient.field_urgent.value %}
{% set card_attrs = card_attrs
.addClass('patient-list__item--urgent')
.setAttribute('aria-label', 'Urgent patient: ' ~ patient.label)
%}
{% endif %}
{# Interactive card: pair `hx-href` with `hx-label` so the whole card
is the navigation. Don't combine the `actions` slot with the
hx-href interactive pattern — that creates an ARIA anti-pattern
(action buttons nested inside a card-sized link). Render the
department badge and date as informational metadata instead. #}
<hx-card
variant="default"
elevation="raised"
hx-href="{{ path('entity.node.canonical', {'node': patient.id}) }}"
hx-label="Open patient record for {{ patient.label }}"
{{ card_attrs }}
>
{% if patient.field_photo.entity %}
<img
slot="image"
src="{{ file_url(patient.field_photo.entity.uri.value) }}"
alt="{{ patient.field_photo.alt }}"
/>
{% endif %}
<div slot="heading">
<span>{{ patient.label }}</span>
{% if patient.field_patient_id.value %}
<small>ID: {{ patient.field_patient_id.value }}</small>
{% endif %}
</div>
<div class="patient-list__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>

{# Good: clone preserves the original for the wrapper #}
{% set card_attrs = attributes.clone().addClass('patient-card') %}
<article{{ attributes }}>
<hx-card {{ card_attrs }}>{{ content }}</hx-card>
</article>
{# Bad: attributes is mutated; wrapper and component share modified state #}
<article{{ attributes }}>
<hx-card {{ attributes.addClass('patient-card') }}>{{ content }}</hx-card>
</article>
{# Good: explicit new attributes object for the component #}
{% set card_attrs = create_attribute()
.addClass('patient-card')
.setAttribute('data-id', node.id)
%}
<hx-card {{ card_attrs }}>{{ content }}</hx-card>
{# Avoid: raw HTML attribute strings break Drupal's attribute merging #}
<hx-card class="patient-card" data-id="{{ node.id }}">{{ content }}</hx-card>
{# Good: chained calls on a single variable #}
{% set attrs = create_attribute()
.addClass('patient-card', 'patient-card--' ~ view_mode|clean_class)
.setAttribute('data-entity-id', node.id)
.setAttribute('role', 'article')
.setAttribute('aria-labelledby', 'heading-' ~ node.id)
%}
{#
* Build card attributes based on:
* - View mode (full, teaser, compact) → class modifier
* - Patient status (active, inactive) → class modifier + ARIA
* - Priority level → class modifier
#}
{% set card_classes = [
'patient-card',
'patient-card--' ~ view_mode|clean_class,
] %}