From cms-cultivator
Best practices for building Drupal Single Directory Components (SDC) with Twig covering props vs slots, attributes, include vs embed, escaping, accessibility, schema validation, and component overriding.
How this skill is triggered — by the user, by Claude, or both
Slash command
/cms-cultivator:drupal-sdc-twigThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Use this skill whenever you are creating, reviewing, or refactoring a Drupal Single Directory Component, or writing the Twig template that ships inside one. SDC has been part of Drupal core since 10.3, so assume it is available unless the user explicitly states an older version.
Use this skill whenever you are creating, reviewing, or refactoring a Drupal Single Directory Component, or writing the Twig template that ships inside one. SDC has been part of Drupal core since 10.3, so assume it is available unless the user explicitly states an older version.
Every component lives in its own folder inside a top-level components/ directory of a theme or module:
components/
└── card/
├── card.component.yml # Metadata + schema (props, slots, libraryOverrides)
├── card.twig # Template — note: .twig, NOT .html.twig
├── card.css # Auto-attached as a library asset
├── card.js # Auto-attached as a library asset
└── thumbnail.png # Optional preview image
Subdirectories are allowed (components/molecules/card/...). Components are referenced by namespace theme_or_module:component_name, e.g. my_theme:card.
Apply these rules when generating or reviewing SDC Twig code. They are derived from the official Drupal SDC docs and the SDC FAQ on drupal.org.
.twig, not .html.twigSDC templates use the bare .twig extension. This is the one place in Drupal where you do not use .html.twig. The Twig filename must match the component machine name (the folder name).
This is the most important design decision in an SDC and the source of most refactors. The rule:
{% if dismissible %}, <h{{ heading_level }}>).Stringable / RenderableInterface / MarkupInterface). Use slots when you only need to know "is this empty or not?" and then print it.If you would ever want to pass a nested component, another Twig render result, or a chunk of HTML, it must be a slot — not a prop. Props that are serialized HTML strings are an anti-pattern.
A schema (props.type: object with properties) is required for:
For a propless component, use the empty-props pattern:
props:
type: object
additionalProperties: false
properties: {}
To enforce schemas across a theme, add enforce_prop_schemas: true to theme.info.yml.
attributes on the root elementEvery SDC template automatically receives an attributes variable (a \Drupal\Core\Template\Attribute object). You must use it because Drupal core, SEO modules, accessibility modules, translation, and style utilities inject classes, lang, data-*, and ARIA attributes through it.
<div{{ attributes.addClass('card') }}>
...
</div>
Use .addClass(), .setAttribute(), .removeClass() — do not stringify and concatenate. Do not redeclare attributes in the schema; SDC adds it automatically. (You may declare body_attributes etc. as type: 'Drupal\Core\Template\Attribute' — that's a known escape hatch for passing additional Attribute objects.)
embed for slots, include() for props-onlyBoth work, but use them deliberately:
include() function — clean syntax for components with props and no markup-bearing slots. Always pass with_context = false to avoid leaking the parent context.embed tag — required when consumers need to override slots with Twig {% block %} markup, because slots are implemented as Twig blocks under the hood.{# Props only — use include() #}
{{ include('my_theme:button', {
label: 'Sign up'|t,
variant: 'primary',
}, with_context = false) }}
{# Slots — use embed #}
{% embed 'my_theme:card' with { variant: 'feature' } only %}
{% block media %}
{{ include('my_theme:image', { src: node.field_image|file_url }, with_context = false) }}
{% endblock %}
{% block body %}
<p>{{ node.body.summary }}</p>
{% endblock %}
{% endembed %}
Use only (or with_context = false) by default so the child component is isolated from the parent's variables. This makes components portable and prevents surprising regressions.
Twig will escape it. If you need to pass HTML, use a slot. Two patterns:
{# Pattern A: capture markup into a variable, pass to a slot via include() #}
{% set body %}
<p><em>Any</em> HTML stays intact.</p>
{% endset %}
{{ include('my_theme:card', { body: body }, with_context = false) }}
{# Pattern B: use embed with a block (preferred for slots) #}
{% embed 'my_theme:card' only %}
{% block body %}<p><em>Any</em> HTML stays intact.</p>{% endblock %}
{% endembed %}
Inside the component template, do not branch on the contents of a slot — only on whether it is empty:
{% if heading %}
<h{{ heading_level|default(2) }} class="card__heading">{{ heading }}</h{{ heading_level }}>
{% endif %}
{{ body }}
All UI logic (variant switching, conditional classes, ARIA states) should run off props, which are typed and predictable.
When a Card needs Buttons inside, do not hard-code {{ include('my_theme:button', ...) }} inside card.twig. Instead, expose an actions slot and let the consumer pass the buttons in. This keeps components decoupled and reusable.
{# card.twig — good #}
<article{{ attributes.addClass('card') }}>
<div class="card__body">{{ body }}</div>
{% if actions is not empty %}
<div class="card__actions">{{ actions }}</div>
{% endif %}
</article>
Each component automatically gets a library named core/components.<theme_or_module>--<component-name-with-dashes>. To attach it from a non-SDC template:
{{ attach_library('core/components.my_theme--card') }}
For extra CSS/JS and dependencies on other libraries, use libraryOverrides in the component's YAML:
libraryOverrides:
dependencies:
- core/drupal
- core/once
- core/components.my_theme--icon
js:
card.js: { attributes: { defer: true } }
For high-traffic sites, consider attaching core/components.all globally to maximize browser-cache hit rate. To override an upstream component's assets, use libraries-override in theme.info.yml.
SDC templates do not go through hook_preprocess_*. If a prop needs computed values (formatted dates, derived classes, fallback logic), compute it in the parent template/preprocess or in the render array that calls the component. Inside the SDC template, keep logic to display-only Twig (|default, |t, conditionals).
When the user asks "should this be a prop or a slot?":
replaces: 'source_module_or_theme:component-name' and ensure both components have a schema.libraries-override in theme.info.yml.Variants let one component declare multiple visual variations with shared structure. Prefer variants over duplicate components when the markup is largely the same. The current SDC variant feature was added in Drupal 11.2 — confirm version compatibility before using.
<article>, <section>, <button>, <nav>) — not generic <div> unless that is genuinely correct.attributes on the root so ARIA attributes injected by modules survive.id, aria-controls, aria-expanded, etc., so consumers can wire them correctly.heading_level prop (enum [2,3,4,5,6]) so consumers can place the component at the right level of the document outline.:focus { outline: none }.references/component.yml-cheatsheet.md — annotated schema cheatsheetreferences/twig-patterns.md — copy-pasteable patterns for common SDC Twig situationsassets/card-example/ — a complete, idiomatic card component (yml + twig)npx claudepluginhub kanopi/claude-toolbox --plugin cms-cultivatorProvides Twig template patterns, filters, theme suggestions, and component architecture for Drupal 10/11. Useful for creating or modifying Twig templates, implementing theme hooks, or building front-end components.
Twig coding standards and conventions for Craft CMS 5 templates. Covers variable naming, null handling, whitespace control, include isolation, Craft Twig helpers, and common pitfalls.
Builds and refactors Symfony UX Twig Components with handling for props, slots, anonymous components, and CVA. Provides architectural workflow, checkpoints, and risk controls.