Skip to content
HELiX

Component Composition Patterns

apps/docs/src/content/docs/guides/composition-patterns Click to copy
Copied! apps/docs/src/content/docs/guides/composition-patterns

HELiX components are designed to be composed together. Individual components handle one concern well; composition patterns handle real-world UI requirements. This guide covers the most common patterns with HTML examples, component interaction notes, accessibility requirements, and Drupal Twig equivalents using Single Directory Components (SDC).

Each pattern shows the minimum viable composition first, then layers in additional components for richer experiences.


Forms are the most common composition surface in healthcare applications — patient intake, scheduling, clinical data entry, and administrative workflows all rely on well-structured form patterns.

hx-form wraps hx-field containers, which pair a label with a control. hx-field manages the label-to-control association, help text, and validation message layout automatically.

<hx-form id="patient-intake" novalidate>
<hx-field>
<label slot="label" for="first-name">First name</label>
<hx-text-input
id="first-name"
name="firstName"
required
autocomplete="given-name"
></hx-text-input>
</hx-field>
<hx-field>
<label slot="label" for="last-name">Last name</label>
<hx-text-input
id="last-name"
name="lastName"
required
autocomplete="family-name"
></hx-text-input>
</hx-field>
<hx-field>
<label slot="label" for="dob">Date of birth</label>
<hx-text-input
id="dob"
name="dateOfBirth"
type="date"
required
autocomplete="bday"
></hx-text-input>
</hx-field>
<hx-button type="submit" variant="primary">Continue</hx-button>
</hx-form>

Full form with select, checkbox, and validation

Section titled “Full form with select, checkbox, and validation”
<hx-form id="appointment-request" novalidate>
<hx-field>
<label slot="label" for="provider">Preferred provider</label>
<hx-select id="provider" name="providerId" required>
<option value="">Select a provider</option>
<option value="p-001">Dr. Amara Osei, MD</option>
<option value="p-002">Dr. Kenji Watanabe, MD</option>
<option value="p-003">NP Sarah Lindqvist, APRN</option>
</hx-select>
<span slot="help-text">Choose from providers accepting new patients.</span>
</hx-field>
<hx-field>
<label slot="label" for="visit-type">Visit type</label>
<hx-select id="visit-type" name="visitType" required>
<option value="">Select visit type</option>
<option value="new">New patient</option>
<option value="followup">Follow-up</option>
<option value="urgent">Urgent care</option>
</hx-select>
</hx-field>
<hx-field>
<label slot="label" for="reason">Reason for visit</label>
<hx-textarea id="reason" name="visitReason" rows="4" maxlength="500"></hx-textarea>
<span slot="help-text"
>Briefly describe your symptoms or reason for visit (500 characters max).</span
>
</hx-field>
<hx-checkbox name="telehealth" value="yes">
I am open to a telehealth appointment if in-person is unavailable.
</hx-checkbox>
<hx-field>
<hx-checkbox-group name="contactMethod" label="Preferred contact method" required>
<hx-checkbox value="phone">Phone</hx-checkbox>
<hx-checkbox value="email">Email</hx-checkbox>
<hx-checkbox value="portal">Patient portal message</hx-checkbox>
</hx-checkbox-group>
</hx-field>
<div class="form-actions">
<hx-button type="submit" variant="primary">Request appointment</hx-button>
<hx-button type="reset" variant="ghost">Clear form</hx-button>
</div>
</hx-form>

Component interaction: hx-form observes hx-field children and coordinates validation timing. Each hx-field passes validation state to its slotted control. hx-text-input, hx-select, and hx-checkbox are all form-associated custom elements — their values submit with the native <form> and participate in browser constraint validation.

Accessibility notes:

  • hx-field automatically associates the label[slot="label"] with the slotted control using for/id or aria-labelledby
  • Help text in slot="help-text" is wired to the control via aria-describedby
  • Validation errors announced to screen readers via aria-errormessage on the control
  • Do not nest hx-field inside hx-field; use hx-checkbox-group with a legend for grouped controls

Drupal Twig (SDC):

{# components/appointment-form/appointment-form.html.twig #}
{{ attach_library('mytheme/helix-form') }}
{{ attach_library('mytheme/helix-field') }}
{{ attach_library('mytheme/helix-select') }}
{{ attach_library('mytheme/helix-checkbox') }}
{{ attach_library('mytheme/helix-button') }}
<hx-form id="appointment-request" novalidate>
<hx-field>
<label slot="label" for="provider">Preferred provider</label>
<hx-select id="provider" name="providerId" required>
<option value="">Select a provider</option>
{% for provider in providers %}
<option value="{{ provider.id }}">{{ provider.name }}</option>
{% endfor %}
</hx-select>
<span slot="help-text">Choose from providers accepting new patients.</span>
</hx-field>
<hx-field>
<label slot="label" for="visit-type">Visit type</label>
<hx-select id="visit-type" name="visitType" required>
<option value="">Select visit type</option>
{% for type in visit_types %}
<option value="{{ type.value }}">{{ type.label }}</option>
{% endfor %}
</hx-select>
</hx-field>
<div class="form-actions">
<hx-button type="submit" variant="primary">{{ submit_label|default('Request appointment') }}</hx-button>
<hx-button type="reset" variant="ghost">Clear form</hx-button>
</div>
</hx-form>

Card compositions are used for article teasers, patient summaries, provider profiles, and content grids. hx-card provides the structural shell; hx-badge, hx-avatar, and hx-button populate specific slots.

<hx-card variant="featured" elevation="raised">
<hx-image
slot="image"
src="/images/cardiology-news.jpg"
alt="Cardiology research team reviewing data"
ratio="16/9"
></hx-image>
<div slot="heading">
<hx-badge variant="info">Cardiology</hx-badge>
<h2>
<a href="/articles/heart-failure-study">New Study Links Sleep Apnea to Heart Failure Risk</a>
</h2>
</div>
<p>
Researchers found a 40% increased risk of heart failure in patients with untreated obstructive
sleep apnea. The five-year longitudinal study tracked 12,000 patients across three hospital
systems.
</p>
<div slot="footer">
<hx-avatar
label="Dr. Amara Osei"
initials="AO"
src="/avatars/osei.jpg"
hx-size="sm"
></hx-avatar>
<span>Dr. Amara Osei · March 20, 2026</span>
<hx-button variant="secondary" hx-href="/articles/heart-failure-study">Read article</hx-button>
</div>
</hx-card>
<hx-card variant="default">
<div slot="heading">
<hx-avatar
label="Dr. Kenji Watanabe"
initials="KW"
src="/avatars/watanabe.jpg"
hx-size="lg"
></hx-avatar>
<div>
<h3>Dr. Kenji Watanabe, MD</h3>
<hx-badge variant="success">Accepting new patients</hx-badge>
</div>
</div>
<dl>
<dt>Specialty</dt>
<dd>Internal Medicine, Geriatrics</dd>
<dt>Location</dt>
<dd>Main Campus — Building C, Suite 302</dd>
<dt>Languages</dt>
<dd>English, Japanese, Mandarin</dd>
</dl>
<div slot="actions">
<hx-button variant="primary">Schedule appointment</hx-button>
<hx-button variant="ghost">View full profile</hx-button>
</div>
</hx-card>

Component interaction: hx-card exposes named slots image, heading, actions, and footer, plus a default slot for body content. Slot content lives in the light DOM, so it inherits your application’s base typography styles. Use the adopted-stylesheets pattern to ensure light-DOM content inside the card is styled correctly in Drupal. hx-avatar accepts a label (accessible name) and initials (visible fallback when no src loads); there is no name attribute. hx-card variants are default | featured | compact (no outlined variant). Card variants reflect content density, not interactivity — to make the entire card a link, set hx-href on the card host (do not combine with slot="actions").

Accessibility notes:

  • Heading hierarchy inside cards must follow document order (h2 in a card on a page with an h1, not h1 inside the card)
  • If the entire card is clickable, set hx-href on the host card — this turns the card into one interactive link. Do NOT combine hx-href with slot="actions"; nested interactive controls inside a link break the activation contract.
  • hx-avatar exposes the accessible name via label and the visible-fallback text via initials; there is no name attribute. When an src is supplied, the avatar still renders the initials fallback if the image fails to load.
  • hx-badge communicates status visually; do not rely on color alone — the text label is required

Drupal Twig (SDC):

{# components/provider-card/provider-card.html.twig #}
{{ attach_library('mytheme/helix-card') }}
{{ attach_library('mytheme/helix-avatar') }}
{{ attach_library('mytheme/helix-badge') }}
{{ attach_library('mytheme/helix-button') }}
<hx-card variant="default">
<div slot="heading">
<hx-avatar
label="{{ provider.name }}"
initials="{{ provider.initials }}"
src="{{ provider.photo_url }}"
hx-size="lg"
></hx-avatar>
<div>
<h3>{{ provider.name }}</h3>
{% if provider.accepting_patients %}
<hx-badge variant="success">Accepting new patients</hx-badge>
{% else %}
<hx-badge variant="neutral">Not accepting new patients</hx-badge>
{% endif %}
</div>
</div>
<dl>
<dt>Specialty</dt>
<dd>{{ provider.specialty }}</dd>
{% if provider.location %}
<dt>Location</dt>
<dd>{{ provider.location }}</dd>
{% endif %}
</dl>
<div slot="actions">
<hx-button variant="primary" hx-href="{{ provider.schedule_url }}">
Schedule appointment
</hx-button>
<hx-button variant="ghost" hx-href="{{ provider.profile_url }}">
View full profile
</hx-button>
</div>
</hx-card>

Navigation compositions combine hx-top-nav, hx-side-nav, hx-breadcrumb, and hx-tabs to create orientation structures for complex healthcare applications.

<hx-top-nav label="Primary navigation">
<a slot="logo" href="/">
<img src="/logo.svg" alt="Health Patient Portal" width="160" height="40" />
</a>
<!-- hx-top-nav's default slot accepts anchor children directly;
do NOT nest another <nav> inside it. -->
<a href="/dashboard">Dashboard</a>
<a href="/appointments">Appointments</a>
<a href="/messages">Messages</a>
<a href="/records">Health Records</a>
<div slot="actions">
<hx-icon-button label="Notifications (3 unread)">
<hx-icon name="bell" aria-hidden="true"></hx-icon>
<hx-badge variant="error">3</hx-badge>
</hx-icon-button>
<hx-avatar
label="Sarah Lindqvist"
initials="SL"
src="/avatars/slindqvist.jpg"
hx-size="sm"
></hx-avatar>
</div>
</hx-top-nav>
<div class="app-layout">
<hx-side-nav label="Section navigation">
<hx-nav-item href="/appointments/upcoming" active>Upcoming</hx-nav-item>
<hx-nav-item href="/appointments/past">Past visits</hx-nav-item>
<hx-nav-item href="/appointments/request">Request appointment</hx-nav-item>
<hx-nav-item href="/appointments/video">Video visits</hx-nav-item>
</hx-side-nav>
<main id="main-content">
<hx-breadcrumb label="Breadcrumb">
<hx-breadcrumb-item href="/">Home</hx-breadcrumb-item>
<hx-breadcrumb-item href="/appointments">Appointments</hx-breadcrumb-item>
<hx-breadcrumb-item current>Upcoming</hx-breadcrumb-item>
</hx-breadcrumb>
<!-- Page content -->
</main>
</div>

Use hx-tabs for content within a single page view — not for page-level navigation. Tabs are appropriate when all content is available at once and the user needs to switch between views without leaving the page.

<hx-breadcrumb label="Breadcrumb">
<hx-breadcrumb-item href="/">Home</hx-breadcrumb-item>
<hx-breadcrumb-item href="/records">Health Records</hx-breadcrumb-item>
<hx-breadcrumb-item current>Medications</hx-breadcrumb-item>
</hx-breadcrumb>
<hx-tabs label="Medication views">
<hx-tab slot="tab" panel="current">Current medications</hx-tab>
<hx-tab slot="tab" panel="history">History</hx-tab>
<hx-tab slot="tab" panel="allergies">Allergies &amp; reactions</hx-tab>
<hx-tab-panel name="current">
<!-- Current medications content -->
</hx-tab-panel>
<hx-tab-panel name="history">
<!-- Medication history content -->
</hx-tab-panel>
<hx-tab-panel name="allergies">
<!-- Allergy list content -->
</hx-tab-panel>
</hx-tabs>

Component interaction: hx-top-nav manages responsive behavior and the mobile menu. hx-side-nav provides an icon-collapsed mode (collapsed attribute) on narrow viewports rather than a drawer overlay. hx-breadcrumb is purely structural — it does not manage routing. hx-tabs controls aria-selected, aria-controls, and aria-labelledby relationships automatically; the panel key is hx-tab[panel="X"] matched against hx-tab-panel[name="X"]. There is no separate hx-tab-list component — slot the <hx-tab> triggers into the tab slot of <hx-tabs> directly.

Accessibility notes:

  • hx-top-nav requires a label attribute to distinguish it from other <nav> landmarks on the page
  • hx-side-nav requires its own label distinct from the top nav
  • hx-breadcrumb exposes a label attribute on the host; the last item should have current (which sets aria-current="page")
  • hx-tabs implements the ARIA Tabs pattern: arrow key navigation between tabs, Tab moves focus to the active panel
  • Never use hx-tabs for top-level page navigation — use <a> elements and aria-current="page" instead

Drupal Twig (SDC):

{# components/patient-nav/patient-nav.html.twig #}
{{ attach_library('mytheme/helix-top-nav') }}
{{ attach_library('mytheme/helix-side-nav') }}
{{ attach_library('mytheme/helix-breadcrumb') }}
{{ attach_library('mytheme/helix-avatar') }}
{{ attach_library('mytheme/helix-button') }}
<hx-top-nav label="Primary navigation">
<a slot="logo" href="{{ front_page }}">
{{ logo }}
</a>
{# default slot — anchor children directly; no nested <nav> #}
{% for item in main_menu_items %}
<a
href="{{ item.url }}"
{% if item.is_current %}aria-current="page"{% endif %}
>{{ item.title }}</a>
{% endfor %}
<div slot="actions">
{% if logged_in %}
<hx-avatar
label="{{ user.display_name }}"
initials="{{ user.initials }}"
src="{{ user.avatar_url }}"
hx-size="sm"
></hx-avatar>
{% else %}
<hx-button variant="primary" hx-href="/user/login">Sign in</hx-button>
{% endif %}
</div>
</hx-top-nav>

Data display compositions pair hx-data-table with hx-pagination and hx-action-bar for sortable, filterable, paginated data grids common in clinical and administrative interfaces.

Patient list with action bar and pagination

Section titled “Patient list with action bar and pagination”

hx-action-bar is a layout container with a default slot (no search, filters, or actions named slots). Lay out its children with author CSS — typically a flex row separating search, filters, and end-aligned actions.

<hx-action-bar>
<hx-text-input
type="search"
placeholder="Search patients..."
accessible-label="Search patients"
></hx-text-input>
<hx-select accessible-label="Filter by status">
<option value="">All statuses</option>
<option value="active">Active</option>
<option value="discharged">Discharged</option>
<option value="pending">Pending admission</option>
</hx-select>
<hx-select accessible-label="Filter by unit">
<option value="">All units</option>
<option value="icu">ICU</option>
<option value="cardiac">Cardiac Care</option>
<option value="oncology">Oncology</option>
</hx-select>
<span style="flex: 1"></span>
<hx-button variant="primary">Add patient</hx-button>
<hx-button variant="secondary">Export CSV</hx-button>
</hx-action-bar>
<!--
hx-data-table uses the `columns` + `rows` properties (set via JS).
There is no separate <hx-column> element. There is no host-level
`sortable` attribute either; per-column sortability is declared in
the columns array passed via JS (see below).
-->
<hx-data-table id="patient-list" label="Patient list"></hx-data-table>
<hx-pagination
total-pages="34"
page-size="25"
current-page="1"
label="Patient list pagination"
></hx-pagination>

Wiring the data table with JavaScript:

const table = document.querySelector('#patient-list');
const pagination = document.querySelector('hx-pagination');
const searchInput = document.querySelector('hx-text-input[type="search"]');
// Columns are defined in JS (no separate <hx-column> element).
table.columns = [
{ key: 'mrn', label: 'MRN', sortable: true },
{ key: 'name', label: 'Patient name', sortable: true },
{ key: 'dob', label: 'Date of birth', sortable: true },
{ key: 'status', label: 'Status' },
{ key: 'provider', label: 'Assigned provider', sortable: true },
{ key: 'actions', label: 'Actions', align: 'end' },
];
// Load and render rows.
async function loadPatients(page = 1, query = '') {
const response = await fetch(
`/api/patients?page=${page}&q=${encodeURIComponent(query)}&per_page=25`,
);
const { patients, totalPages } = await response.json();
table.rows = patients.map((p) => ({
mrn: p.mrn,
name: `${p.lastName}, ${p.firstName}`,
dob: new Intl.DateTimeFormat('en-US').format(new Date(p.dateOfBirth)),
status: p.status,
provider: p.assignedProvider,
actions: '',
}));
pagination.totalPages = totalPages;
}
// hx-pagination emits hx-page-change with {page} detail.
pagination.addEventListener('hx-page-change', (e) => {
loadPatients(e.detail.page, searchInput.value);
});
// Search (debounced).
let searchTimeout;
searchInput.addEventListener('hx-input', (e) => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
pagination.currentPage = 1;
loadPatients(1, e.detail.value);
}, 300);
});
loadPatients();

Component interaction: hx-action-bar is a layout container with a single default slot — place your search, filters, and action buttons directly inside it and lay them out with CSS. hx-data-table manages column configuration and sort state via the columns and rows properties (set in JS, not via attribute). hx-pagination fires hx-page-change with { page } detail and consumes total-pages (not total-items). Coordinate these three components in your application layer, not inside the components themselves.

Accessibility notes:

  • hx-data-table renders a native <table> in its shadow DOM with aria-sort attributes managed automatically; the label attribute provides the table’s accessible name when no visible <caption> is present
  • hx-pagination announces page changes to screen readers — do not add your own live region
  • Search inputs in hx-action-bar must have explicit accessible-label (or aria-label) when no visible <label> is present

Overlays — dialogs and drawers — collect focused user input or display supplemental content without navigating away. hx-dialog handles modal forms; hx-drawer handles side panels.

<!-- Trigger -->
<hx-button id="open-add-medication" variant="primary">Add medication</hx-button>
<!--
hx-dialog is non-modal by default. Set the `modal` attribute (or open
via dialog.showModal()) to trap focus and inert the underlying page.
Use the `heading` attribute for the accessible name, or slot a
heading element into the `header` slot. Width is driven by
--hx-dialog-* CSS custom properties (there is no `size` attribute).
-->
<hx-dialog id="add-medication-dialog" modal heading="Add medication">
<hx-form id="add-medication-form" action="/api/medications" method="post" novalidate>
<hx-field>
<label slot="label" for="med-name">Medication name</label>
<hx-text-input
id="med-name"
name="medicationName"
required
autocomplete="off"
></hx-text-input>
</hx-field>
<hx-field>
<label slot="label" for="med-dose">Dosage</label>
<hx-text-input id="med-dose" name="dosage" placeholder="e.g. 10mg" required></hx-text-input>
</hx-field>
<hx-field>
<label slot="label" for="med-frequency">Frequency</label>
<hx-select id="med-frequency" name="frequency" required>
<option value="">Select frequency</option>
<option value="once-daily">Once daily</option>
<option value="twice-daily">Twice daily</option>
<option value="three-times-daily">Three times daily</option>
<option value="as-needed">As needed (PRN)</option>
</hx-select>
</hx-field>
</hx-form>
<div slot="footer">
<hx-button form="add-medication-form" type="submit" variant="primary">Add medication</hx-button>
<hx-button id="cancel-medication" variant="ghost">Cancel</hx-button>
</div>
</hx-dialog>
<script>
const dialog = document.querySelector('#add-medication-dialog');
const openBtn = document.querySelector('#open-add-medication');
const cancelBtn = document.querySelector('#cancel-medication');
const form = document.querySelector('#add-medication-form');
openBtn.addEventListener('click', () => (dialog.open = true));
cancelBtn.addEventListener('click', () => (dialog.open = false));
// Dialog fires hx-close after dismissal (Escape, close button, or
// backdrop click when close-on-backdrop is enabled).
dialog.addEventListener('hx-close', () => {
form.reset();
});
// hx-form re-emits the native submit as hx-submit with structured
// detail. Use that, not the inner <form> directly.
form.addEventListener('hx-submit', async (e) => {
e.preventDefault();
if (!e.detail.valid) return; // hx-invalid already fired
await saveMedication(e.detail.values);
dialog.open = false;
form.reset();
});
</script>
<hx-button id="open-patient-detail" variant="secondary">View patient details</hx-button>
<!--
hx-drawer is modal-by-default — it inert-traps the page underneath
on open. Use `placement` to anchor an edge (start | end | top | bottom)
and `hx-size` for the preset width/height. The drawer's accessible
name comes from `label` (attribute) or the `label` slot.
-->
<hx-drawer id="patient-detail-drawer" placement="end" hx-size="lg" label="Patient details">
<div class="patient-detail-content">
<hx-tabs label="Patient detail sections">
<hx-tab slot="tab" panel="overview">Overview</hx-tab>
<hx-tab slot="tab" panel="vitals">Vitals</hx-tab>
<hx-tab slot="tab" panel="notes">Notes</hx-tab>
<hx-tab-panel name="overview">
<!-- Patient overview content -->
</hx-tab-panel>
<hx-tab-panel name="vitals">
<!-- Vitals content -->
</hx-tab-panel>
<hx-tab-panel name="notes">
<!-- Clinical notes content -->
</hx-tab-panel>
</hx-tabs>
</div>
<div slot="footer">
<hx-button variant="primary" hx-href="/patients/p-001/record">Open full record</hx-button>
<hx-button id="close-drawer" variant="ghost">Close</hx-button>
</div>
</hx-drawer>
<script>
const drawer = document.querySelector('#patient-detail-drawer');
document.querySelector('#open-patient-detail').addEventListener('click', () => {
drawer.open = true;
});
document.querySelector('#close-drawer').addEventListener('click', () => {
drawer.open = false;
});
// hx-drawer's close lifecycle is hx-hide / hx-after-hide (not hx-close).
drawer.addEventListener('hx-after-hide', () => {
// restore UI state, blur, etc.
});
</script>

Component interaction: hx-dialog is non-modal by default; set the modal attribute (or call dialog.showModal()) to trap focus and inert the page underneath. hx-drawer is modal-by-default — it traps focus on open. Both components manage their own aria-modal, role, and focus trap behavior; the dialog’s accessible name comes from the heading attribute (or header slot), the drawer’s from label (or label slot).

Accessibility notes:

  • hx-dialog provides its accessible name via the heading attribute or the heading element slotted into header — there is no separate heading slot
  • Focus moves to the dialog when opened and returns to the trigger element when closed
  • Pressing Escape closes both hx-dialog and hx-drawer. Listen for hx-close on dialog and hx-hide / hx-after-hide on drawer to reset form state
  • The form attribute on a submit button (e.g., form="add-medication-form") correctly associates the button with a form outside the button’s DOM ancestry — this is required when the submit button is in the footer slot

Drupal Twig (SDC):

{# components/medication-dialog/medication-dialog.html.twig #}
{{ attach_library('mytheme/helix-dialog') }}
{{ attach_library('mytheme/helix-form') }}
{{ attach_library('mytheme/helix-field') }}
{{ attach_library('mytheme/helix-text-input') }}
{{ attach_library('mytheme/helix-select') }}
{{ attach_library('mytheme/helix-button') }}
<hx-button id="open-add-medication-{{ unique_id }}" variant="primary">
{{ trigger_label|default('Add medication') }}
</hx-button>
<hx-dialog
id="add-medication-dialog-{{ unique_id }}"
modal
heading="{{ dialog_title|default('Add medication') }}"
>
<hx-form
id="add-medication-form-{{ unique_id }}"
novalidate
action="{{ form_action }}"
method="post"
>
{{ form_fields }}
</hx-form>
<div slot="footer">
<hx-button form="add-medication-form-{{ unique_id }}" type="submit" variant="primary">
{{ submit_label|default('Save') }}
</hx-button>
<hx-button class="dialog-cancel" variant="ghost">Cancel</hx-button>
</div>
</hx-dialog>

Feedback compositions communicate system status, validation results, and process outcomes. hx-alert handles inline messages, hx-toast handles transient notifications, and hx-banner handles site-wide announcements.

<!--
hx-alert is hidden until `open` is set. variant values are
primary | secondary | success | warning | error | neutral | info
(no `danger`). The title slot is `title`, not `heading`.
-->
<hx-alert open variant="error">
<span slot="title">Unable to save changes</span>
<p>Your session has expired. Please <a href="/user/login">sign in again</a> and resubmit.</p>
</hx-alert>
<!-- Shown after a successful action -->
<hx-alert open variant="success" dismissible>
<span slot="title">Appointment scheduled</span>
<p>
Your appointment with Dr. Watanabe on March 28 at 2:00 PM has been confirmed. A confirmation has
been sent to your email.
</p>
</hx-alert>
<!-- Advisory information with no action required -->
<hx-alert open variant="warning">
<span slot="title">Incomplete health profile</span>
<p>
Some providers require a complete health history before scheduling.
<a href="/profile/health-history">Complete your health history</a> to access all providers.
</p>
</hx-alert>

Toast notifications for transient feedback

Section titled “Toast notifications for transient feedback”
<!-- Toast stack — place once at the application root. The shipped
container is hx-toast-stack (not hx-toast-region). -->
<hx-toast-stack label="Notifications"></hx-toast-stack>
<script>
const toastStack = document.querySelector('hx-toast-stack');
// Show a toast. Appending alone is not enough — hx-toast defaults
// to closed; call show() (or set the `open` attribute) to display it.
function showToast({ message, variant = 'info', duration = 5000 }) {
const toast = document.createElement('hx-toast');
toast.variant = variant;
toast.duration = duration;
toast.textContent = message;
toastStack.appendChild(toast);
toast.show(); // or toast.setAttribute('open', '')
}
async function handleSave() {
try {
await saveRecord();
showToast({ message: 'Record saved successfully.', variant: 'success' });
} catch (error) {
showToast({
message: 'Save failed. Please try again.',
variant: 'error',
duration: 0,
});
}
}
</script>
<!--
hx-banner exposes default + `action` slots only — there is no `icon`
slot. Use the `heading` attribute for the title and place an
action via slot="action" or via `action-label` + `action-href`.
Dismissal sets `open` to false and dispatches hx-dismiss; persistence
across sessions is your own concern (store the id in localStorage).
-->
<hx-banner
open
variant="warning"
dismissible
heading="Scheduled maintenance"
id="maintenance-banner"
>
Scheduled maintenance is planned for Sunday, March 29 from 2–4 AM EST. The patient portal will be
unavailable during this window.
<a slot="action" href="/notices/maintenance-march-29">Learn more</a>
</hx-banner>
<hx-top-nav><!-- ... --></hx-top-nav>

Component interaction: hx-alert is hidden until open is set; once open it persists until removed from the DOM or dismissed (if dismissible). hx-toast auto-dismisses after duration ms (set duration="0" to require manual dismiss); the toast does not appear until show() is called or open is set. hx-toast-stack manages stacking, animation, and the polite live-region announcement — create one stack at the application root and append toasts to it programmatically. hx-banner spans full width above the navigation layer.

Accessibility notes:

  • Set open on hx-alert to make it appear and announce. For critical errors, prefer severity-label="Error" (or similar) — hx-alert manages its own role/aria-live based on variant
  • hx-toast-stack manages its own live region — do not wrap it in another aria-live region
  • hx-banner with dismissible flips open to false on dismiss and emits hx-dismiss; if you need cross-session persistence, capture that event and persist the banner id yourself (e.g. localStorage)
  • Never rely on color alone to communicate alert severity — variant also drives the default icon and accessible label

Drupal Twig (SDC):

{# components/system-alert/system-alert.html.twig #}
{{ attach_library('mytheme/helix-alert') }}
{% if message %}
<hx-alert
open
variant="{{ variant|default('info') }}"
{% if dismissible %}dismissible{% endif %}
>
{% if heading %}
<span slot="title">{{ heading }}</span>
{% endif %}
{{ message }}
</hx-alert>
{% endif %}

Healthcare applications have recurring UI patterns unique to the domain. These compositions address the most common: provider cards, appointment schedulers, and patient information displays.

<hx-card variant="default" class="provider-card">
<div slot="heading" class="provider-header">
<hx-avatar
label="Dr. Amara Osei, MD"
initials="AO"
src="/avatars/osei-amara.jpg"
hx-size="lg"
></hx-avatar>
<div class="provider-identity">
<h2 class="provider-name">Dr. Amara Osei, MD</h2>
<p class="provider-specialty">Cardiology · Interventional</p>
<div class="provider-status">
<!--
hx-status-indicator values: online | offline | away | busy | unknown
('available' is not part of the union — use 'online' for the
"accepting new patients" / "actively practicing" signal).
-->
<hx-status-indicator status="online" label="Active practice"></hx-status-indicator>
<hx-badge variant="success">Accepting new patients</hx-badge>
</div>
</div>
</div>
<dl class="provider-meta">
<dt>Board certified</dt>
<dd>American Board of Internal Medicine (Cardiovascular Disease)</dd>
<dt>Hospital affiliations</dt>
<dd>Lenox Hill, Long Island Jewish</dd>
<dt>Languages</dt>
<dd>English, Akan, French</dd>
<dt>Next available</dt>
<dd>
<hx-badge variant="info">March 31, 2026</hx-badge>
</dd>
</dl>
<div slot="actions">
<hx-button variant="primary">Schedule appointment</hx-button>
<hx-button variant="secondary">Send message</hx-button>
<hx-button variant="ghost">View full profile</hx-button>
</div>
</hx-card>
<!--
hx-steps exposes only the default slot. Step children are
authored as hx-list-item / your own step components; the host
does not provide `current-step` / `total-steps` numeric attrs
or `step-N` named slots.
-->
<hx-card variant="default" class="appointment-scheduler">
<span slot="heading">Request an appointment</span>
<hx-steps accessible-label="Appointment scheduling progress">
<hx-list-item aria-current="step">Choose provider</hx-list-item>
<hx-list-item>Select time</hx-list-item>
<hx-list-item>Confirm</hx-list-item>
</hx-steps>
<!-- Step 1: Provider selection -->
<div id="step-provider" class="scheduler-step">
<hx-form id="provider-select-form" novalidate>
<hx-field>
<label slot="label" for="specialty-filter">Filter by specialty</label>
<hx-select id="specialty-filter" name="specialty">
<option value="">All specialties</option>
<option value="cardiology">Cardiology</option>
<option value="primary-care">Primary Care</option>
<option value="oncology">Oncology</option>
</hx-select>
</hx-field>
<hx-field>
<label slot="label" for="insurance-filter">Your insurance</label>
<hx-select id="insurance-filter" name="insurance">
<option value="">All insurance accepted</option>
<option value="bcbs">Blue Cross Blue Shield</option>
<option value="aetna">Aetna</option>
<option value="medicare">Medicare</option>
</hx-select>
</hx-field>
</hx-form>
<!-- Provider results rendered here -->
<div id="provider-results" role="list" aria-label="Matching providers">
<!-- hx-card elements injected dynamically -->
</div>
</div>
<div slot="footer">
<hx-button id="prev-step" variant="ghost" disabled>Back</hx-button>
<hx-button id="next-step" variant="primary">Continue</hx-button>
</div>
</hx-card>

Patient information display with PHI protection

Section titled “Patient information display with PHI protection”

Healthcare applications must handle Protected Health Information (PHI) carefully. The hx-phi-field component provides a standardized pattern for revealing sensitive data on demand with an audit hook.

<!--
hx-phi-field takes `field-type` (ssn | mrn | dob | insurance) and
`field-id` to identify the record being viewed. The actual PHI value
is fetched from a server-side endpoint on reveal — never set the
value as an attribute or render it pre-masked into the DOM. The
component emits `hx-phi-access` for audit logging on every
reveal/mask transition.
-->
<hx-card variant="default" class="patient-summary">
<span slot="heading">Patient summary</span>
<dl class="patient-info-grid">
<div class="patient-info-row">
<dt>Full name</dt>
<dd>Margaret Elizabeth Thornton</dd>
</div>
<div class="patient-info-row">
<dt>Date of birth</dt>
<dd>
<hx-phi-field field-type="dob" field-id="p-0044821" label="Date of birth"></hx-phi-field>
</dd>
</div>
<div class="patient-info-row">
<dt>Medical record number</dt>
<dd>
<hx-phi-field
field-type="mrn"
field-id="p-0044821"
label="Medical record number"
></hx-phi-field>
</dd>
</div>
<div class="patient-info-row">
<dt>Allergies</dt>
<dd>
<hx-clinical-status
severity="critical"
message="Penicillin (anaphylaxis), Sulfa drugs (rash), Latex"
></hx-clinical-status>
</dd>
</div>
<div class="patient-info-row">
<dt>Code status</dt>
<dd>
<hx-clinical-status severity="warning" message="DNR / DNI"></hx-clinical-status>
</dd>
</div>
</dl>
<div slot="actions">
<hx-button variant="secondary" hx-href="/patients/p-0044821/record">Open full record</hx-button>
<hx-button variant="ghost" id="print-summary">Print summary</hx-button>
</div>
</hx-card>

Component interaction: hx-phi-field manages reveal/mask state internally; on every transition it emits a single hx-phi-access event that consumers wire to their audit log. hx-clinical-status accepts a severity value of info | warning | critical | emergent and the message via the message attribute (or default slot). attention / neutral are not valid severities.

document.querySelectorAll('hx-phi-field').forEach((field) => {
field.addEventListener('hx-phi-access', (e) => {
auditLog.record({
action: e.detail.revealed ? 'phi_revealed' : 'phi_masked',
fieldType: field.getAttribute('field-type'),
fieldId: field.getAttribute('field-id'),
user: currentUserId,
timestamp: new Date().toISOString(),
});
});
});

Accessibility notes:

  • hx-phi-field shows the reveal/mask toggle as part of its shadow UI and labels it relative to the label attribute (e.g. “Reveal date of birth”); the value is fetched on demand, never embedded pre-masked
  • hx-clinical-status selects role based on severitycritical / emergent get assertive announcements; verify the mapping matches your clinical workflow requirements
  • Patient summary cards should include a visually hidden heading that identifies the patient by name so screen reader users can orient themselves
  • Code status and allergy information are safety-critical; never hide them behind a collapsed state without a clearly labeled disclosure control

Drupal Twig (SDC):

{# components/patient-summary/patient-summary.html.twig #}
{{ attach_library('mytheme/helix-card') }}
{{ attach_library('mytheme/helix-phi-field') }}
{{ attach_library('mytheme/helix-clinical-status') }}
{{ attach_library('mytheme/helix-button') }}
<hx-card variant="default" class="patient-summary">
<span slot="heading">Patient summary</span>
<dl class="patient-info-grid">
<div class="patient-info-row">
<dt>Full name</dt>
<dd>{{ patient.full_name }}</dd>
</div>
<div class="patient-info-row">
<dt>Date of birth</dt>
<dd>
<hx-phi-field
field-type="dob"
field-id="{{ patient.id }}"
label="Date of birth"
></hx-phi-field>
</dd>
</div>
<div class="patient-info-row">
<dt>Medical record number</dt>
<dd>
<hx-phi-field
field-type="mrn"
field-id="{{ patient.id }}"
label="Medical record number"
></hx-phi-field>
</dd>
</div>
{% if patient.allergies %}
<div class="patient-info-row">
<dt>Allergies</dt>
<dd>
<hx-clinical-status
severity="critical"
message="{{ patient.allergies|join(', ') }}"
></hx-clinical-status>
</dd>
</div>
{% endif %}
{% if patient.code_status %}
<div class="patient-info-row">
<dt>Code status</dt>
<dd>
<hx-clinical-status
severity="{{ patient.code_status == 'DNR' ? 'warning' : 'info' }}"
message="{{ patient.code_status }}"
></hx-clinical-status>
</dd>
</div>
{% endif %}
</dl>
<div slot="actions">
<hx-button variant="secondary" hx-href="{{ patient.record_url }}">Open full record</hx-button>
</div>
</hx-card>

These patterns share common principles worth making explicit:

One concern per layer. Components handle their own rendering and accessibility. Compositions coordinate multiple components. Application code handles data, routing, and business logic. Do not put data fetching logic inside components; do not put component-specific accessibility overrides in application code.

Events: typed CustomEvents with hx- prefixes for the public lifecycle. Most HELiX components emit CustomEvent instances with an hx- prefix (e.g. hx-change, hx-submit, hx-page-change). A few components extend Event directly when the detail payload is empty, and some still dispatch standard DOM events alongside the typed wrapper. Always consult the component’s CEM events entry for the canonical name and detail shape before subscribing.

Properties for data, attributes for state. Pass complex data (arrays, objects, non-string values) via JavaScript properties. Use HTML attributes for boolean flags, string identifiers, and initial state that must be set before JavaScript runs.

Slot content is light DOM. Content passed into slots lives in the document’s DOM, not inside the component’s shadow root. This means it inherits your application styles but not the component’s internal styles. Use the adopted-stylesheets pattern when slotted content needs base typography, link styles, or CMS-authored formatting.

Design tokens, not inline styles. Override component appearance by setting --hx-* custom properties at the appropriate scope. Never use !important or inline style attributes to force visual changes — these create maintenance debt and bypass the token system.