Creating a component
Each widget lives in its own folder under components/. Drupal discovers it because it contains <name>.component.yml (theme components are namespaced by the theme machine name: react_scaffold:node-list).
components/
apiClient.js common.js helpers.jsx # shared entries, not components
recipe-explorer/
recipe-explorer.component.yml # SDC metadata: props, slots, library
recipe-explorer.twig # server markup: the mount point
index.jsx # entry: Drupal behavior that mounts React
RecipeExplorer.jsx # the React component
recipe-explorer.scss # imported from index.jsx
recipe-explorer.story.twig # optional, for Storybook
__tests__/recipe-explorer.test.js # optional, jestThe three files that make a component
*.component.yml
$schema: https://git.drupalcode.org/project/drupal/-/raw/HEAD/core/assets/schemas/v1/metadata.schema.json
name: Recipe Explorer
status: stable
group: Content
props:
type: object
properties:
endpoint:
type: string
default: '/api/recipes'
heading:
type: string
default: 'Recipes'
required:
- endpoint
libraryOverrides:
js:
../../assets/recipe-explorer.js: { attributes: { type: module } }
css:
theme:
../../assets/recipe-explorer.css: { }
dependencies:
- react_scaffold/react
- react_scaffold/react-api-clientpropsare validated by core when the component renders. Wrong type or a missing required prop is an error, which is what you want while developing.libraryOverridesis the SDC way to attach assets. Core builds a library for the component and attaches it only on pages where the component renders. Paths are relative to the component folder, and the Vite output lives in the theme'sassets/, hence../../assets/. (Core turns them into/core/../themes/...URLs, which browsers normalize, this is core behavior.)dependenciespull the global React libraries in before your entry runs.- Because the Vite output is ES modules (shared chunks), the script needs
attributes: { type: module }. See Build.
Convention alternative
SDC also auto-attaches recipe-explorer.js and recipe-explorer.css found next to the twig file. That works if your build writes there. This scaffold writes everything to assets/ (one place to git-ignore and deploy), so it uses libraryOverrides.
*.twig: the mount point
<div {{ attributes.addClass('recipe-explorer').setAttribute('data-endpoint', endpoint).setAttribute('data-heading', heading|default('Recipes')) }}>
<noscript>{{ 'The recipe explorer needs JavaScript.'|t }}</noscript>
</div>Twig renders an empty, accessible-by-default element and passes props to React as data-* attributes. attributes is provided by SDC, so callers can add classes and attributes. For structured data, build one JSON attribute (0 into data-props) and JSON.parse it in index.jsx.
index.jsx: the Drupal behavior
import { RecipeExplorer } from './RecipeExplorer';
import './recipe-explorer.scss';
import { createRoot } from 'react-dom/client';
(function (Drupal, once) {
const attach = (element) => {
createRoot(element).render(
<RecipeExplorer endpoint={element.dataset.endpoint} heading={element.dataset.heading}/>
);
};
Drupal.behaviors.recipeExplorer = {
attach(context) {
once('react', '.recipe-explorer', context)
.forEach((element) => executeWhenVisible(element, attach, 'recipe-explorer'));
},
};
})(Drupal, once);Keep index.jsx thin: it only wires Drupal to React. Why it is written like this is explained in React + Drupal behaviors.
Add a new component, step by step
- Folder:
components/my-widget/. my-widget.component.yml: name, props,libraryOverridespointing to../../assets/my-widget.jsand.css(the file names come from the folder name, see below), dependency onreact_scaffold/react.my-widget.twig: the mount element with a class anddata-*props.index.jsx: copy the behavior above, change the selector, behavior name and the component.MyWidget.jsxandmy-widget.scss.npm run dist. The Vite config scanscomponents/*/index.jsxand names the bundle after the folder, somy-widget/producesassets/my-widget.jsandassets/my-widget.css.drush cr, then place it in Twig.
The folder name is also the bundle name, so keep the three in sync: folder my-widget, bundle assets/my-widget.js, and the path in libraryOverrides.
Props or slots?
SDC gives you two ways to pass content in. Choose by what the value is:
| Use a prop when | Use a slot when |
|---|---|
| it is a value: string, number, boolean, enum, URL, ID | it is content: markup, links, a render array, translated rich text |
| you want core to validate it (type, enum, required, default) | the caller may want to put any HTML or other components inside |
React needs it as data (endpoint, variant, limit) | Drupal should render it (page title, field output, a block) |
| it is small and safe in an HTML attribute | it could contain user content you do not want to double-encode |
Rules of thumb:
- Config for React → props.
endpoint,variant,heading. They end up indata-*attributes andelement.dataset. - Anything Drupal renders → slots. Never pass rendered HTML as a prop string, it is escaped and bypasses Drupal's render pipeline.
- Enums over free text for anything that changes behavior (
variant: light | dark), the schema documents and enforces them. - Defaults in
component.yml, not in twig or JS, so every caller sees the same default. - Prop values are strings in the browser.
data-limit="10"arrives as"10". Parse numbers and booleans inindex.jsx, or send structured data as one JSON attribute andJSON.parseit. - Slot content is server markup. React replaces the element's children when it mounts, so read what you need first (
element.innerText,element.innerHTML) and render it yourself. The tooltip does this with its trigger text.
Slot example (tooltip)
# react-tooltip.component.yml
props: { type: object, properties: { text: { type: string } }, required: [text] }
slots:
content: { title: Content }<span {{ attributes.addClass('react-tooltip').setAttribute('data-text', text) }}>{% block content %}{% endblock %}</span>text is a value React needs, content is whatever Drupal wants shown in the span.
Gotchas and advice
Naming and wiring
- The folder name is the bundle name: folder
my-widget→assets/my-widget.js/.css. The path inlibraryOverrides, the folder and the component ID (react_scaffold:my-widget) must agree. A typo gives a 404 for the script, not an error. index.jsxis required for the build to pick the folder up (index.jsalso works). Other files are free.- One behavior per component, named uniquely (
Drupal.behaviors.myWidget). A clashing name silently replaces another behavior. - Use a specific mount class (
.my-widget) inonce(). A generic selector mounts React into unrelated markup. - The
once()id ('react') can be shared:oncetracks element and id together, different elements never conflict.
Libraries
- Always depend on
react_scaffold/react(andreact_scaffold/react-api-clientif you callapiClient). Without itReact,apiClientandexecuteWhenVisibleare undefined and the component fails silently. - If the HTML you render contains ajax links or dropbuttons, add
core/drupal.ajax/core/drupal.dropbutton, see Ajax. These pull jQuery in through core, your code does not need it. - Scripts that are ES modules need
attributes: { type: module }, see Build.
JavaScript
import './my-widget.scss'inindex.jsx. Vite extracts the CSS intoassets/my-widget.css. Third-party CSS (react-tippy/dist/tippy.css) must be imported the same way, it is not automatic.- Use
Drupal.t()/Drupal.formatPlural()for text.Drupal,onceanddrupalSettingsare globals, do not bundle them. - React is an external, so it is not bundled:
import { useState } from 'react'is fine, it resolves to the globalReact. Do not add a second React to a component's dependencies. - Keep React component files free of Drupal globals where you can. Pass
endpointand text in as props, so Jest tests do not need Drupal. - Handle loading, error and empty states. The server markup is a bare mount element, users see it until your first render.
Markup and accessibility
- Put server-rendered fallback inside the mount element (
<noscript>, or real content that React replaces). It is what search engines and no-JS users get. - Prefer real form controls, labels and
aria-livefor results. React does not add them for you. - SDC
attributeslets callers add classes and attributes, keepattributes.addClass(...)in twig instead of hardcoding a bare<div>.
Workflow
- After editing
*.component.yml, twig orlibraries.yml:drush cr. After editing JSX/SCSS: rebuild (npm run watchdoes it). - Component props are validated on every render, a wrong type is a visible error. Fix the schema or the caller, do not loosen the schema to silence it.
- If something does not show up, follow the checklist in Build.