Skip to content

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.

  • 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.
  • A component project connected to your site with working Canvas CLI credentials (Canvas quickstart)
  • Working knowledge of React and Tailwind CSS
  1. 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.properties in component.yml is a JSON Schema property, and together they are the form the editor gets. Scaffold a component (npx canvas scaffold --name feature-card, then set machineName to match the directory) and replace the generated props:

    src/components/feature-card/component.yml
    name: Feature Card
    machineName: feature-card
    status: true
    required:
    - heading
    props:
    properties:
    heading:
    type: string
    title: Heading
    description: The card's title
    examples:
    - Ship weekly
    tone:
    type: string
    title: Tone
    description: Color treatment for the card
    examples:
    - neutral
    enum:
    - neutral
    - brand
    meta:enum:
    neutral: Neutral
    brand: Brand
    elevated:
    type: boolean
    title: Elevated
    description: Lift the card with a shadow
    examples:
    - false
    slots:
    body:
    title: Body
    description: Content below the heading
    examples:
    - <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 Settings tab, field for field:

    • title is the field label and description the help text under it.
    • heading is a plain string, so it renders as a text field, prefilled with its first examples value. Listing it under required marks the field with an asterisk.
    • tone has an enum, so it renders as a dropdown, showing the meta:enum labels (Neutral, Brand) rather than the stored values. Because it is not required, the dropdown also offers - None -.
    • elevated is a boolean, so it renders as a toggle.

    Types beyond these three: integer and number carry numbers (step 4 puts an enum on an integer), and object carries 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.

  2. 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 }) => (
    <div
    className={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 -. The examples values in component.yml are only prefills and previews; changing them later does not touch anything already placed.

    Two slot behaviors to design for:

    • In the editor, the body slot 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 its examples markup as fallback content, for every visitor. Write example markup you would ship.
    • A slot’s name is part of the stored content model. Renaming body and 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
  3. 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.css is a Tailwind CSS 4 entry point: @import 'tailwindcss' plus an @theme block defining the design tokens the example components use (primary-*, gray-*, the font stack). npx canvas push builds 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 completed

    That 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-100 and shadow-lg from 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 the
    body 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-canvas package ships the cn helper used in step 2 (clsx plus tailwind-merge, so later classes win conflicts), and the scaffold includes class-variance-authority for components with many variants.

    On the site, all of it comes back aggregated: the global Tailwind build and every component’s index.css are 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) and npx canvas validate are 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.

  4. 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 Row
    machineName: card-row
    status: true
    required: []
    props:
    properties:
    heading:
    type: string
    title: Heading
    description: Optional heading above the row
    examples:
    - What you get
    columns:
    type: integer
    title: Columns
    description: Cards per row on wide screens
    examples:
    - 3
    enum:
    - 2
    - 3
    - 4
    meta:enum:
    2: Two
    3: Three
    4: Four
    slots:
    cards:
    title: Cards
    description: The cards in the row
    examples:
    - <div>Example card</div>

    An enum works on non-string types too: columns stores a real integer, and the editor shows a dropdown reading Two, 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 cards slot with Feature Cards, and each card renders as a grid item. The same nesting works as code, in the page spec’s slots arrays. One ordering rule applies: an element must appear in the elements map 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.

  5. 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 --all
    Saved 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.

    --all runs every default context provider and needs @drupal-canvas/cli 0.21 or later; on 0.20.1, the version the scaffold pins, the command takes no options (npx canvas agents-context) and saves prop-sources.json and view-modes.json (Canvas CLI reference).

    prop-sources.json lists, per content type and per component, every prop that can bind to an entity field and the exact binding expression to copy; view-modes.json lists 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.

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.

Was this page helpful?