Component development
Goal: turn a hello-world component into ones editors can really work with: props that render as a form, slots that hold other components, styling that still works after the push, and components that compose into layouts.
The quickstart left you with a scaffolded project and one pushed component. Everything a component offers an editor is declared in its component.yml; everything it renders lives in its index.jsx. The two components built below, a card and a row that lays cards out in a grid, work through the whole loop: schema, source, styling, push, and placement.
What you’ll have when you’re done
Section titled “What you’ll have when you’re done”- A card component whose editing form you defined: a required text field, a dropdown with human-readable options, and a toggle.
- A slot editors fill by dragging other components in, and a second component that arranges its slot children in a grid.
- The component’s Tailwind utilities and its own CSS served by your site, and the rule that keeps utility class names visible to the build.
- Context files pulled from the site that teach an AI coding agent which props can bind to which fields.
Prerequisites
Section titled “Prerequisites”- A component project connected to your site with working Canvas CLI credentials (Canvas quickstart)
- Working knowledge of React and Tailwind CSS
-
Define props: the component’s editing form
Section titled “Define props: the component’s editing form”A prop is a typed value an editor sets in Drupal Canvas. Each entry under
props.propertiesincomponent.ymlis a JSON Schema property, and together they are the form the editor gets. Scaffold a component (npx canvas scaffold --name feature-card, then setmachineNameto match the directory) and replace the generated props:src/components/feature-card/component.yml name: Feature CardmachineName: feature-cardstatus: truerequired:- headingprops:properties:heading:type: stringtitle: Headingdescription: The card's titleexamples:- Ship weeklytone:type: stringtitle: Tonedescription: Color treatment for the cardexamples:- neutralenum:- neutral- brandmeta:enum:neutral: Neutralbrand: Brandelevated:type: booleantitle: Elevateddescription: Lift the card with a shadowexamples:- falseslots:body:title: Bodydescription: Content below the headingexamples:- <p>Example body content</p>Select a placed Feature Card in the Canvas editor and this schema is the form in the right panel’s
Settingstab, field for field:titleis the field label anddescriptionthe help text under it.headingis a plainstring, so it renders as a text field, prefilled with its firstexamplesvalue. Listing it underrequiredmarks the field with an asterisk.tonehas anenum, so it renders as a dropdown, showing themeta:enumlabels (Neutral,Brand) rather than the stored values. Because it is not required, the dropdown also offers- None -.elevatedis aboolean, so it renders as a toggle.
Types beyond these three:
integerandnumbercarry numbers (step 4 puts anenumon aninteger), andobjectcarries structured values such as images. The full field list and each type’s exact declaration are in the schema reference; so are the constraints that arrive once a component is in the library, where a prop’s name and type are no longer yours to change freely. -
Receive props and render the slot
Section titled “Receive props and render the slot”The component’s default export receives every prop as an argument named after its key, plus each slot as an argument carrying the components editors placed in it. Render the slot where the children belong:
src/components/feature-card/index.jsx import { cn } from 'drupal-canvas';const FeatureCard = ({ heading, tone = 'neutral', elevated = false, body }) => (<divclassName={cn('flex w-full flex-col gap-3 rounded-2xl border p-6',tone === 'neutral' && 'border-gray-200 bg-white',tone === 'brand' && 'border-primary-200 bg-primary-100',elevated && 'shadow-lg',)}><h3 className="text-lg font-semibold text-gray-800">{heading}</h3><div className="feature-card-body text-sm leading-6 text-gray-600">{body}</div></div>);export default FeatureCard;The JSX defaults (
tone = 'neutral') are the real fallbacks: they apply when an editor leaves an optional prop unset, or picks- None -. Theexamplesvalues incomponent.ymlare only prefills and previews; changing them later does not touch anything already placed.Two slot behaviors to design for:
- In the editor, the
bodyslot draws as a labeled drop target, and editors drag components from the library into it. On the published page, a slot with nothing placed in it renders itsexamplesmarkup as fallback content, for every visitor. Write example markup you would ship. - A slot’s name is part of the stored content model. Renaming
bodyand pushing succeeds without a warning, and every component editors placed in the slot stops rendering; pushing the exact original name back restores them. Treat a slot name as permanent once the component is in use (schema reference).
Validate as you go; the same ESLint rules run again before every push:
Terminal window npx canvas validate┌ Drupal Canvas validate◇ Validated components│ Valid: card-row, feature-card│└ Validation completed - In the editor, the
-
Style with Tailwind, and know how the CSS travels
Section titled “Style with Tailwind, and know how the CSS travels”The scaffold’s
src/global.cssis a Tailwind CSS 4 entry point:@import 'tailwindcss'plus an@themeblock defining the design tokens the example components use (primary-*,gray-*, the font stack).npx canvas pushbuilds one global stylesheet from it and uploads the result as the site’s global CSS assets, alongside the components themselves:◇ Pushed components│ Created: card-row, feature-card◇ Prepared assets◇ Pushed assets│ Global CSS (Tailwind CSS build)│└ ✓ Push completedThat build scans your component source for utility class names, so a class ends up in the site’s CSS only if it appears somewhere as a literal string.
bg-primary-100andshadow-lgfrom step 2 are in the served stylesheet because they are written out in the JSX. A computed name like`md:grid-cols-${columns}`never appears in the source, so the build emits nothing for it and the style silently fails on the site. Map dynamic values to literal class names instead (step 4 does exactly this).Each component may also ship its own
index.css, compiled through the same Tailwind build and delivered with the component. The file is optional; its real use is markup you cannot put utility classes on, like whatever editors drop into a slot:src/components/feature-card/index.css /* Slot content arrives as markup you don't control, so links inside thebody get their styling here rather than from utility classes. */.feature-card-body a {text-decoration: underline;text-underline-offset: 2px;}For conditional classes, the
drupal-canvaspackage ships thecnhelper used in step 2 (clsxplustailwind-merge, so later classes win conflicts), and the scaffold includesclass-variance-authorityfor components with many variants.On the site, all of it comes back aggregated: the global Tailwind build and every component’s
index.cssare served in the page’s stylesheets, and the component itself arrives as a client-rendered island whose renderer is Preact’s React compatibility layer, not React. Ordinary components never notice; a dependency that reaches into React internals can fail on the site even though it works in a plain React app. Canvas Workbench (npm run dev) andnpx canvas validateare the fast checks that what you author will run.Each placed component renders independently, the components in your slots included, so share data through props, not React context: a provider works inside the component that renders it and reaches nothing placed outside it. When a design genuinely needs one shared client-side tree, that is the signal to weigh headless; Canvas Headless keeps the visual editor.
Never put a secret in a component. Its inputs are visible to every visitor and it has no secret storage; plenty still works without one. Requests to the site’s own APIs need no key at all: they run on the site’s origin, so they carry the visitor’s Drupal session (cookie authentication). Keys designed for the browser work too: a payment provider’s publishable key, a search service’s search-only key, a maps key, all meant to ship to visitors.
What a component cannot hold is the confidential half (creating payment sessions, write-capable keys, any private API). Where that call lives depends on your backend. On Cloud Platform, you write it into your own Drupal code and give the component an endpoint. On Source CMS, where custom PHP is not available, it belongs in headless server code, where environment variables stay on the server.
-
Compose: a listing component with a slot
Section titled “Compose: a listing component with a slot”Editors build layouts by nesting: a wrapper component offers a slot, and cards go inside. The wrapper’s own props then control the arrangement of children it never knows the contents of. A row that lays out cards in a grid:
src/components/card-row/component.yml name: Card RowmachineName: card-rowstatus: truerequired: []props:properties:heading:type: stringtitle: Headingdescription: Optional heading above the rowexamples:- What you getcolumns:type: integertitle: Columnsdescription: Cards per row on wide screensexamples:- 3enum:- 2- 3- 4meta:enum:2: Two3: Three4: Fourslots:cards:title: Cardsdescription: The cards in the rowexamples:- <div>Example card</div>An
enumworks on non-string types too:columnsstores a real integer, and the editor shows a dropdown readingTwo,Three,Four.src/components/card-row/index.jsx import { cn } from 'drupal-canvas';// Tailwind only generates classes it can read in the source, so the// column count maps to literal class names, never a template string.const columnClasses = {2: 'md:grid-cols-2',3: 'md:grid-cols-3',4: 'md:grid-cols-4',};const CardRow = ({ heading, columns = 3, cards }) => (<section className="mx-auto w-full max-w-5xl px-6 py-12">{heading && (<h2 className="mb-8 text-2xl font-semibold text-gray-800">{heading}</h2>)}<div className={cn('grid grid-cols-1 gap-6', columnClasses[columns])}>{cards}</div></section>);export default CardRow;Push both components, then compose on a page: place a Card Row, fill its
cardsslot with Feature Cards, and each card renders as a grid item. The same nesting works as code, in the page spec’sslotsarrays. One ordering rule applies: an element must appear in theelementsmap before the children its slots reference, or the push fails naming an out-of-order parent (pages and regions covers the spec format):{"d6858ab4-5b6f-47bc-9b57-ebe8258b9f51": {"type": "js.card-row","props": { "heading": "What you get", "columns": 3 },"slots": {"cards": ["44c07fb1-c628-4c6f-8190-72aa98eec806","a1b2c3d4-0000-4000-8000-000000000002","a1b2c3d4-0000-4000-8000-000000000003"]}}}This pair is the composition pattern to reuse: content components carry props and a content slot; layout components carry layout props and a slot for children. When editors instead want a preassembled arrangement they can paste and then tweak per use, that is a pattern, saved from the editor; placing one copies the components rather than linking them.
-
Give your AI agent the same context
Section titled “Give your AI agent the same context”The scaffolded project already carries authoring rules for AI coding agents in
.agents/skills/(component structure, prop and slot conventions, styling, validation), readable by Codex and Copilot directly and symlinked into.claude/skills/for Claude Code. An agent working in the project picks them up with no extra step.What the project cannot know by itself is your site: which content types exist, and which entity fields can feed which props. The CLI pulls that from the site into
.agents/drupal-canvas/:Terminal window npx canvas agents-context --allSaved prop source suggestions for content template component props to .agents/drupal-canvas/prop-sources.json.Saved content template entity view modes to .agents/drupal-canvas/view-modes.json.Saved content-entity-reference field browser expressions to .agents/drupal-canvas/content-entity-reference-expressions.json.--allruns every default context provider and needs@drupal-canvas/cli0.21 or later; on 0.20.1, the version the scaffold pins, the command takes no options (npx canvas agents-context) and savesprop-sources.jsonandview-modes.json(Canvas CLI reference).prop-sources.jsonlists, per content type and per component, every prop that can bind to an entity field and the exact binding expression to copy;view-modes.jsonlists the view modes each content type exposes. Both are as readable by you as by an agent, and both power the binding work in content templates. Re-run the command after changing content types or fields on the site; the files are a snapshot.
When something goes wrong
Section titled “When something goes wrong”Component "<uuid>" references unknown or out-of-order parent "<uuid>". (from canvas push)
a page spec nests an element into a slot of a parent that appears later in the elements map. Order is meaning: move the parent element above every child its slots arrays reference and push again.
Prop "columns" has invalid example value: [] Integer value found, but a string or an object is required (from canvas push)
a prop’s examples (or enum) values no longer match its declared type. This is usually the leftover of editing a prop’s type in component.yml: the push validates the whole schema together, so change the example and enum values to the new type, or put the type back.
Placed components vanished after you renamed a slot
the rename pushed cleanly, but the components editors placed under the old slot name no longer render. Their data is still in the page. Restore the exact original slot name in component.yml and push again; the children reappear.
Example markup showing on the published page
a slot nobody filled renders its examples markup as fallback content, for every visitor, not just in previews. Either place real content in the slot or write slot examples you are happy to ship.
A class name your component computes at runtime styles nothing on the site
the pushed global CSS contains only class names that appear literally in your source, so the computed name has no rule to match. Replace computed class strings with a lookup table of literal names (step 4), then push again.
A React context provider in one component has no effect on the components in its slot
placed components render independently, so pass the value down as a prop, or render the provider inside each component that needs it.
Next steps
Section titled “Next steps”- Render CMS content with content templates: bind the props you just defined to entity fields, using the
prop-sources.jsonyou pulled. - Fetch content directly: when a component needs content that is not its own page (a related-entries list, a menu), with whose access that fetch runs and what it can reach.
- Version pages and global regions: the page specs these components were composed in, as code.
- Push to a live site: what the next push does once editors have placed these components.
- Canvas CLI & schema reference: every
component.ymlfield, and the constraints on changing props and slots later.
Was this page helpful?
What went wrong?
Still stuck? Contact Acquia Support (opens in a new tab)