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.
What you’ll build
Section titled “What you’ll build”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.)
Prerequisites
Section titled “Prerequisites”- First theme as part 3 left it, with the
first_theme/globallibrary loading.
Step 1: Describe the component
Section titled “Step 1: Describe the component”Create 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.jsonname: Cardstatus: stabledescription: '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.ymlmust agree; together with the providing theme they form the component ID,first_theme:card. propsis a JSON Schema object.heading_levelis an integer restricted byenumto 2, 3, or 4;collapsibleis a boolean. Pass a string"3"where an integer is declared and validation fails loudly in development instead of rendering something subtly wrong.- The
attributesprop is typed by a PHP class,Attribute, rather than a JSON primitive. Prop types can name any class or interface. slotsdeclare the holes for arbitrary markup:headingandcontentare required,footeris optional. Props are typed values; slots are blocks of renderable content. That distinction is the heart of the format.- The
$schemaline 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.
Step 2: Write the template
Section titled “Step 2: Write the template”Create 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.
Step 3: Style it
Section titled “Step 3: Style it”Create 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.
Step 4: Render teasers through the card
Section titled “Step 4: Render teasers through the card”Replace the contents of templates/node--teaser.html.twig with the final version:
{#/** * @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 { ... } onlypasses 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_prefixandtitle_suffixcarry 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,
includepasses slots as variables:{% include 'first_theme:card' with { heading: label, content: content } %}.embedis for filling slots with markup.
Rebuild caches and reload the front page.
Troubleshooting
Section titled “Troubleshooting”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 card renders but has no styling
Section titled “The card renders but has no styling”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.
Next steps
Section titled “Next steps”- Part 5: JavaScript behaviors with once() makes the collapsible cards collapse.
- Single-directory components covers variants,
libraryOverrides, and rendering components from render arrays and modules. - Twig in Drupal for the
embed,include, andblockmechanics this part leaned on.
Was this page helpful?
Drupal is a registered trademark of Dries Buytaert.