Creating new Palette component
A practical guide to adding a Single Directory Component (SDC) to Palette: what every component must contain, and how the Style (variant) setting works. See CLAUDE.md for the architecture overview, styles.md for extending a component from another module/theme.
1. The anatomy of a component
Every component lives in its own folder under components/:
components/<id>/
<id>.component.yml # required — metadata + prop/slot schema
<id>.twig # required — the markup
<id>.css # optional — co-located styles
<id>.js # optional — co-located behaviour<id>is the machine name (lowercase, underscores). Palette exposes the component ascanvas_palette:<id>.- Core SDC auto-discovers the folder and auto-generates a library
core/components.canvas_palette--<id>from the co-located.css/.js. You do not register the library yourself.
1a. <id>.component.yml — the required keys
$schema: https://git.drupalcode.org/project/drupal/-/raw/HEAD/core/assets/schemas/v1/metadata.schema.json
name: My Thing # human label shown in the editor
group: Palette # category in the component list — always "Palette"
status: experimental # experimental | stable | deprecated
description: 'One line describing what it does.'
props:
type: object
properties:
# … see below …
slots:
# … optional, see 1c …1b. Props
Each prop is a JSON-schema property. Common shapes used in this kit:
props:
type: object
properties:
title:
type: string
title: Title
examples: ['Default title'] # the editor placeholder / seed value
text:
type: string
title: Text
contentMediaType: text/html # rich text …
x-formatting-context: block # … edited with the block CKEditor
examples: ['<p>Some copy.</p>']
count:
type: integer
title: Count
default: 3
image:
$ref: json-schema-definitions://canvas.module/image
type: object
title: ImageRules that matter (all enforced by Palette — get them wrong and the component silently disables itself and vanishes from the editor):
- A
required:prop MUST have anexamplesvalue. If you don't want a default, make the prop optional (leave it out ofrequired:) rather than giving it an empty example. examples[0]is the value seeded onto a newly placed instance — treat it as the editor default/placeholder.default:is the runtime fallback the Twig sees when the prop is unset.- Enum props list a
meta:enummap for human labels (see §2). - Optional integer props error when cleared in the editor; prefer a
default: 0that the Twig treats as "unset" instead of making it clearable.
1c. Slots (container components)
Container components (Carousel, Tiles, Stats…) hold child components in static slots, and usually pair with a dedicated *_item component:
slots:
items:
title: Items
description: 'Place one My Item per row.'
expected: ['canvas_palette:my_item']
minItems: 1Slots are rendered in Twig with {% block <slot> %}{% endblock %}. Palette blocks runtime-dynamic slots, so the number of slots is fixed at definition time.
1d. <id>.twig
Plain SDC Twig. Props and slots are top-level variables/blocks:
<div class="cp-my-thing cp-my-thing--{{ style|default('default') }}">
{% if title %}<h2 class="cp-my-thing__title">{{ title }}</h2>{% endif %}
{% if text %}<div class="cp-my-thing__body">{{ text }}</div>{% endif %}
{% block items %}{% endblock %}
</div>Conventions: BEM class names cp-<id>, cp-<id>__element, cp-<id>--modifier.
1e. JS that uses Drupal.behaviors / once / a third-party library
The auto-generated component library gets core/drupal injected only when the component declares libraryOverrides. So any component whose .js uses Drupal.behaviors, once(), or a shared library must declare its dependencies:
libraryOverrides:
dependencies:
- core/drupal
- core/once
- canvas_palette/glightbox # third-party libs are attached the same wayWithout this, Drupal/once are undefined at runtime. (See tabs, accordion, carousel for real examples.)
1f. Editor-only chrome
To show something only inside the Palette editor preview (grab padding, empty outlines…), gate it on the canvas_is_preview flag Palette injects into the Twig context and emit a cp-<id>--preview modifier — it never reaches published pages:
{% set classes = ['cp-my-thing', canvas_is_preview ? 'cp-my-thing--preview' : ''] %}The JS-side equivalent is window.frameElement?.dataset?.canvasPreview === 'true'.
2. How the Style setting works
The "Style" setting is Palette's single convention for component variants. There is no per-style Twig or JS branching — it is one enum prop turned into a CSS class.
2a. The style prop
Most components declare a single enum string prop named style (labelled "Style", or sometimes "Layout" / "Columns"):
style:
type: string
title: Style
default: default
enum:
- default
- colorful
meta:enum:
default: Default
colorful: Colorful
examples: [default]enum— the machine values.meta:enum— the human labels shown in the select.default/examples— the starting value.
2b. Twig interpolates it into a BEM modifier
The Twig writes the value verbatim onto the root element as a modifier class:
<div class="cp-my-thing cp-my-thing--{{ style|default('default') }}">So style: colorful renders class="cp-my-thing cp-my-thing--colorful".
2c. CSS keys on the modifier
Each style is just a CSS rule set keyed on .cp-<id>--<value>:
.cp-my-thing--colorful {
background: linear-gradient(135deg, #0973f2, #7333e5);
color: #fff;
}That is the entire mechanism: enum value → cp-<id>--<value> class → CSS.
2d. It floats to the top of the editor form
canvas_palette_move_style_prop_first() gives any prop literally named style a very low #weight, so the Style selector always appears first in the component-inputs sidebar. This runs from canvas_palette_form_component_instance_form_alter() for every sdc.canvas_palette.* component — you get it for free just by naming the prop style. (Components with a custom inputs-form handler that re-weights props must still leave style at the top.)
2e. Adding a new Style value to an existing component
Adding an enum value is an additive, safe change:
- Add the value to
enum:and a label tometa:enum:. - Add the
.cp-<id>--<value>CSS. - Rebuild (see §3). The new value appears on newly placed instances and after a hard editor reload; existing instances keep their pinned version.
Shared style conventions in this kit (see CLAUDE.md for the exact token values): a colorful value and an optional eyebrow kicker prop, styled to the landing-page design system. Reuse the hard-coded token values there rather than inventing new colours.
3. Build & the versioning gotchas
After adding or editing a component, rebuild from the site root:
php vendor/drush/drush/drush.php crOn this OSPanel stack a CLI drush cr often does not reach the browser — flush through the web (admin Configuration → Development → Performance → Clear all caches, or a throwaway web/flush.php). See CLAUDE.md → "Local environment quirk".
Canvas freezes each component's schema as a versioned config entity (sdc.canvas_palette.<id>). The consequences:
- Editing
*.component.ymldoes not live-update placed instances. New props / enum values show up on newly placed instances and after a hard editor reload. - Additive changes are safe (new enum value, new optional prop). Changing an existing prop's field type is not — it throws and disables the whole component. Revert the type to restore it.
If a component ends up disabled after a change, regenerate and re-enable:
\Drupal::service('Drupal\canvas\ComponentSource\ComponentSourceManager') ->generateComponents(); $c = \Drupal::entityTypeManager()->getStorage('component') ->loadUnchanged('sdc.canvas_palette.<id>'); if ($c && !$c->status()) { $c->enable()->save(); }Verify it stays enabled across a second generate — a fix that only survives one manual re-enable is not a real fix.
Checklist for a new component
components/<id>/<id>.component.yml with $schema, name, group: Palette, status, description.
Props defined; every required prop has examples.
<id>.twig with cp-<id> BEM classes.
A style enum prop (+ meta:enum) if the component has variants, wired to cp-<id>--{{ style }} and matching CSS.
libraryOverrides.dependencies if the JS uses Drupal.behaviors / once / a third-party library.
Slots + a paired *_item component if it's a container.
Rebuilt (through the web); component shows ENABLED across two generates.