Architecture Decision Record

The Decision That
Defines Everything

Properties vs. Slots: Where Does Control Live?

Every web component in this 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.

Three Strategies, One Goal

Every component sits somewhere on this spectrum. Understanding where helps you choose the right approach for each use case.

Property-Driven
Component controls everything. Data passed via attributes and properties. Storybook-first, Drupal passes data.
Hybrid
The sweet spot. Atoms use properties, organisms use slots, forms use both. Best of both worlds.
Slot-Driven
Drupal controls everything. Component provides structure via named slots. "Let Drupal Drupal."
The Sweet Spot: Slot-First, Property-Enhanced
Default to slots for content flexibility (Drupal controls structure), then add properties for behavior, state, and configuration. This gives content editors maximum freedom while components handle the hard parts.

See the Difference in Code

The same card component, built two ways. See how each approach impacts both your TWIG templates and your Storybook stories.

Property-Driven
TWIG Template
card.html.twig
{# Component controls ALL rendering #}
<wc-card
  title="{{ content.field_title }}"
  description="{{ content.field_body }}"
  image-src="{{ file_url(content.field_image) }}"
  image-alt="{{ content.field_image.alt }}"
  href="{{ url }}"
  variant="elevated"
></wc-card>
Storybook
card.stories.ts
// Perfect in Storybook - all props visible
export const Default = {
  args: {
    title: 'Article Title',
    description: 'Summary text...',
    imageSrc: '/placeholder.jpg',
    variant: 'elevated',
  },
};
Slot-Driven
TWIG Template
card.html.twig
{# Drupal controls ALL content #}
<wc-card variant="elevated">
  <img slot="media"
    src="{{ file_url(content.field_image) }}"
    alt="{{ content.field_image.alt }}" />
  <h3 slot="heading">
    {{ content.field_title }}
  </h3>
  <div slot="body">
    {{ content.field_body }}
  </div>
  <a slot="actions" href="{{ url }}">
    Read More
  </a>
</wc-card>
Storybook
card.stories.ts
// Slots need HTML strings in Storybook
export const Default = {
  args: {
    variant: 'elevated',
  },
  render: (args) => `
    <wc-card variant="${args.variant}">
      <img slot="media" ... />
      <h3 slot="heading">...</h3>
      <div slot="body">...</div>
    </wc-card>`,
};

Drupal Module Compatibility

Not every Drupal module works equally well with every strategy. Here is how each approach maps to the modules you use daily.

Drupal Module
Property-Driven
Slot-Driven
Hybrid
Layout Builder
Requires custom block plugins to map fields
Blocks drop content into slots naturally
Best of both - slots for layout, props for config
Paragraphs
Paragraph fields map directly to properties
Paragraph content renders into named slots
Ideal match - structured data + flexible content
Media
Must extract URL/alt from media entity manually
Drupal renders media natively into slots
Slot for media, props for display config
Views
Views HTML output is hard to serialize to props
Views row output drops into slots perfectly
Slots for content, limited prop use for views
Webform
Props work for simple field config (label, type)
Slots handle form rendering but lose validation
Props for validation/state, slots for form layout

Right Tool for Each Level

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

Atoms
Property-Driven

Small, self-contained components with a fixed API. All rendering logic lives in the component.

  • Button
  • Badge
  • Toggle
  • Tooltip
  • Spinner
  • Avatar
Organisms
Slot-Driven

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

  • Card
  • Hero
  • Modal
  • Navigation
  • Data Table
  • Accordion
Forms
Hybrid

Properties control validation, state, and behavior. Slots allow custom labels, help text, and error messages.

  • Text Input
  • Select
  • Checkbox
  • Radio
  • Date Picker
  • File Upload

Our Architecture Decision

Slot-First, Property-Enhanced

Default to slots for content flexibility. Use properties for behavior, configuration, and state management. This approach maximizes Drupal's strengths while keeping components powerful and testable.

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 Behavior

Use properties for things content editors should not control: variant styles, validation rules, accessibility states, animation settings, and component configuration.

Healthcare First

Healthcare organizations need robust content governance. Slot-driven components give editorial teams control while maintaining WCAG AAA accessibility standards.

Architecture Decision Record

How Components
Reach the Browser

Bundle Strategy: One File, Many Files, or Smart Groups?

The second critical architecture decision: how do web components get delivered to each page? A single monolithic bundle, individual per-component files, or intelligent context-based groups? The right strategy means fast first paint and zero wasted bytes.

How Components Reach the Browser

The architecture decision does not stop at props vs. slots. How you physically load web component JavaScript into Drupal pages has a direct impact on performance, cacheability, and maintainability.

Single Bundle
"Load Everything"
~220KB per page
FCP Impact
Cache Efficiency
Setup Complexity
wc_2026.libraries.yml
# Single bundle - every component in one file
wc_2026:
  js:
    dist/wc-2026.bundle.js: { minified: true }
  dependencies:
    - core/drupal
    - core/once
Per-Component Libraries
"Surgical Loading"
~5-15KB per page
FCP Impact
Cache Efficiency
Setup Complexity
wc_2026.libraries.yml
# Per-component - surgical loading
wc_2026/card:
  js:
    dist/components/wc-card.js: { minified: true }
  dependencies:
    - wc_2026/lit-runtime

wc_2026/button:
  js:
    dist/components/wc-button.js: { minified: true }
  dependencies:
    - wc_2026/lit-runtime

wc_2026/accordion:
  js:
    dist/components/wc-accordion.js: { minified: true }
  dependencies:
    - wc_2026/lit-runtime
Performance Impact Comparison
Side-by-side metrics showing how each loading strategy affects real-world performance. Bars animate when scrolled into view.
Initial Page Load
KB transferred on first visit
Single Bundle
220KB
220KB
Per-Component
25KB
25KB
Hybrid Groups
60KB
60KB
HTTP Requests
Script requests per page load
Single Bundle
1
1 req
Per-Component
3-8
3-8 req
Hybrid Groups
2-3
2-3 req
Cache Hit Rate
After navigating to a second page
Single Bundle
100%
100%
Per-Component
~60%
~60%
Hybrid Groups
~90%
~90%
How Per-Component Loading Works in Practice
From content creation to Custom Element upgrade, every step is designed to deliver only the JavaScript each page actually needs.
1
Content Editor
Creates node with paragraph types (Card, Hero, etc.)
2
TWIG Template
Renders component and attaches library
attach_library('wc_2026/card')
3
Drupal Aggregation
Combines only the needed assets into optimized bundles
4
Minimal JS Load
Browser downloads only card.js + lit-runtime.js
~18KB total (not 220KB)
5
CE Upgrade
Custom Elements register and upgrade in the DOM
customElements.define()
TWIG Template Integration
paragraph--card.html.twig
{# In paragraph--card.html.twig #}
{{ attach_library('wc_2026/card') }}

<wc-card variant="elevated">
  <img slot="media" src="{{ image_url }}" />
  <h3 slot="heading">{{ title }}</h3>
  <div slot="body">{{ body }}</div>
</wc-card>

{# Drupal only loads wc-card.js + lit-runtime.js #}
{# Total: ~18KB for this page (not 220KB!) #}
Shared Runtime Dependency
wc_2026.libraries.yml
# Shared Lit runtime - loaded once, cached forever
wc_2026/lit-runtime:
  js:
    dist/vendor/lit-core.js: { minified: true }
  dependencies:
    - core/drupal
    - core/once
  # ~15KB gzipped - Lit 3 runtime
  # Cached across ALL pages after first load

Real-World Patterns

Three components, three strategies. Here is how each approach looks in production TWIG templates.

Card (Slot-Driven)

Drupal renders content into named slots. The component provides layout, elevation, and interaction patterns.

card.html.twig
<wc-card variant="elevated" interactive>
  <img slot="media"
    src="{{ file_url(image.uri) }}"
    alt="{{ image.alt }}"
    loading="lazy" />

  <h3 slot="heading">
    {{ title }}
  </h3>

  <p slot="body">
    {{ body|striptags|truncate(120) }}
  </p>

  <a slot="actions" href="{{ url }}">
    Read More &rarr;
  </a>
</wc-card>
Button (Property-Driven)

All configuration via properties. The component handles all rendering, states, and accessibility.

button.html.twig
<wc-button
  variant="primary"
  size="large"
  icon="arrow-right"
  icon-position="end"
  {% if is_disabled %}
    disabled
  {% endif %}
>
  {{ button_label }}
</wc-button>

{# Loading state example #}
<wc-button
  variant="primary"
  loading
  aria-busy="true"
>
  Submitting...
</wc-button>
Form Field (Hybrid)

Properties handle validation and state. Slots allow custom labels, descriptions, and error messages.

text-input.html.twig
<wc-text-input
  name="{{ field_name }}"
  type="{{ field_type }}"
  required
  pattern="{{ validation_pattern }}"
  maxlength="{{ max_length }}"
>
  <!-- Slots for custom content -->
  <span slot="label">
    {{ field_label }}
    {% if required %}
      <abbr title="required">*</abbr>
    {% endif %}
  </span>

  <span slot="help">
    {{ field_description }}
  </span>

  <span slot="error">
    {{ error_message }}
  </span>
</wc-text-input>