Skip to content
HELiX

SDC Composition Patterns

apps/docs/src/content/docs/drupal/sdc/composition Click to copy
Copied! apps/docs/src/content/docs/drupal/sdc/composition

Composition SDCs map Drupal content fields to HELiX component slots and properties. This guide provides two complete examples — an article teaser and a healthcare staff profile — then documents the field mapping patterns and slot projection techniques used in each.


Before building, map each content type’s fields to component primitives:

Content Type FieldSDC Prop / SlotHELiX Component
node.labeltitle prophx-card heading slot
field_body (summary)summary prophx-card default slot
field_category (taxonomy ref)category + category_variant propshx-badge inside heading slot
field_image (media ref)image slothx-card image slot (the canonical slot name; media is not real)
node.uid.entity.displaynameauthor_name prophx-avatar in the default body slot
field_avatar (image)author_image_url prophx-avatar src attribute
node.created (timestamp)published_label prop<time> in the default body slot
node.toUrl()url prophx-button href attribute

components/article-teaser/
├── article-teaser.component.yml
├── article-teaser.twig
├── article-teaser.css
name: Article Teaser
description: News or editorial article rendered as a card with category badge, author avatar, body summary, and read-more CTA.
status: stable
props:
type: object
required:
- title
- url
properties:
title:
type: string
title: Article Title
description: The node label. Rendered as the card heading.
url:
type: string
title: Article URL
description: Canonical URL used for the read-more link.
summary:
type: string
title: Body Summary
description: Trimmed body text, plain text only.
category:
type: string
title: Category
description: Primary taxonomy term label.
category_variant:
type: string
title: Category Badge Variant
enum: [primary, secondary, success, warning, error, neutral, info]
default: primary
author_name:
type: string
title: Author Display Name
author_image_url:
type: string
title: Author Avatar URL
published_label:
type: string
title: Formatted Published Date
description: Human-readable date string (e.g., March 24, 2026).
card_variant:
type: string
title: Card Visual Variant
enum: [default, featured, compact]
default: default
slots:
image:
title: Card Image
description: Drupal-rendered image field output. Projected into hx-card's media slot.
libraryOverrides:
dependencies:
- mytheme/helix-card
- mytheme/helix-badge
- mytheme/helix-button
- mytheme/helix-avatar
{# components/article-teaser/article-teaser.twig #}
<hx-card variant="{{ card_variant|default('default') }}">
{#
* Drupal media image output — this is already rendered HTML from the
* image formatter. Projecting it into slot="image" inside hx-card.
* The |raw filter is safe here because this is Drupal-rendered content,
* not user-submitted text. Never use |raw on user-generated content.
#}
{% if image %}
<div slot="image">
{{- image|raw -}}
</div>
{% endif %}
{# Heading slot: badge + title #}
<div slot="heading">
{% if category %}
<hx-badge variant="{{ category_variant|default('primary') }}">
{{- category|escape -}}
</hx-badge>
{% endif %}
<span class="article-teaser__title">{{ title|escape }}</span>
</div>
{# Author/date metadata #}
{% if author_name %}
<div slot="footer" class="article-teaser__byline">
{% if author_image_url %}
<hx-avatar
src="{{ author_image_url|escape }}"
alt="{{ author_name|escape }}"
hx-size="sm"
></hx-avatar>
{% endif %}
<span>{{ author_name|escape }}</span>
{% if published_label %}
<span aria-hidden="true">·</span>
<time>{{ published_label|escape }}</time>
{% endif %}
</div>
{% endif %}
{# Body summary #}
{% if summary %}
<p class="article-teaser__summary">{{ summary|escape }}</p>
{% endif %}
{# CTA #}
<div slot="actions">
<hx-button href="{{ url|escape }}" variant="ghost">
Read more
<span class="visually-hidden"> about {{ title|escape }}</span>
</hx-button>
</div>
</hx-card>
/* Layout for elements within the SDC — not component internals */
.article-teaser__byline {
display: flex;
align-items: center;
gap: var(--hx-space-2);
font-size: var(--hx-font-size-sm);
color: var(--hx-color-neutral-600);
}
.article-teaser__title {
display: block;
font-size: var(--hx-font-size-lg);
font-weight: var(--hx-font-weight-semibold);
}
.article-teaser__summary {
color: var(--hx-color-neutral-700);
line-height: var(--hx-line-height-relaxed);
}
{# node--article--teaser.html.twig #}
{% include 'mytheme:article-teaser' with {
title: node.label,
url: url,
summary: content.body[0]['#text']|striptags|trim|slice(0, 200),
category: node.field_category.entity.label,
category_variant: 'primary',
author_name: node.uid.entity.displayname,
author_image_url: node.uid.entity.field_avatar.0.entity.fileuri|file_url,
published_label: node.created.value|format_date('medium'),
image: content.field_hero_image,
} only %}

A staff profile for a clinical directory page using hx-card, hx-badge, and hx-avatar.

components/staff-profile/
├── staff-profile.component.yml
├── staff-profile.twig
├── staff-profile.css
name: Staff Profile
description: Clinical staff member card for directory listings.
status: stable
props:
type: object
required:
- name
- role
properties:
name:
type: string
title: Full Name
role:
type: string
title: Clinical Role
description: e.g., "Attending Physician", "Nurse Practitioner"
department:
type: string
title: Department Name
specialty:
type: string
title: Primary Specialty
photo_url:
type: string
title: Staff Photo URL
phone:
type: string
title: Direct Phone
email:
type: string
title: Contact Email
profile_url:
type: string
title: Full Profile URL
accepting_patients:
type: boolean
title: Accepting New Patients
default: false
libraryOverrides:
dependencies:
- mytheme/helix-card
- mytheme/helix-badge
- mytheme/helix-button
- mytheme/helix-avatar
{# components/staff-profile/staff-profile.twig #}
<hx-card variant="default" class="staff-profile">
<div slot="image" class="staff-profile__photo">
{% if photo_url %}
<hx-avatar
src="{{ photo_url|escape }}"
alt="{{ name|escape }}"
hx-size="xl"
></hx-avatar>
{% else %}
<hx-avatar
label="{{ name|escape }}"
initials="{{ name|split(' ')|map(w => w[0])|join('')|upper }}"
hx-size="xl"
></hx-avatar>
{% endif %}
</div>
<div slot="heading">
<span class="staff-profile__name">{{ name|escape }}</span>
<span class="staff-profile__role">{{ role|escape }}</span>
</div>
<div slot="footer">
{% if department %}
<hx-badge variant="neutral">{{ department|escape }}</hx-badge>
{% endif %}
{% if accepting_patients %}
<hx-badge variant="success">Accepting New Patients</hx-badge>
{% else %}
<hx-badge variant="warning">Not Accepting New Patients</hx-badge>
{% endif %}
</div>
{% if specialty %}
<p class="staff-profile__specialty">
<strong>Specialty:</strong> {{ specialty|escape }}
</p>
{% endif %}
<div slot="actions">
{% if profile_url %}
<hx-button href="{{ profile_url|escape }}" variant="primary" hx-size="sm">
View Profile
</hx-button>
{% endif %}
{% if phone %}
<hx-button href="tel:{{ phone|replace({' ': '', '-': '', '(': '', ')': ''})|escape }}" variant="ghost" hx-size="sm">
{{ phone|escape }}
</hx-button>
{% endif %}
</div>
</hx-card>
.staff-profile__photo {
display: flex;
justify-content: center;
padding: var(--hx-space-4) var(--hx-space-4) 0;
}
.staff-profile__name {
display: block;
font-size: var(--hx-font-size-lg);
font-weight: var(--hx-font-weight-semibold);
}
.staff-profile__role {
display: block;
font-size: var(--hx-font-size-sm);
color: var(--hx-color-neutral-600);
}
.staff-profile__specialty {
font-size: var(--hx-font-size-sm);
margin: 0;
}
{# node--staff--teaser.html.twig #}
{% include 'mytheme:staff-profile' with {
name: node.label,
role: node.field_clinical_role.value,
department: node.field_department.entity.label,
specialty: node.field_primary_specialty.value,
photo_url: node.field_photo.0.entity.fileuri|file_url,
phone: node.field_direct_phone.value,
email: node.field_email.value,
profile_url: url,
accepting_patients: node.field_accepting_patients.value,
} only %}

Rendering Drupal media images in slot=“image”

Section titled “Rendering Drupal media images in slot=“image””

Drupal renders image fields through formatters. The rendered output — including <picture>, srcset, and image styles — should be passed as a slot, not converted to a URL string.

Pass the rendered content array, not a URL:

{# Correct: pass content.field_image (rendered render array) #}
{% include 'mytheme:article-teaser' with {
image: content.field_image,
} only %}
{# In the SDC template — project rendered image into the image slot #}
{% if image %}
<div slot="image">
{{- image|raw -}}
</div>
{% endif %}

Using |raw here is safe because content.field_image is output from Drupal’s rendering pipeline, not user-submitted text. Drupal applies twig_escape_filter to untrusted strings before they reach the render system.

{# striptags removes HTML from the body field, trim removes whitespace #}
{{ content.body[0]['#text']|striptags|trim|slice(0, 200) }}
{# For avatars without photos — generates "JD" from "Jane Doe" #}
{{ name|split(' ')|map(w => w[0])|join('')|upper }}

Map taxonomy term labels to badge variants using a Twig hash:

{% set variant_map = {
'News': 'primary',
'Events': 'success',
'Research': 'warning',
'Policy': 'danger',
} %}
<hx-badge variant="{{ variant_map[category]|default('default') }}">
{{ category|escape }}
</hx-badge>

SDCs can include other SDCs. An article-grid SDC can include multiple article-teaser SDCs:

{# components/article-grid/article-grid.twig #}
<div class="article-grid">
{% for item in items %}
{% include 'mytheme:article-teaser' with {
title: item.title,
url: item.url,
summary: item.summary,
category: item.category,
image: item.image,
} only %}
{% endfor %}
</div>

SDCs can include other SDCs. An article-grid SDC can include multiple article-teaser SDCs:

{# components/article-grid/article-grid.twig #}
<div class="article-grid">
{% for item in items %}
{% include 'mytheme:article-teaser' with {
title: item.title,
url: item.url,
summary: item.summary,
category: item.category,
image: item.image,
} only %}
{% endfor %}
</div>

For complex prop derivation, use a hook_preprocess_HOOK() function rather than embedding Twig logic in the template.

mytheme.theme
<?php
/**
* Implements hook_preprocess_node() for article teaser display.
*/
function mytheme_preprocess_node__article__teaser(array &$variables): void {
$node = $variables['node'];
// Derive category variant from taxonomy field value.
$category_term = $node->get('field_category')->entity;
$category_label = $category_term ? $category_term->label() : '';
$category_variant = match ($category_label) {
'Clinical Research' => 'primary',
'Patient Safety' => 'error',
'Wellness' => 'success',
default => 'default',
};
// Pass derived values to the SDC via the variables array.
$variables['category_label'] = $category_label;
$variables['category_variant'] = $category_variant;
// Pass the author entity for the avatar.
$author = $node->get('field_author')->entity;
$variables['author_name'] = $author?->label() ?? '';
$variables['author_image_url'] = '';
if ($author && !$author->get('field_profile_image')->isEmpty()) {
$image_entity = $author->get('field_profile_image')->entity;
if ($image_entity) {
$variables['author_image_url'] = \Drupal::service('file_url_generator')
->generateAbsoluteString($image_entity->getFileUri());
}
}
}

The following table lists composition-SDC patterns commonly authored alongside HELiX, grouped by content category. Each pattern composes multiple HELiX primitives into a complete editorial shape. These are illustrative pattern names, not a one-for-one inventory of what @helixui/drupal-starter scaffolds — the starter ships a smaller curated set of SDCs; consumers typically author the remaining patterns from scratch using these primitives as the building blocks. See the starter’s own components/ directory for the exact list of shipped scaffolds.

SDC NameHELiX ComponentsDescription
article-teaserhx-card, hx-badge, hx-avatar, hx-text, hx-buttonNews/blog teaser with author attribution
article-fullhx-prose, hx-avatar, hx-badge, hx-divider, hx-textFull article layout with author bio
featured-articlehx-card, hx-badge, hx-text, hx-buttonLarge-format featured story card
related-articleshx-card, hx-badge, hx-text, hx-grid2-3 column related content grid
content-listinghx-card, hx-badge, hx-pagination, hx-stackPaginated content archive listing
SDC NameHELiX ComponentsDescription
staff-profilehx-card, hx-avatar, hx-badge, hx-text, hx-buttonProvider/staff profile card
staff-directoryhx-card, hx-avatar, hx-grid, hx-text-input, hx-selectSearchable provider directory
department-cardhx-card, hx-icon, hx-text, hx-buttonClinical department overview card
service-line-herohx-banner, hx-text, hx-button-groupService line hero banner
appointment-ctahx-card, hx-button, hx-icon, hx-textAppointment scheduling CTA block
clinical-alerthx-alert, hx-icon, hx-text, hx-buttonClinical advisory/safety alert
patient-resourcehx-card, hx-icon, hx-text, hx-badge, hx-buttonPatient education resource card
location-cardhx-card, hx-icon, hx-text, hx-badge, hx-buttonFacility/clinic location card
insurance-listhx-structured-list, hx-text, hx-badgeAccepted insurance plan list
condition-teaserhx-card, hx-icon, hx-text, hx-badgeMedical condition/treatment card
SDC NameHELiX ComponentsDescription
site-headerhx-top-nav, hx-button, hx-icon-button, hx-dropdownGlobal site header with primary nav
site-footerhx-text, hx-link, hx-divider, hx-stackGlobal site footer
breadcrumb-navhx-breadcrumb, hx-breadcrumb-itemDrupal path breadcrumb
section-navhx-side-nav, hx-textIn-page section navigation
pagination-navhx-pagination, hx-textViews/listing pagination
SDC NameHELiX ComponentsDescription
hero-bannerhx-banner, hx-text, hx-button-groupFull-width page hero
stat-blockhx-stat, hx-text, hx-gridKey metrics/statistics display
cta-blockhx-card, hx-text, hx-button, hx-iconSingle call-to-action block
cta-bandhx-banner, hx-text, hx-button-groupFull-width CTA banner
feature-gridhx-grid, hx-card, hx-icon, hx-textFeature highlights grid
testimonial-cardhx-card, hx-avatar, hx-text, hx-ratingPatient/client testimonial
event-cardhx-card, hx-badge, hx-icon, hx-text, hx-buttonEvent listing card
event-listinghx-card, hx-badge, hx-icon, hx-stack, hx-paginationEvent archive listing
SDC NameHELiX ComponentsDescription
search-barhx-text-input, hx-button, hx-iconGlobal/section search form
contact-formhx-form, hx-text-input, hx-textarea, hx-select, hx-buttonBasic contact form layout
appointment-formhx-form, hx-text-input, hx-date-picker, hx-select, hx-buttonAppointment request form
newsletter-signuphx-text-input, hx-button, hx-textEmail list signup inline form
filter-barhx-select, hx-checkbox-group, hx-button, hx-tagViews exposed filter bar
SDC NameHELiX ComponentsDescription
status-messagehx-alert, hx-icon, hx-textDrupal status/error/warning message
empty-statehx-icon, hx-text, hx-buttonEmpty Views/listing state
loading-skeletonhx-skeleton, hx-stackAJAX loading placeholder

Layout Builder and Experience Builder Integration

Section titled “Layout Builder and Experience Builder Integration”

SDCs work as Layout Builder custom blocks. Create a block type that maps to your SDC, then expose it in Layout Builder sections.

{# block--helix-article-teaser.html.twig #}
{% if content.field_article_reference[0] is defined %}
{% set node = content.field_article_reference[0]['#node'] %}
{% include 'mytheme:article-teaser' with {
title: node.label,
url: url('entity.node.canonical', {node: node.id()}),
summary: node.body.summary ?: node.body.value|striptags|slice(0, 200),
image: content.field_article_reference[0].field_hero_image,
} only %}
{% endif %}

Drupal’s Experience Builder (experimental, Drupal 10.3+) exposes SDCs as drag-and-drop XB components when they include a complete props schema with $ui metadata for the editing interface.

Add $ui hints to your schema to control how XB presents each prop in the visual editor:

components/article-teaser/article-teaser.component.yml
name: Article Teaser
props:
type: object
properties:
title:
type: string
title: Article Title
'$ui':
widget: text
category_variant:
type: string
enum: [primary, secondary, success, warning, error, neutral, info]
title: Category Color
'$ui':
widget: select
label: Category badge color
url:
type: string
title: Link URL
'$ui':
widget: url

Experience Builder SDC integration is available from Drupal 10.3. The $ui metadata schema is finalized in the XB initiative but may evolve before stable release. Review the Drupal Experience Builder documentation for the current specification.


HELiX components render their slotted content in the light DOM before JavaScript loads. This means the SDC output is accessible and indexable even when component JavaScript has not yet executed.

Structure your SDC templates so the slot content is semantically complete without component enhancement:

{# article-teaser.twig — semantic fallback is the slot content itself #}
<hx-card hx-href="{{ url }}">
{# Before JS: renders as <div> with its content visible in light DOM #}
{# After JS: card Shadow DOM applies design, layout, and interaction #}
<h3 slot="heading">{{ title }}</h3>
<hx-text>{{ summary }}</hx-text>
<div slot="footer">
<a href="{{ url }}">{{ 'Read More'|t }}</a>
</div>
</hx-card>

The <a href> in the footer slot ensures keyboard navigation and screen-reader access to the link before the HELiX runtime upgrades the card. After upgrade, hx-card continues to host that native <a> in its footer slot — the slotted content stays a real anchor, which is the correct progressive-enhancement story. (hx-button is not part of this sample; if you want a button-shaped CTA after upgrade, slot <hx-button hx-size="sm" variant="ghost" href="...">Read More</hx-button> instead of <a>.)


If your Drupal site uses htmx, you will encounter a namespace collision: htmx attributes use the hx- prefix (e.g., hx-boost, hx-get, hx-target), the same prefix HELiX uses for its custom element tag names (hx-card, hx-button).

This is not a functional conflict — htmx’s hx-* attribute scanner reads attributes on elements, not element tag names, so it never tries to “process” <hx-card> as an htmx-boosted element. The collision is purely cosmetic: code reviewers eyeballing a Twig file have to distinguish hx-boost (htmx attribute) from hx-card (HELiX tag) and hx-href (HELiX attribute). The mitigations below reduce the cognitive load.

Mitigation 1: Scope htmx to specific elements

Section titled “Mitigation 1: Scope htmx to specific elements”

Do not apply htmx globally. Instead of adding hx-boost to <body>, scope it to specific regions:

{# Scope htmx to a specific container, not the whole page #}
<div class="ajax-region" hx-boost="true">
{# htmx-enhanced links and forms go here #}
</div>
{# HELiX components outside the scoped container are unaffected #}
<hx-card hx-href="/article/1">...</hx-card>

Mitigation 2: Use the htmx data-* attribute syntax

Section titled “Mitigation 2: Use the htmx data-* attribute syntax”

htmx accepts both hx-* and data-hx-* forms for every directive (e.g. data-hx-boost, data-hx-get, data-hx-target). Using the data-hx-* form throughout your htmx markup makes the cognitive distinction obvious — anything starting with hx- is HELiX, anything starting with data-hx- is htmx. There is no global htmx.config prefix-rename knob; this is a coding convention, not a runtime configuration switch.

Mitigation 3: Use htmx hx-disable exclusions

Section titled “Mitigation 3: Use htmx hx-disable exclusions”

If a region must be invisible to htmx altogether, mark its wrapper with hx-disable (or the data-hx-disable equivalent). htmx will not scan its descendants:

<div hx-disable>
<hx-card>...</hx-card>
<hx-button>...</hx-button>
</div>

Mitigation 2 (changing htmx’s attribute prefix to data-hx-*) is the cleanest long-term solution for sites that use both htmx and HELiX. Document this configuration choice in your theme’s README so future maintainers understand why the prefix is non-standard.


SDC is not discovered after adding component files

Section titled “SDC is not discovered after adding component files”

Run drush cr after adding any new SDC directory. Drupal discovers components only on cache rebuild, not on page load.

Component renders as unknown HTML element (no styles)

Section titled “Component renders as unknown HTML element (no styles)”

The component library JavaScript has not loaded. Check:

  1. The attach_library() call is present in the SDC Twig template, or the SDC’s libraryOverrides.dependencies lists the component library.
  2. The library definition exists in mytheme.libraries.yml.
  3. The CDN URL or local file path is correct.
  4. Browser DevTools Network tab shows the script loaded without 404.

Drupal Behaviors do not fire on AJAX-loaded content

Section titled “Drupal Behaviors do not fire on AJAX-loaded content”

Drupal Behaviors with once() fire automatically on AJAX responses. If your behavior is not running, verify:

  1. The behavior key in Drupal.behaviors is unique across your theme.
  2. The CSS selector in the once() call matches the actual rendered element.
  3. The [data-drupal-*] (per /drupal-behaviors) attribute is present on the rendered element in the page source.

Apply one of the three mitigations documented in the htmx section above. The fastest fix is adding hx-disable to the wrapper <div> around HELiX component regions.

Props schema validation error in Drupal logs

Section titled “Props schema validation error in Drupal logs”

The value passed to a prop does not match the declared JSON Schema type or enum. Common causes:

  • Passing an integer where a string is declared (use title|string in Twig)
  • Passing a value not in the enum list (validate in hook_preprocess_HOOK() before passing)
  • Passing null for a required prop (guard with {% if value %} before the include)