# Component development

**Goal:** turn a hello-world [component](/start-here/glossary/#component) into ones editors can really work with: [props](/start-here/glossary/#prop) that render as a form, [slots](/start-here/glossary/#slot) that hold other components, styling that still works after the push, and components that compose into layouts.

The [quickstart](/source-cms/canvas-components/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

- 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](/start-here/glossary/#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

- A component project connected to your site with working Canvas CLI credentials ([Canvas quickstart](/source-cms/canvas-components/quickstart/))
- Working knowledge of React and Tailwind CSS

## Steps

<Steps>

1. ### Define props: the component's editing form

   A prop is a typed value an editor sets in [Drupal Canvas](/start-here/glossary/#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:

   ```yaml
   # 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](/source-cms/reference/canvas-cli/#prop-type-declarations) are in the [schema reference](/source-cms/reference/canvas-cli/#component-schema); 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. ### 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:

   ```jsx
   // 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](/source-cms/reference/canvas-cli/#component-schema)).

   Validate as you go; the same ESLint rules run again before every push:

   ```bash
   npx canvas validate
   ```

   ```text
   ┌   Drupal Canvas  validate
   ◇  Validated components
   │  Valid: card-row, feature-card
   │
   └  Validation completed
   ```

3. ### 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:

   ```text
   ◇  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:

   ```css
   /* 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](/start-here/glossary/#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](/start-here/choose-your-backend/#what-each-rendering-mode-gives-you); [Canvas Headless](/source-cms/get-content/quickstart/) 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](/source-cms/content-api/fetch-content/#put-the-fetch-where-it-belongs), where environment variables stay on the server.

4. ### 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:

   ```yaml
   # 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`.

   ```jsx
   // 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](/source-cms/canvas-components/pages-and-regions/) covers the spec format):

   ```json
   {
     "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](/start-here/glossary/#pattern), saved from the editor; placing one copies the components rather than linking them.

5. ### 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/`:

   ```bash
   npx canvas agents-context --all
   ```

   ```text
   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](/source-cms/reference/canvas-cli/#canvas-agents-context)).

   `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](/source-cms/canvas-components/content-templates/). Re-run the command after changing content types or fields on the site; the files are a snapshot.

</Steps>

## 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

- [Render CMS content with content templates](/source-cms/canvas-components/content-templates/): bind the props you just defined to entity fields, using the `prop-sources.json` you pulled.
- [Fetch content directly](/source-cms/content-api/fetch-content/#fetch-from-a-canvas-code-component): 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](/source-cms/canvas-components/pages-and-regions/): the page specs these components were composed in, as code.
- [Push to a live site](/source-cms/canvas-components/push-to-a-live-site/): what the next push does once editors have placed these components.
- [Canvas CLI & schema reference](/source-cms/reference/canvas-cli/): every `component.yml` field, and the constraints on changing props and slots later.
