Single Directory Components

This content was generated with the assistance of AI and should be verified against the official Drupal documentation before being relied on in production.

This sub-section’s Drupal bibliography lists the reference material consulted while preparing these pages.

A Single Directory Component (SDC) is a piece of user interface whose markup, styling, behaviour and description live in one folder: a Twig template, an optional CSS file, an optional JavaScript file, optional assets and a YAML file that declares what the component accepts. Drupal discovers the folder, builds a plugin definition and a CSS/JavaScript library for it, checks the data passed in against the declared schema and renders the Twig template. Components are the unit that a theme publishes to its templates, that a module publishes to other modules, and that Canvas offers to editors in its component library.

This page was checked against the official documentation and the Drupal 11.x source on 2026-10-30. It is written against Drupal 11.4.x, Drush 13 or 14 and PHP 8.3 or later (see Requirements and hosting for the baseline). The examples use the fictitious Meridian Museum site from the sub-section index and build three components for its meridian theme: card, hero and exhibition-teaser. Drupal 10 reaches end of life on 2026-12-09, so new sites should start on Drupal 11. Drupal 12.0.0 is planned for the week of 2026-12-07 and is not yet released; SDC is part of core in Drupal 12 as well, and the Drupal 12 notes below describe scheduled changes that should be re-checked at the final release.

Who uses components, and why

  • The themer writes components in the theme’s components/ folder and uses them from node, block and field templates.

  • The module developer ships components in a module’s components/ folder, so that every theme can render the module’s output, and builds render arrays with the component element.

  • The site builder or editor meets components only through Canvas, where each component with a valid schema becomes a draggable item with generated property forms.

  • The front-end team and designers use the YAML file as the contract between design and implementation: prop names, types, allowed values and slots are written down in one place.

Before SDC, one design element was scattered across THEME.libraries.yml (assets), a templates/ folder (markup), a css/ folder, a js/ folder and a preprocess function (data shaping). A component keeps all of this together, loads its CSS and JavaScript only on pages where it is rendered, and fails loudly in development when it is called with the wrong data.

Version history

  • Drupal 10.1 shipped SDC as an experimental module named sdc in core.

  • Drupal 10.3 made SDC part of the core render system. There is no sdc module to enable; on 10.3 and later the official guide states that components work out of the box, and on 10.2 and earlier the module had to be enabled by hand.

  • Drupal 11.2 is the first release for which the official guide documents the variants key for the component render element, and Drupal 11.3 is the first for which it documents #attributes on that element.

  • Drupal 11.4 is current; the component API (the ID format, the plugin.manager.sdc service and the component render element) is documented as stable public API, while the JSON file that validates metadata is internal.

  • Drupal 12 keeps SDC. It removes Claro, Olivero and Stable 9 from core, so a theme whose components extend a library of one of those themes has to be reviewed (see Preparing for Drupal 12).

Older books and tutorials describe a different situation. Modernizing Drupal 10 Theme Development (2023) and Drupal 10 Masterclass (2023) target Drupal 10.0 and 10.1, where SDC was an experimental module: they tell the reader to enable sdc, refer to library names that begin with sdc/, and point the $schema key at a path under core/modules/sdc. In current Drupal there is no module to enable, the generated libraries are named core/components.<provider>--<component> and the schema lives under core/assets/schemas/v1/. The experimental label also disappeared: SDC is the recommended way to build reusable theme output.

Anatomy of a component

A component folder holds a component.yml file, a Twig template, optional CSS and JavaScript and assets; core discovers it, builds a library, validates props and renders the template into markup plus attached assets

Where components live

Components are found in a folder named components at the top of any installed theme or module. Sub-folders are allowed for organisation (components/atoms/button/). The provider is the machine name of the extension that owns the folder, and the component ID is provider:component, for example meridian:card. The ID is public API: Twig and PHP code refer to a component only by it.

themes/custom/meridian/
  meridian.info.yml
  meridian.libraries.yml
  components/
    card/
      card.component.yml     # metadata: required
      card.twig              # markup: required, must end in .twig, not .html.twig
      card.css               # attached automatically when the component renders
      card.js                # attached automatically when the component renders
      thumbnail.png          # preview used by tools such as Canvas
      README.md              # documentation for other developers
      assets/
        placeholder.svg
    hero/
      hero.component.yml
      hero.twig
      hero.css
    exhibition-teaser/
      exhibition-teaser.component.yml
      exhibition-teaser.twig
      exhibition-teaser.css
modules/custom/meridian_collection/
  components/
    object-label/
      object-label.component.yml
      object-label.twig

The rules that make this layout work:

  • The component’s machine name is the name of its YAML file without .component.yml. By convention the folder, the YAML file, the Twig file, the CSS file and the JavaScript file all share that name. A CSS or JavaScript file with the same name is attached with no configuration; anything else must be listed under libraryOverrides.

  • The Twig file extension is .twig. Naming it card.html.twig is a frequent mistake and the template is then not found.

  • A folder with a YAML file but a name that does not match the Twig file produces a "template not found" error at render time rather than at discovery.

  • Names with a hyphen (exhibition-teaser) are fine for the component; prop names and slot names become Twig variables and so follow variable rules (see Slots).

What core does with the folder

Core defines the service plugin.manager.sdc, implemented by Drupal\Core\Theme\ComponentPluginManager. It scans every enabled theme and module for *.component.yml files, caches the resulting definitions in the component_plugins cache bin and, for each component:

  • creates a plugin definition (ID, label, group, status, schema, slots, variants) from the metadata

  • generates an anonymous library whose name is core/components.provider--component, containing the same-named CSS and JavaScript plus whatever libraryOverrides adds, and makes core/drupal a dependency when overrides are present

  • registers a Twig node visitor that attaches the library each time the component is rendered

  • checks, at definition time, that props and slots do not share a name, and, once replaces is in play, that a replacing component has a compatible schema

The plugin manager deliberately has no alter hook for component definitions: everything about a component has to be in its directory, and the supported way of changing another extension’s component is the replaces key. A cache rebuild (ddev drush cr) is needed after adding a component or changing its YAML, because definitions are cached.

The component.yml file

The metadata file is YAML. Editors that understand JSON Schema complete and validate its keys when the file starts with the $schema line. The path used by current core is under core/assets/schemas/v1/ (older material shows a path under core/modules/sdc); the key is a hint for editors and does not affect rendering.

# themes/custom/meridian/components/card/card.component.yml
$schema: https://git.drupalcode.org/project/drupal/-/raw/HEAD/core/assets/schemas/v1/metadata.schema.json
name: Card
description: A framed block with optional media, a heading that can link, body text and actions.
status: stable
group: Meridian
props:
  type: object
  required:
    - title
  properties:
    title:
      type: string
      title: Title
      examples:
        - Deep Sea Light
    eyebrow:
      type: string
      title: Eyebrow
      description: Short label above the title, such as a gallery name.
    url:
      type: string
      format: uri-reference
      title: Link
      description: Where the title links to. Leave empty for a card that is not a link.
    heading_level:
      type: integer
      title: Heading level
      enum: [2, 3, 4]
      default: 3
slots:
  media:
    title: Media
    description: An image or video shown above the content.
  body:
    title: Body
  actions:
    title: Actions
    description: Buttons or links placed at the bottom.
libraryOverrides:
  dependencies:
    - core/once

The keys

Key Meaning

$schema

URL of the JSON Schema that describes the file itself. Optional; used by editors.

name

Human-readable name. When it is missing, core derives one from the machine name. Canvas and other tools show it.

description

Human-readable summary, shown in tools.

status

One of experimental, stable, deprecated or obsolete. A missing or invalid value is treated as stable. Tools use it to hide or warn about components that should not be used in new work.

group

A label used to group components in listings; Canvas shows it as a section of its library. A component without one is listed under a default "All Components" or "Other" group depending on the tool.

noUi

Boolean. When true, the component is meant for developers only and is excluded from the user interface of tools such as Canvas.

props

A JSON Schema object that describes the data the component accepts (see Props).

slots

Named regions for free-form content (see Slots).

variants

Named presentational variations (see Variants).

tags

Free labels for tooling.

libraryOverrides

Replaces or extends parts of the generated library (see libraryOverrides).

replaces

provider:component of a component this one takes over. Themes only (see Replacing a component of another extension).

thirdPartySettings

Free-form settings that other modules read, keyed by module name.

Two details are worth stating plainly. First, the schema file shipped in core describes $schema, name, description, status, noUi, props, slots, variants, tags, libraryOverrides and thirdPartySettings; group and replaces are not listed in it but are read by the plugin manager, which is why a strict editor may underline them. The official guide marks the schema file as internal, not as a contract. Second, the status of a component is advice to people and tools, not a switch: core still renders a component marked obsolete.

Props

Props are strictly typed data. They are declared in the props key as a standard JSON Schema object and become Twig variables of the same name inside the component’s template.

props:
  type: object
  required:
    - heading
  properties:
    heading:
      type: string
    count:
      type: integer
      minimum: 0
    featured:
      type: boolean
    layout:
      type: string
      enum: [stacked, side-by-side]
      meta:enum:
        stacked: Stacked
        side-by-side: Side by side
    image:
      type: object
      properties:
        src:
          type: string
        alt:
          type: string
      required: [src, alt]

What to know about props:

  • Types. JSON Schema types map to PHP values: string, number, integer, boolean, null, object (an associative array) and array (a list). A prop can declare enum, title, description, examples, default and the usual JSON Schema constraints. Core also understands meta:enum (labels for the values of an enum, used by tools) and x-translation-context.

  • Additional properties are not allowed. Core forces additionalProperties: false on the props object, and rejects a schema that sets it to true. Passing a prop that is not declared is therefore an error when validation is on.

  • The key must be present. The official guide says that props has to be present even for a component with none, and shows an object schema with additionalProperties: false and no properties. In the source, a missing props key is an error for components that live in modules, and for components of a theme only when that theme sets enforce_prop_schemas: true in its .info.yml. Write the key in every component; Canvas also requires it.

  • default is documentation, not a runtime default. Twig variables are not filled in from the schema. Use {{ heading_level|default(3) }} in the template.

  • Required props need examples for Canvas. A required prop without examples makes the component unusable in Canvas (see Canvas and visual page building).

  • Props are data, not markup. Pass strings, numbers, booleans and small structures. Markup belongs in slots.

  • The attributes variable is used in the template without being declared as a prop; it is an Attribute object and is covered in The attributes variable.

To make the schema mandatory for every component of a theme, set the key in the theme’s info file.

# themes/custom/meridian/meridian.info.yml
name: Meridian
type: theme
base theme: false
description: Theme of the Meridian Museum site, built from the Starterkit.
core_version_requirement: ^11.3
enforce_prop_schemas: true
libraries:
  - meridian/global
regions:
  header: Header
  content: Content
  footer: Footer

Slots

Slots are areas for free-form content: render arrays, other components, Twig blocks or plain HTML. Because their content has no fixed structure, they are not checked against a schema; the template can only test whether a slot is empty. A slot is declared by name, and can have a title and description:

slots:
  media:
    title: Media
  body: {}

Rules for slots:

  • A slot name and a prop name must not be equal; core reports the collision when it loads the definition.

  • Slot names follow the pattern of a Twig variable (letters, digits and underscores, not starting with a digit), so a slot cannot be called card-body.

  • Inside the template a slot is a variable, printed with {{ body }}. Wrap it in a {% block body %} to let callers fill it with {% embed %} (see Filling slots with embed).

  • The slots schema allows the optional keys examples, expected, minItems and maxItems, which Canvas reads to decide what can be dropped into the slot.

Variants

A variant is a named alternative presentation, for example the compact and default versions of a hero. Declare them under variants; each needs a title.

variants:
  default:
    title: Default
  compact:
    title: Compact
    description: Shorter banner for inner pages.

The chosen variant reaches the template as the variable variant. Core adds it to the props when a render array carries one, and when Twig debugging is on the generated HTML comment names the variant that was used. Variant names may contain dashes and underscores. Use variants for what changes appearance only; if the data changes, use props.

libraryOverrides

The generated library contains the same-named CSS and JavaScript. libraryOverrides accepts the keys of a normal library entry (css, js, dependencies, header, and so on), with paths relative to the component folder. The merge is shallow: if you set a key, you replace everything core generated under that key, so list the component’s own file as well.

# themes/custom/meridian/components/hero/hero.component.yml (excerpt)
libraryOverrides:
  dependencies:
    - core/once
    - core/drupalSettings
  css:
    component:
      hero.css: {}
      assets/hero-print.css: { media: print }
  js:
    hero.js: { attributes: { defer: true } }

Notes:

  • When libraryOverrides is present, core appends core/drupal to the dependencies, so JavaScript behaviours are attached. When it is absent, a JavaScript file that uses Drupal.behaviors or once() still works only if those libraries are on the page for another reason; declare core/once and core/drupal explicitly.

  • External files (URLs) are left as written; local files are rewritten relative to the library root.

  • CSS goes under the SMACSS categories base, layout, component, state and theme, as in Libraries and assets.

  • Assets that several components share (resets, typography, design tokens) still belong to a theme library listed in the .info.yml; only the component’s own parts belong in the component.

Replacing a component of another extension

A theme can take over a component provided by a module or by another theme with the replaces key. The value is the ID of the component being replaced.

# themes/custom/meridian/components/object-label/object-label.component.yml
$schema: https://git.drupalcode.org/project/drupal/-/raw/HEAD/core/assets/schemas/v1/metadata.schema.json
name: Object label
status: stable
replaces: 'meridian_collection:object-label'
props:
  type: object
  properties:
    accession_number:
      type: string
    title:
      type: string
slots: {}

The conditions are strict:

  • Only a theme can replace a component; a module cannot.

  • Both components need a props schema, and the schemas must be compatible: the replacement has to accept what the original accepts, and its slots must match. Core checks this when it builds the definitions and fails with a message that names both components when it does not hold.

  • Everything that calls meridian_collection:object-label now renders the theme’s version when the replacing theme is active, with no change in the caller. That makes replaces the right tool for per-theme customisation of a module’s output, in place of an alter hook (the component plugin manager has none).

  • Replacement is resolved per request from the active theme; a replacing component in a theme that is installed but not active is ignored.

Rendering a component

There are three ways to render a component. All of them refer to it by provider:component.

Twig include

Use include() when the component needs data only, or when its slots are printed from Twig variables. Pass with_context = false so the caller’s variables do not leak in:

{# themes/custom/meridian/templates/content/node--exhibition--teaser.html.twig #}
{% set dates = node.field_dates.value|date('j M Y') ~ ' to ' ~ node.field_dates.end_value|date('j M Y') %}
{{ include('meridian:exhibition-teaser', {
  title: label,
  link: url,
  kicker: 'Now open',
  dates: dates,
}, with_context = false) }}

In node templates, slot data usually comes from content (rendered fields) and prop data from node (raw field values). Shape anything more complex than this in a preprocess function (see Template suggestions and preprocess).

Filling slots with embed

Use {% embed %} when a slot is a block of Twig. The embedded component must wrap the slot in {% block name %} in its own template. only keeps the caller’s variables out of the component; without it the component sees the whole context, and with it every value used inside a block has to be passed in explicitly.

{% embed 'meridian:card' with { title: label, url: url, eyebrow: 'Collection' } only %}
  {% block body %}
    <p>{{ summary }}</p>
  {% endblock %}
  {% block actions %}
    <a class="button" href="{{ url }}">View object</a>
  {% endblock %}
{% endembed %}

In the example, summary and url work inside the blocks only if they were handed to the embed (url is, summary is not); with only an undefined variable renders as nothing, which is a classic way to lose content silently. Add summary to the with hash.

The component render element

Modules, controllers, blocks and preprocess functions build render arrays, and a component is one more render element: '#type' => 'component'. #component is required; #props and #slots are optional.

<?php

namespace Drupal\meridian_collection\Controller;

use Drupal\Core\Controller\ControllerBase;

/**
 * Landing page for the exhibitions section.
 */
final class ExhibitionsLandingController extends ControllerBase {

  /**
   * Builds the page.
   */
  public function __invoke(): array {
    return [
      'hero' => [
        '#type' => 'component',
        '#component' => 'meridian:hero',
        '#variant' => 'compact',
        '#props' => [
          'heading' => $this->t('Exhibitions'),
          'subheading' => $this->t('What is on at the Meridian Museum'),
          'image' => [
            'src' => '/themes/custom/meridian/images/exhibitions-hero.jpg',
            'alt' => $this->t('Visitors in the main hall'),
          ],
        ],
        '#slots' => [
          'actions' => [
            '#type' => 'link',
            '#title' => $this->t('Plan your visit'),
            '#url' => \Drupal\Core\Url::fromRoute('entity.node.canonical', ['node' => 1]),
          ],
        ],
      ],
    ];
  }

}

Points about the element:

  • Slot values must be render arrays or scalars. Anything else raises an exception whose message names the component and the slot. Child elements whose key is a slot name are also treated as slots.

  • Variants. The element reads the variant from the render array and passes it to the template as the prop variant. The official guide lists the variants key as available from Drupal 11.2, and the 11.x element documents the property as #variant; check the property name against the version you run.

  • Attributes. The universal #attributes property is merged into the attributes variable of the component; the official guide lists this from Drupal 11.3. When both #attributes and #props['attributes'] are given, the values from the props win.

  • Callbacks. #propsAlter and #slotsAlter take trusted callbacks (see TrustedCallbackInterface) that run before the component renders, for example to replace tokens in props.

  • Caching. Cache metadata of render arrays placed in slots bubbles up as usual (see Performance and caching); data passed through props must carry its own cache tags if it depends on entities.

The service plugin.manager.sdc can also be used directly (find('meridian:card'), getAllComponents()), for example to build a style guide page that lists all components with their metadata.

The attributes variable

Components use an attributes variable, an Attribute object. Printing it on the root element lets callers add classes, IDs and data attributes without the component knowing:

<div{{ attributes.addClass('alert').setAttribute('role', 'alert') }}>
  {% block message %}{{ message }}{% endblock %}
</div>

The official example prints attributes without declaring it as a prop. From Twig, pass attributes as a variable of that name (an Attribute object). Always keep attributes on one element only; printing it twice duplicates IDs.

Building the Meridian components

The Meridian theme needs a card for objects and news, a hero for the top of landing pages, and a teaser for exhibitions that is built from the card. The three files of each component follow.

card

The card.component.yml shown earlier declares the props title, eyebrow, url and heading_level and the slots media, body and actions. The template:

{# themes/custom/meridian/components/card/card.twig #}
{% set heading_tag = 'h' ~ (heading_level|default(3)) %}
<article{{ attributes.addClass('card') }}>
  <div class="card__media">{% block media %}{{ media }}{% endblock %}</div>
  <div class="card__content">
    {% if eyebrow %}
      <p class="card__eyebrow">{{ eyebrow }}</p>
    {% endif %}
    <{{ heading_tag }} class="card__title">
      {% if url %}
        <a class="card__link" href="{{ url }}">{{ title }}</a>
      {% else %}
        {{ title }}
      {% endif %}
    </{{ heading_tag }}>
    <div class="card__body">{% block body %}{{ body }}{% endblock %}</div>
    <div class="card__actions">{% block actions %}{{ actions }}{% endblock %}</div>
  </div>
</article>
/* themes/custom/meridian/components/card/card.css */
.card {
  display: flex;
  flex-direction: column;
  border: 1px solid var(--meridian-border, #c9d1d9);
  border-radius: 0.5rem;
  background: var(--meridian-surface, #ffffff);
}
.card__content { padding: 1rem; }
.card__eyebrow {
  margin: 0 0 0.25rem;
  font-size: 0.75rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
}
.card__title { margin: 0 0 0.5rem; }
.card__link:focus-visible { outline: 3px solid currentColor; outline-offset: 2px; }
// themes/custom/meridian/components/card/card.js
((Drupal, once) => {
  Drupal.behaviors.meridianCard = {
    attach(context) {
      // Make the whole card clickable without nesting links.
      once('meridian-card', '.card', context).forEach((card) => {
        const link = card.querySelector('.card__link');
        if (!link) {
          return;
        }
        card.addEventListener('click', (event) => {
          if (!event.target.closest('a, button')) {
            link.click();
          }
        });
      });
    },
  };
})(Drupal, once);

Design choices in the template: each slot is wrapped in a block that sits outside any if, because a block inside a false condition is never rendered and an embed that fills it would silently produce nothing. Empty wrappers are acceptable in the markup; if they affect layout, hide them with :empty in CSS. The behaviour uses once() with a context, so it runs once per card even when Ajax or Canvas inserts cards later (see Libraries and assets).

hero

# themes/custom/meridian/components/hero/hero.component.yml
$schema: https://git.drupalcode.org/project/drupal/-/raw/HEAD/core/assets/schemas/v1/metadata.schema.json
name: Hero
description: Large banner with an image, a heading and optional actions.
status: stable
group: Meridian
props:
  type: object
  required:
    - heading
    - image
  properties:
    heading:
      type: string
      title: Heading
      examples:
        - Welcome to the Meridian Museum
    subheading:
      type: string
      title: Subheading
    image:
      type: object
      title: Image
      required: [src, alt]
      properties:
        src:
          type: string
          title: Image URL
        alt:
          type: string
          title: Alternative text
      examples:
        - src: /themes/custom/meridian/images/hero.jpg
          alt: Visitors in the main hall
variants:
  default:
    title: Default
  compact:
    title: Compact
slots:
  actions:
    title: Actions
libraryOverrides:
  dependencies:
    - core/once
{# themes/custom/meridian/components/hero/hero.twig #}
{% set variant = variant|default('default') %}
<section{{ attributes.addClass('hero', 'hero--' ~ variant) }}>
  <img class="hero__image" src="{{ image.src }}" alt="{{ image.alt }}" loading="eager" fetchpriority="high">
  <div class="hero__content">
    <h1 class="hero__heading">{{ heading }}</h1>
    {% if subheading %}
      <p class="hero__subheading">{{ subheading }}</p>
    {% endif %}
    <div class="hero__actions">{% block actions %}{{ actions }}{% endblock %}</div>
  </div>
</section>

The image prop is an object with a mandatory alt, which makes it impossible to call the hero with an image and no text alternative when validation is on. If the image is decorative, add a boolean prop decorative and print alt="". Use a responsive image style for real photos (see Breakpoints and responsive images) and keep only one h1 per page.

exhibition-teaser

The teaser composes the card: a component can include or embed other components, and the library of each one is attached when it renders.

# themes/custom/meridian/components/exhibition-teaser/exhibition-teaser.component.yml
$schema: https://git.drupalcode.org/project/drupal/-/raw/HEAD/core/assets/schemas/v1/metadata.schema.json
name: Exhibition teaser
description: A card announcing an exhibition with its dates and a link.
status: stable
group: Meridian
props:
  type: object
  required:
    - title
    - link
  properties:
    title:
      type: string
      title: Title
      examples:
        - Deep Sea Light
    kicker:
      type: string
      title: Kicker
      examples:
        - Now open
    dates:
      type: string
      title: Dates
      examples:
        - 12 March to 30 June
    link:
      type: string
      format: uri-reference
      title: Link
      examples:
        - /exhibitions/deep-sea-light
slots:
  media:
    title: Image
  actions:
    title: Actions
{# themes/custom/meridian/components/exhibition-teaser/exhibition-teaser.twig #}
{% embed 'meridian:card' with {
  title: title,
  eyebrow: kicker,
  url: link,
  heading_level: 3,
  attributes: attributes.addClass('exhibition-teaser'),
  media: media,
  actions: actions,
  dates: dates,
} only %}
  {% block body %}
    {% if dates %}<p class="exhibition-teaser__dates">{{ dates }}</p>{% endif %}
  {% endblock %}
{% endembed %}

Because the teaser embeds the card with only, everything the card needs (media, actions, dates and the extra class) is passed explicitly. The teaser is the component that Canvas lists in its library for the Meridian site; the examples on the required props are what Canvas uses to preview it.

Validation

Core validates twice, and both checks are intended for development.

  1. The definition (the YAML file) is checked when components are loaded: props and slots must not share names, the props object must be of type object, additional properties must not be allowed, and replacements must be compatible.

  2. The data (the props passed at render time) is checked against the schema.

The official guide describes validation as a development aid. To switch it on in a local environment:

ddev composer require --dev drupal/core-dev
<?php

// web/sites/default/settings.local.php (local development only)
ini_set('zend.assertions', 1);
ini_set('assert.exception', 1);

drupal/core-dev brings in the JSON Schema validator library that core uses when it is available. With assertions on, a wrong prop type, a missing required prop or an undeclared prop throws an InvalidComponentException that names the component and the failing property, instead of rendering a broken or empty element. In production leave both settings off (the recommended production value of zend.assertions is -1) so that a bad value cannot take a page down, and treat the schema as development-time documentation. The theme key enforce_prop_schemas makes the presence of a schema mandatory for a theme; it does not turn runtime validation on.

Component libraries

A component library is a set of components shared by several sites or themes. There are three ways to organise one:

  • In a base or starter theme. The theme owns generic components (button, card, hero) and sub-themes inherit them: components of a base theme are discovered even when a sub-theme is active (see Starterkit and subthemes).

  • In a module. A module such as meridian_components, or meridian_collection for its own output, ships components that work with any theme. The schema is mandatory here, which makes module components safer to reuse.

  • In a distribution or site template. Drupal CMS pairs Canvas with a theme that carries components; a project can follow the same pattern (see Drupal CMS and site templates).

Any project can then customise a library without forking it: a theme replaces a module’s or a base theme’s component with replaces, and passes extra classes through attributes. For previews outside Drupal, community tooling renders components in a Storybook-like gallery. Older books (Modernizing Drupal 10 Theme Development) show Storybook with Twig.js; that renders templates outside Drupal, so Drupal-specific Twig functions and the provider:component IDs need stand-ins. Check the current state of the community tools before choosing one, and see Front-end tooling for build tooling.

Components in Canvas

Canvas treats SDCs as one of its component sources. A component appears in the library when it is enabled and valid under Canvas’s rules: props present, every required prop has examples, and slots declared. The group decides the section of the library, name the label and noUi: true hides the component. Canvas builds the property form of each component from the prop schemas, using title, enum and meta:enum for labels. An SDC needs no change to work in Canvas, which is the reason to write components this way from the start. The details, including the troubleshooting list for a component that does not show up, are in Canvas and visual page building.

Layout Builder, the other page builder, can place components too, but only through blocks or field formatters that render them. Layout Builder stays in core; see Layout Builder.

Security

  • Autoescaping. Twig escapes every {{ }} output. A string prop is safe by default. Do not add |raw to a prop or slot that can hold user input; a slot filled from a render array already carries sanitised markup.

  • URLs. Autoescaping does not remove dangerous URL schemes, so a prop printed in href or src can still carry javascript:. Build links with Url objects or run user-supplied URLs through UrlHelper::stripDangerousProtocols() in preprocess code before passing them as props. The schema format: uri-reference is documentation, not a filter.

  • Attributes. The Attribute object escapes attribute names and values; building attributes with string concatenation loses this.

  • Third-party code. A component’s JavaScript is code that runs on the page: review components taken from other projects as you would review a module.

  • Secrets. Props reach the page as HTML. Never pass tokens, keys or internal URLs through a component.

Common errors and pitfalls

Symptom Cause and fix

ComponentNotFoundException: unable to find component "meridian:card"

The cache has not been rebuilt, the folder is not named components, the YAML file name does not end in .component.yml, the provider is not an enabled theme or module, or the ID uses the folder name instead of the YAML file name. Run ddev drush cr and check the ID.

The template is not found

The file is named card.html.twig instead of card.twig, or its name differs from the YAML file.

CSS or JavaScript is not loaded

The file name differs from the component name, or libraryOverrides.css or js replaced the generated entry without listing it. Aggregation can hide a change: rebuild caches.

An invalid component exception about additional properties

A prop that is not declared in the schema was passed; declare it or stop passing it. A schema that allows additional properties is itself rejected.

A required prop is "missing" on a node teaser

The Twig include passed content.field_x (a render array) where the schema expects a string. Pass raw values from node.field_x.value, or shape the data in preprocess.

The slot is empty when filled with {% embed %}

The component printed the slot inside an if that tests the (unset) variable, or only removed a variable that the block uses. Put the block outside the condition and pass what the block needs.

The component does not appear in Canvas

props missing, a required prop without examples, noUi: true, or caches not rebuilt after the change.

replaces is ignored or fails

A module tried to replace a component (only themes can), the replacing theme is not active, or the schemas are incompatible.

Duplicated IDs or classes

attributes printed on more than one element, or the same component rendered twice with a fixed id.

The old sdc module is listed as a dependency

Tutorials for Drupal 10.1 and 10.2 enable it. On 10.3 and later there is no such module; remove the dependency.

Checklist

  • One folder per component with a matching YAML file and Twig file; the Twig file ends in .twig.

  • props always present, typed, with examples on required props; no markup in props.

  • Slots wrapped in blocks outside any condition, so embed works.

  • attributes printed once, on the root element.

  • CSS and JavaScript named after the component; shared assets in a theme library; behaviours use once().

  • status, group and thumbnail.png set for components that editors see.

  • Validation enabled locally with drupal/core-dev and assertions; off in production.

  • Cache rebuilt after every metadata change.