Skip to content
HELiX

ADR: Slots vs Props

apps/docs/src/content/docs/architecture/adrs/slots-vs-props Click to copy
Copied! apps/docs/src/content/docs/architecture/adrs/slots-vs-props

Every web component in the system must answer one fundamental question: does the component control the rendering, or does Drupal? This architectural decision shapes every integration pattern, every Twig template, and every content editor’s experience.

Accepted. The default for new components is slot-first, property-enhanced.

HELiX components are consumed primarily by Drupal Twig templates. Drupal already provides a powerful field system, media library, and WYSIWYG editor. Components must coexist with these tools rather than replace them.

Two extremes are possible:

  • Property-driven — The component owns all rendering. Data is passed via attributes and properties. Drupal serializes field values into a flat prop list.
  • Slot-driven — The component provides structure via named slots. Drupal renders content directly into those slots. “Let Drupal Drupal.”

Each end of the spectrum has tradeoffs in Storybook usability, Drupal module compatibility, and editorial control.

Every component sits somewhere on a spectrum between these two strategies:

StrategyWho controls contentBest for
Property-drivenComponentAtoms with fixed API (Button, Badge, Switch)
HybridComponent + DrupalForms with validation behavior and slotted labels
Slot-drivenDrupalOrganisms with rich content (Card, Dialog, Data Table)

Default to slots for content flexibility. Use properties for behavior, configuration, and state.

This approach maximises Drupal’s strengths while keeping components powerful and testable.

The same card component, built two ways:

Property-driven (hypothetical) — a card component that owned all rendering would expose every content field as a prop. hx-card does not ship this API — the snippet below is a deliberate anti-pattern for contrast:

{# ❌ Hypothetical property-driven card — these props don't exist on real hx-card #}
<hx-card
title="{{ content.field_title }}"
description="{{ content.field_body }}"
image-src="{{ file_url(content.field_image) }}"
image-alt="{{ content.field_image.alt }}"
hx-href="{{ url }}"
variant="featured"
></hx-card>
// In Storybook the hypothetical version would look like this — every value is a control:
export const HypotheticalPropertyDriven = {
args: {
title: 'Article Title',
description: 'Summary text...',
imageSrc: '/placeholder.jpg',
variant: 'featured',
},
};

Slot-driven (actual hx-card API) — Drupal projects content into named slots; the component owns the chrome:

{# Drupal controls all content; hx-card exposes variant + elevation + hx-href #}
<hx-card variant="featured" elevation="raised" hx-href="{{ url }}">
<img slot="image"
src="{{ file_url(content.field_image) }}"
alt="{{ content.field_image.alt }}" />
<h3 slot="heading">{{ content.field_title }}</h3>
{{ content.field_body }}
<a slot="actions" href="{{ url }}">Read More</a>
</hx-card>
// Slot-driven Storybook stories use a render function with Lit's html``
// tagged template — the repo's standard pattern. Slot content can't be
// driven by Controls directly, but the render function can interpolate args.
import { html } from 'lit';
export const Default = {
args: { variant: 'featured', elevation: 'raised' },
render: (args) => html`
<hx-card variant=${args.variant} elevation=${args.elevation}>
<img slot="image" src="/placeholder.jpg" alt="Placeholder" />
<h3 slot="heading">Article Title</h3>
<p>Summary text…</p>
<a slot="actions" href="#">Read more</a>
</hx-card>
`,
};

Not every Drupal module works equally well with every strategy. The matrix below is implementation guidance distilled from the HELiX Drupal integration packages (@helixui/drupal-starter, @helixui/drupal-behaviors) rather than a controlled test matrix — verify against your site’s stack before treating any cell as a guarantee.

Drupal moduleProperty-drivenSlot-drivenHybrid
Layout BuilderRequires custom block plugins to map fieldsBlocks drop content into slots naturallyBest of both — slots for layout, props for config
ParagraphsParagraph fields map directly to propertiesParagraph content renders into named slotsIdeal — structured data + flexible content
MediaMust extract URL/alt from media entity manuallyDrupal renders media natively into slotsSlot for media, props for display config
ViewsViews HTML output is hard to serialise to propsViews row output drops into slots perfectlySlots for content, limited prop use
WebformProps work for simple field configSlots handle form rendering but lose validationProps for validation/state, slots for layout

The component’s complexity determines the right strategy. Simple atoms use properties. Complex organisms use slots. Forms use both.

Small, self-contained components with a fixed prop API. Some expose at most one or two narrow slots (prefix/suffix icons, default label content); rendering logic still lives in the component.

  • hx-button (default + prefix/suffix slots)
  • hx-badge
  • hx-switch
  • hx-tooltip
  • hx-spinner
  • hx-avatar

Complex, content-rich components. Drupal editors need full control over what appears inside.

  • hx-card — slot-driven (image, heading, default, footer, actions)
  • hx-dialog — slot-driven (header, default body, footer)
  • hx-accordion / hx-accordion-item — slot-driven (trigger, default body)
  • hx-nav, hx-top-nav, hx-side-nav — hybrid (props for orientation/labels, slots for nav items)
  • hx-data-table — primarily property-driven (rows/columns are bound via props); slots cover toolbar, empty, and loading states

Properties control validation, state, and behaviour. Many form controls expose slots for labels, help text, and error overrides — but exceptions exist (e.g. hx-file-upload and hx-radio-group use props for label/help text rather than slots).

  • hx-text-input
  • hx-select
  • hx-checkbox
  • hx-radio-group
  • hx-date-picker
  • hx-file-upload
  • Let Drupal own content. Content editors already know how to use Drupal’s field system, media library, and WYSIWYG. Slots let them keep using these tools without learning component APIs.
  • Properties for behaviour. Use properties for things content editors should not control: variant styles, validation rules, accessibility states, animation settings.
  • Content-first. Enterprise organisations need robust content governance. Slot-driven components give editorial teams control. Each component’s accessibility profile is documented in its CEM entry and AAA-AUDIT.md — the audited contract guarantees the component’s chrome; consumer-supplied slotted markup still has to be semantic (headings in heading slots, accessible names on image slots, etc.) for the composition to meet WCAG.
  • Storybook stories for slot-driven components require a render() function that projects markup via Lit’s html tagged template (the repo’s standard story pattern). Storybook Controls can drive props passed into the render function, but cannot drive slotted DOM directly.
  • Mixed strategy components require discipline. Form components must clearly document which inputs are properties (validation rules) versus slots (label content).
  • Attribute Naming — applies to every property in this ADR.
  • Light DOM — covers the narrow cases (Drupal Form API interop, scoped CSS via Lit controllers) where a slot-driven component drops the shadow boundary entirely; default remains Shadow DOM.