Skip to content

TutorialFor theme developersDrupal 11

Build your first theme 4: a card component with props and slots

In part 4 of the theme tutorial you build a card as a single-directory component (SDC): one folder holding the component’s metadata, template, and CSS, usable from any template in the theme by name.

A components/card/ directory containing three files:

  • card.component.yml, the metadata: typed props and named slots, validated against core’s component schema,
  • card.twig, the markup, with each slot as a Twig block,
  • card.css, styles that Drupal attaches automatically whenever the card renders.

Then you rewrite node--teaser.html.twig from part 2 to render every teaser through the card.

Single-directory components are part of Drupal core proper; the component plugin manager, ComponentPluginManager, discovers a components/ directory in any theme or module. (You may find an sdc module from the Drupal 10 experimental phase mentioned elsewhere; it is obsolete and there is nothing to install.)

  • First theme as part 3 left it, with the first_theme/global library loading.

Create components/card/card.component.yml:

web/themes/custom/first_theme/components/card/card.component.yml
# This $schema line gives IDEs autocomplete for this file's syntax.
$schema: https://git.drupalcode.org/project/drupal/-/raw/HEAD/core/assets/schemas/v1/metadata.schema.json
name: Card
status: stable
description: 'A content card with an optional collapsible body.'
# Props are typed values, validated against JSON Schema.
props:
type: object
properties:
attributes:
type: Drupal\Core\Template\Attribute
title: Attributes
description: Wrapper attributes.
heading_level:
type: integer
title: 'Heading level'
description: 'Which <h*> element to use for the card heading.'
enum: [2, 3, 4]
default: 2
collapsible:
type: boolean
title: Collapsible
description: 'Let visitors collapse the card body (uses the card behavior).'
default: false
# Slots hold arbitrary markup. In card.twig each slot is a {% block %}.
slots:
heading:
title: Heading
required: true
description: 'The card heading text or link.'
content:
title: Content
required: true
description: 'The card body.'
footer:
title: Footer
description: 'Optional footer, for metadata or links.'

This file is the component’s contract, and everything else hangs off it:

  • The directory name and the file name before .component.yml must agree; together with the providing theme they form the component ID, first_theme:card.
  • props is a JSON Schema object. heading_level is an integer restricted by enum to 2, 3, or 4; collapsible is a boolean. Pass a string "3" where an integer is declared and validation fails loudly in development instead of rendering something subtly wrong.
  • The attributes prop is typed by a PHP class, Attribute, rather than a JSON primitive. Prop types can name any class or interface.
  • slots declare the holes for arbitrary markup: heading and content are required, footer is optional. Props are typed values; slots are blocks of renderable content. That distinction is the heart of the format.
  • The $schema line points editors and IDEs at core’s metadata schema (core/assets/schemas/v1/metadata.schema.json) for autocomplete and inline validation while you type.

Schema validation is enforced by core’s ComponentValidator. For components in modules, a props schema is mandatory. For components in themes it is optional unless the theme opts in by setting enforce_prop_schemas: true in its info file; ship one anyway, as this card does, because it is the difference between a documented, checkable contract and a guess.

Create components/card/card.twig:

web/themes/custom/first_theme/components/card/card.twig
{#
/**
* @file
* The card single-directory component.
*
* card.css (and any card.js) next to this file is attached automatically
* whenever the component is rendered, with no libraries.yml entry needed.
*
* Render it from any template:
* {% include 'first_theme:card' with { heading: label, content: body } %}
* or fill the slots with {% embed %} blocks (see node--teaser.html.twig).
*/
#}
{% set heading_level = heading_level ?? 2 %}
<article{{ attributes.addClass('card', collapsible ? 'card--collapsible') }}>
<h{{ heading_level }} class="card__heading">
{% block heading %}{{ heading }}{% endblock %}
</h{{ heading_level }}>
<div class="card__content">
{% block content %}{{ content }}{% endblock %}
</div>
{% block footer %}{{ footer }}{% endblock %}
</article>

Each slot from the YAML appears as a {% block %} with the same name, printing the variable of that name unless a caller overrides the block. Props arrive as ordinary Twig variables; heading_level chooses the heading element, and collapsible toggles a modifier class through addClass()’s conditional form. The attributes variable is always safe to use here: when the caller does not pass one, Drupal creates an Attribute object for the component and merges in a data-component-id attribute identifying it, which is also how you will spot the card in the page source.

Create components/card/card.css:

web/themes/custom/first_theme/components/card/card.css
/**
* Styles for the card component.
*
* Loaded automatically with the component because it sits next to
* card.twig and shares its name.
*/
.card {
padding: 1rem 1.25rem;
border: 1px solid #d4d4d8;
border-radius: 0.5rem;
background-color: #fff;
}
.card + .card {
margin-block-start: 1rem;
}
.card__heading {
margin-block: 0 0.5rem;
font-size: 1.25rem;
line-height: 1.3;
}
.card--collapsible .card__heading {
cursor: pointer;
}
.card--collapsible .card__heading::after {
content: " \25BE"; /* Small down triangle. */
}
.card.is-collapsed .card__content {
display: none;
}

There is no libraries.yml entry for this file and there never will be: the component plugin manager finds assets named after the component (card.css, and card.js if you add one) next to card.twig and registers them as an auto-generated library, attached only on pages where the card actually renders. The .card--collapsible and .is-collapsed rules style states that nothing toggles yet; the JavaScript arrives in part 5.

Replace the contents of templates/node--teaser.html.twig with the final version:

web/themes/custom/first_theme/templates/node--teaser.html.twig
{#
/**
* @file
* Teaser display of a node, rendered with the card component.
*
* This file demonstrates template suggestions: node--teaser.html.twig is
* more specific than node.html.twig, so Drupal uses it whenever a node is
* rendered in the "teaser" view mode. Enable Twig debug mode to see every
* suggestion as an HTML comment in the page source.
*
* It fills the slots of the card single-directory component defined in
* components/card/.
*/
#}
{# With "only", the blocks below see just the variables listed here. #}
{% embed 'first_theme:card' with {
attributes,
heading_level: 3,
label,
url,
content,
title_prefix,
title_suffix,
} only %}
{% block heading %}
<a href="{{ url }}" rel="bookmark">{{ label }}</a>
{% endblock %}
{% block content %}
{# Modules print contextual links and similar output here. #}
{{ title_prefix }}
{{ title_suffix }}
{{ content }}
{% endblock %}
{% endembed %}

{% embed %} is how a template fills a component’s slots: it references the component by ID and provides a {% block %} for each slot it wants to fill. The footer slot is optional and omitted. Two details matter:

  • with { ... } only passes the props, plus the node variables the blocks print, and cuts the embedded template off from everything else. The blocks render inside the component’s context, so a variable left off the list prints empty. Components stay predictable when everything they receive is visible at the call site.
  • title_prefix and title_suffix carry module output tied to the title, such as contextual links. Every node template prints them, so the card keeps them in its body.
  • When plain values are enough, include passes slots as variables: {% include 'first_theme:card' with { heading: label, content: content } %}. embed is for filling slots with markup.

Rebuild caches and reload the front page.

ComponentNotFoundException when the page renders

Section titled “ComponentNotFoundException when the page renders”

The ID in {% embed %} did not match a discovered component. Check the three names that must agree: the directory (components/card/), the metadata file (card.component.yml), and the reference (first_theme:card). Then rebuild caches; component discovery is cached.

“The component first_theme:card does not provide schema information”

Section titled ““The component first_theme:card does not provide schema information””

This error appears when schemas are enforced (always for module components, or with enforce_prop_schemas: true in a theme) and the component declares props without a props schema. Add the schema, as in step 1.

InvalidComponentException about a prop value

Section titled “InvalidComponentException about a prop value”

A caller passed a value that violates the schema, such as heading_level: 5 against the enum. Note that prop validation runs through PHP assertions, which are enabled in development environments and disabled in production, so catch these locally: fix the call site, not the schema.

The auto-generated component library attaches when the component renders, so a hard refresh after a cache rebuild normally fixes it. If not, confirm the CSS file is named exactly card.css, next to card.twig; asset auto-discovery matches on the component’s machine name.

Was this page helpful?

Drupal is a registered trademark of Dries Buytaert.