name: symfony-ux-toolkit-kit
description: >
Generate, modify, or review Symfony UX Toolkit kit recipes (Shadcn, Flowbite, future kits).
Enforces conventions for manifest, Twig component docblocks, sub-components, asChild
<recipe>_<role>_attrs pattern, outer-scope propagation, Stimulus controllers, examples,
snapshots, and PR hygiene. Use when adding/editing files under src/Toolkit/kits/ or
reviewing PRs touching the Toolkit.
Symfony UX Toolkit — Kit Recipe Skill
Author + review recipes for UX Toolkit. Recipes = unit shipped to end-users (Twig components + optional Stimulus controllers + examples).
When to Activate
- User says "add recipe", "new kit recipe", "Toolkit component", "port shadcn X", or similar.
- Any file change under
src/Toolkit/kits/<kit>/<recipe>/. - Reviewing PR titled
[Toolkit][...].
Core Rules
- One PR per recipe. Never batch multiple recipes single PR. PR title:
[Toolkit][<Kit>] Add <recipe> recipeor[Toolkit][<Kit>] Align <recipe> with <upstream> reference. - Target
3.x. CHANGELOG entry under active3.xsection insrc/Toolkit/CHANGELOG.md. - Visual + behavioral parity with upstream reference (Shadcn UI / Flowbite). Verify manually; attach screenshot/video to PR body for animated/interactive components.
- Reuse all upstream examples. No subset. Read both component source and every example file (see Shadcn UI / Flowbite v4).
- Companion PR on
symfony/ux.symfony.comfor visual preview/docs. Link in recipe PR body. - Regenerate snapshots after every recipe change + commit. CI + reviewers reject stale snapshots.
- Use GitHub PR template (Bug fix / Feature / License: MIT / Issues: Part of #3233). Fabbot fails otherwise.
- Prefer Stimulus controller over native browser features (e.g.
<details>) when parity needs animations, ARIA sync, coordinated state. Native fine only when matches upstream UX exactly.
Recipe Directory Layout
src/Toolkit/kits/<kit>/<recipe>/
├── manifest.json
├── examples/
│ ├── Usage.html.twig # mandatory, minimal API showcase
│ ├── Demo.html.twig # mandatory, rich preview (used on ux.symfony.com)
│ └── <Variant Name>.html.twig # one per upstream example, Title Case with spaces
├── templates/components/
│ ├── <Component>.html.twig # root component
│ └── <Component>/<SubName>.html.twig # e.g. Trigger, Close, Header, Item, Content
└── assets/controllers/ # optional, only if interactive behavior is needed
└── <recipe>_controller.js
Sub-component file path Component/SubName.html.twig consumed as <twig:Component:SubName>.
Shadcn UI
Always emit data-slot="<recipe-name>" on root + data-slot="<recipe-name>-<sub>" on every sub-component root. Shadcn-specific convention driven by its CSS selectors.
Upstream sources
Read all source files per recipe: component source carries canonical classes + data-* surface; examples show usage patterns; MDX drives docs and manifest.
| File | Purpose |
|---|---|
apps/v4/styles/radix-nova/ui/<recipe>.tsx |
Component source — canonical Tailwind classes, sub-component structure, data-slot/data-state surface |
apps/v4/styles/radix-nova/ui-rtl/<recipe>.tsx |
RTL variant — classes that differ per text direction (see RTL class variants) |
apps/v4/examples/radix/<recipe>-*.tsx |
Usage examples — one file per variant, drives examples list |
apps/v4/content/docs/components/radix/*.mdx |
Docs + manifest metadata — single source of truth for titles, descriptions, section order; description copied verbatim to ux/src/Toolkit/kits/shadcn/**/manifest.json |
Enumerate every example file for recipe:
gh api "repos/shadcn-ui/ui/git/trees/main?recursive=1" --jq '.tree[].path' \
| grep "apps/v4/examples/radix/<recipe>"
Fetch each:
https://raw.githubusercontent.com/shadcn-ui/ui/refs/heads/main/apps/v4/examples/radix/<example>.tsx
RTL class variants
The canonical source provides two implementations per component:
ui/apps/v4/styles/radix-nova/ui/{component}.tsx— LTR (default)ui/apps/v4/styles/radix-nova/ui-rtl/{component}.tsx— RTL
Always diff both files. For each class that differs between LTR and RTL, apply the rtl: Tailwind variant in addition to the LTR class — do not create separate templates.
Use ltr: to scope the LTR class when RTL replaces it with a different one. Keep rtl: classes from the RTL file verbatim when they are already prefixed (e.g. rtl:translate-x-1/2).
Example: if ui/table.tsx has [&:has([role=checkbox])]:pr-0 and ui-rtl/table.tsx has [&:has([role=checkbox])]:pe-0, write:
ltr:[&:has([role=checkbox])]:pr-0 rtl:[&:has([role=checkbox])]:pe-0
Flowbite v4
Kit identifier: flowbite-4.
Upstream sources
| Source | Purpose |
|---|---|
https://flowbite.com/docs/components/<recipe>/ |
Reference page — canonical markup, variants, accessibility notes |
https://github.com/themesberg/flowbite/blob/main/src/components/<recipe>/index.ts |
JS source — behavior, state, options (when Stimulus controller needed) |
Flowbite docs page = primary source: ships copy-pasteable HTML with Tailwind classes + lists every variant. Read full page before writing any template.
Local Visual Testing
ux and ux.symfony.com must be on matching branches; mismatch causes assetmap failures:
The asset "./vendor/symfony/ux-toolkit/kits/
/ /assets/controllers/ _controller.js" cannot be found in any asset map paths.
cd /path/to/ux && git checkout feat/toolkit-<kit>-<recipe>
cd /path/to/ux.symfony.com && git checkout docs/<kit>-<recipe>
# In ux.symfony.com:
php ../link
symfony php bin/console tailwind:build
symfony serve -d
Companion PR on ux.symfony.com
Every recipe PR needs companion PR on symfony/ux.symfony.com. Contents:
templates/toolkit/docs/<kit>/<recipe>.md.twigextending_base_component.md.twig- One
{{ toolkit_code_example(...) }}per example file shipped — missing entries cause silent rendering failures - If JS: register Stimulus controller in
assets/toolkit-<kit>.js+importmap.php - Run
symfony php bin/console tailwind:build+ commit CSS output - Attach screenshot/video of every interactive state
Link companion PR URL in recipe PR body before requesting review.
manifest.json
Kit-level (src/Toolkit/kits/<kit>/manifest.json)
{
"$schema": "../../schema-kit-v1.json",
"name": "<Display Name>",
"description": "...",
"license": "MIT",
"homepage": "https://ux.symfony.com/toolkit/kits/<kit>"
}
Recipe-level (src/Toolkit/kits/<kit>/<recipe>/manifest.json)
{
"$schema": "../../../schema-kit-recipe-v1.json",
"type": "component",
"name": "<Human Name>",
"description": "<short, ends with a period>",
"copy-files": {
"assets/": "assets/",
"templates/": "templates/"
},
"dependencies": {
"composer": [
"twig/extra-bundle",
"twig/html-extra:^3.12.0",
"tales-from-a-dev/twig-tailwind-extra:^1.0.0"
],
"recipe": ["<other-recipe>"]
}
}
Rules:
- Drop
assets/fromcopy-filesif no Stimulus controller. - Add
"symfony/ux-icons"tocomposerwhenever templates use<twig:ux:icon>. - Bump
twig/html-extraconstraint when using newer filters (e.g.^3.24.0for currenthtml_attr_type). - Declare
dependencies.recipeonly for recipes required by the component templates themselves (e.g.toggle-groupdepends ontoggle). Do NOT declare recipe deps for components used only in examples — examples are demo files, not shipped dependencies.
Twig Component Patterns
1. Header docblock (mandatory)
Every component starts with one {# @prop ... #} per declared prop + one {# @block content ... #}:
{# @prop id string Unique identifier used to generate internal Dialog IDs #}
{# @prop open boolean Whether the dialog is open on initial render. Defaults to `false` #}
{# @block content The dialog structure, typically includes `Dialog:Trigger` and `Dialog:Content` #}
{%- props id, open = false -%}
- Type uses Twig/PHP-flavored union syntax:
'default'|'secondary'|...,string|array<string>|null,boolean,number. - "Defaults to `
`" when default exists. - Reference sub-components by Twig tag name (
\Dialog:Trigger``).
2. Root element
<div
class="{{ ('<base classes> ' ~ attributes.render('class'))|tailwind_merge }}"
{{ attributes.defaults({
'data-slot': '<recipe-name>',
'data-controller': '<recipe>',
'data-<recipe>-<key>-value': value,
'aria-labelledby': _<recipe>_title_id,
}) }}
>
{%- block content %}{% endblock -%}
</div>
- Always use
attributes.defaults({...})(NOT raw{{ attributes }}). Consumers must override. - Class merging mandatory:
('<base> ' ~ attributes.render('class'))|tailwind_merge.
3. Variant systems with html_cva
{%- set style = html_cva(
base: '<base classes>',
variants: {
variant: { default: '...', outline: '...' },
size: { default: '...', sm: '...', lg: '...' },
},
) -%}
<button class="{{ style.apply({variant: variant, size: size}, attributes.render('class'))|tailwind_merge }}">
4. Parent → descendant context propagation
Preferred: provide() / inject() (needs symfony/ux-twig-component:^3.1). Parent publishes values, any descendant at any depth reads them. Works for self-closing children, crosses intermediate components without forwarding, replaces brittle outer-scope pattern.
{# parent — InputOtp.html.twig #}
{%- props maxLength = 6 -%}
{%- do provide('inputOtp.maxLength', maxLength) -%}
{%- do provide('inputOtp.id', 'input-otp-' ~ id) -%}
<div ...>{%- block content %}{% endblock -%}</div>
{# descendant — InputOtp/Slot.html.twig (works even self-closing) #}
{%- set _inputOtp_maxLength = inject('inputOtp.maxLength', 6) -%}
{%- set _inputOtp_id = inject('inputOtp.id') -%}
Conventions:
- Key format:
'<camelCaseRecipe>.<key>'(e.g.'inputOtp.maxLength','tabs.active','toggleGroup.variant'). Prefix avoids collisions across recipes. - Local variable name for injected values:
_<camelCaseRecipe>_<key>(e.g._tabs_defaultValue,_toggleGroup_variant). The_prefix + recipe name prevents collision with the child's own props or Twig globals. - Always pass fallback to
inject()when child can render standalone. - Place
provide()at top of parent template, before{% block content %}— descendants only see values published before their render. - Keys for ID-driven a11y wiring: derive
<recipe>.id,<recipe>.titleId,<recipe>.descriptionId,<recipe>.contentId,<recipe>.triggerIdfrom parent'sidprop. - Values flow top-down only; siblings never share state; provides dropped once parent finishes rendering.
Legacy: outer-scope _<recipe>_<key> variables. Older recipes use {%- set _dialog_title_id = ... -%} read by children with ?? fallback. Still works for body-form children but breaks for self-closing components (<twig:X:Item .../> compiles without outer context). Migrate to provide()/inject() when touching such recipes.
5. The <recipe>_<role>_attrs (asChild) pattern
Sub-templates like Trigger.html.twig, Close.html.twig, Cancel.html.twig MUST NOT wrap user's element in own <button>. Instead expose attrs bag consumer spreads onto own element:
{# templates/components/Dialog/Trigger.html.twig #}
{# @block content The trigger element (e.g., a `Button`) that opens the dialog when clicked #}
{%- set dialog_trigger_attrs = {
'data-action': 'click->dialog#open'|html_attr_type('sst'),
'data-dialog-target': 'trigger',
'aria-haspopup': 'dialog',
} -%}
{%- block content %}{% endblock -%}
{# example consumer #}
<twig:Dialog:Trigger>
<twig:Button {{ ...dialog_trigger_attrs }}>Open</twig:Button>
</twig:Dialog:Trigger>
Rules:
- Variable name:
<snake_case_recipe>_<role>_attrs—dialog_trigger_attrs,dialog_close_attrs,tooltip_trigger_attrs,collapsible_trigger_attrs,alert_dialog_trigger_attrs. - Apply
|html_attr_type('sst')todata-action.'sst'= Stimulus Shorthand Token — marks value appendable, so consumer spreading{{ ...dialog_trigger_attrs }}alongside owndata-actiongets both merged rather than first overwritten. - Template body =
{%- block content %}{% endblock -%}only — no wrapping element, otherwise variable not visible to outer scope. - Variant (when wrapping known component acceptable, e.g.
AlertDialog:Action):
{# @prop variant 'default'|'destructive' ... #}
{%- props variant = 'default' -%}
<twig:Button variant="{{ variant }}" {{ ...attributes }}>
{{- block(outerBlocks.content) -}}
</twig:Button>
6. Collapse/expand animation — grid-template-rows, never hidden
hidden (display:none) causes layout jumps. Use grid-template-rows: 0fr + overflow:hidden for smooth transitions:
<div class="grid overflow-hidden transition-[grid-template-rows] duration-300 ease-out"
style="grid-template-rows: {{ open ? '1fr' : '0fr' }};">
<div class="min-h-0 min-w-0 overflow-hidden">
{%- block content %}{% endblock -%}
</div>
</div>
Stimulus controller toggles style.gridTemplateRows between '1fr' and '0fr' on open/close.
7. ARIA & data-state surfaces
Always emit (when applicable):
role,aria-haspopup,aria-expanded,aria-controls,aria-labelledby,aria-describedby,aria-disabled,aria-pressed,aria-hidden.data-state="open|closed|active|inactive",data-orientation="vertical|horizontal",data-variant,data-size,data-disabled,data-open,data-closed.- IDs deterministic + shared between trigger/content via parent's
idprop (e.g.aria-controls={{ _accordion_item_content_id }}).
Stimulus Controller Conventions
assets/controllers/<recipe>_controller.js:
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static targets = ['trigger', 'content'];
static values = { open: Boolean };
connect() {
if (this.openValue) this.open();
}
open() { /* ...sync ARIA after transitions... */ }
close() { /* ... */ }
}
- ESM, default export,
@hotwired/stimulus. - Sync ARIA from JS on state changes (
aria-expanded,data-state). - Respect transitions:
if (el.getAnimations().length > 0) el.addEventListener('transitionend', ..., { once: true });. - Naming:
<recipe>_controller.js, controller identifier<recipe>(kebab-case in Twig). - Keyboard actions — use Stimulus descriptor syntax in Twig, not raw JS
keydownlisteners:
Pipe throughdata-action="keydown.enter->{{ recipe }}#toggle keydown.space->{{ recipe }}#toggle"|html_attr_type('sst')when exposing via<recipe>_<role>_attrsso consumers can append own actions. - Hover/focus-triggered components — never use
group-hover+group-focus-within+tabindex=0; use Stimulus controller withopenDelay/closeDelayvalues instead (see anti-patterns). - Nested open-state — never use
in-data-[state=open]:visibleon nested components; use named Tailwind groups (group/<recipe>-menu,group/<recipe>-sub) instead (see anti-patterns).
Examples Conventions
- File names: Title Case with spaces, e.g.
Custom close button.html.twig,With Icon.html.twig,Different sizes.html.twig,RTL.html.twig,File Tree.html.twig. - Mandatory:
Usage.html.twig(minimal call surface) +Demo.html.twig(rich showcase used as kit preview). - One example per upstream variant. Match upstream copy/structure where possible.
- When upstream uses cross-cutting JS (e.g. shadcn's
language-selector), replicate intent without inventing new infrastructure (e.g. stack two independent components for RTL+LTR side-by-side, seecollapsible/RTL).
RTL examples
- The RTL example file must be named
RTL.html.twig(all caps, matching the collapsible kit convention). - The Twig RTL example must show both the Arabic and Hebrew versions (
dir="rtl"), stacked vertically — no side-by-side LTR/RTL comparison. - The LTR card is intentionally omitted: it duplicates the Demo example and adds no value.
- The RTL section in the doc must use
### RTL(not## RTL) — it is a subsection of Examples, not a top-level section. - The RTL section description in the doc must always be:
To enable RTL support, set the \dir="rtl"` attribute on the root element.`
Tests & Snapshots
cd src/Toolkit
# When examples were renamed/removed, blow them away first
rm -fr tests/Functional/__snapshots__/*<recipe>*
# Regenerate
php vendor/bin/simple-phpunit -d --update-snapshots
# Re-run normally to confirm green
php vendor/bin/simple-phpunit
git add tests/Functional/__snapshots__
Reviewers explicitly check snapshots regenerated (#3488).
Orphan snapshots: when recipe rewritten (e.g. <details> → Stimulus), old snapshot files with old naming scheme (e.g. Demo.html__1.html without .twig suffix) never regenerated + silently persist. After regenerating, inspect git status for leftover files + git rm them.
After rebase on 3.x: snapshot formatter may have evolved upstream. Re-run --update-snapshots once more after final rebase to avoid "diff in snapshots" CI failures.
Authoring Workflow
- Locate upstream reference (see Shadcn UI / Flowbite v4) — list every example variant before writing any code
- Scaffold recipe directory +
manifest.json - Root component, sub-components (with
<recipe>_<role>_attrs), Stimulus controller if needed - Examples:
Usage.html.twig,Demo.html.twig, then every upstream variant - Snapshots — regenerate, inspect HTML diff, commit
- Lint/format, CHANGELOG entry, open PR + companion PR
PR / Review Checklist
- Single recipe per PR
- Targets
3.x - PR template filled (Bug/Feature, License: MIT, Issues: Part of #3233)
- CHANGELOG entry under
3.x - All upstream examples present, file names Title Case
-
Usage.html.twig+Demo.html.twigboth present - Visual + behavioral parity verified manually (screenshot/video attached)
- Snapshots regenerated + committed (no stale entries)
- Companion PR on
symfony/ux.symfony.comlinked -
php-cs-fixer,twig-cs-fixer,pnpm run fmt,pnpm run lintclean - All Twig components have
{# @prop #}+{# @block #}docblocks (if applicable) - Trigger/Close sub-components use
<recipe>_<role>_attrs(no wrapping<button>) -
data-actionStimulus actions piped through|html_attr_type('sst')when concatenable - Inter-recipe deps declared in
manifest.jsondependencies.recipe - No orphan snapshot files after rework/rename (
git statusclean after--update-snapshots) - Every shipped file ends with trailing newline (
.html.twig,.json,.js,.css,.md)
Anti-patterns (flag in review)
| Anti-pattern | Fix |
|---|---|
{{ attributes }} on root without defaults |
{{ attributes.defaults({...}) }} |
Hardcoded class="..." on root |
class="{{ ('<base> ' ~ attributes.render('class'))|tailwind_merge }}" |
Variant via {% if variant == ... %} chains |
html_cva(base, variants).apply({...})|tailwind_merge |
Trigger.html.twig wraps own <button> |
Expose <recipe>_trigger_attrs + use {%- block content %}{% endblock -%} only |
data-action="click->x#y" not piped |
'click->x#y'|html_attr_type('sst') |
Missing data-slot on root/sub-roots (Shadcn) |
Add data-slot="<recipe>" / data-slot="<recipe>-<sub>" |
Missing {# @prop #} / {# @block #} docblocks |
Add docblocks before {%- props -%} |
Self-closing item reading _parent_var (outer-scope) |
Use provide() in parent + inject() in child |
Recipe depends on another recipe but dependencies.recipe empty |
Declare it (e.g. toggle-group → toggle) |
| Snapshots not regenerated / partially stale | Regenerate via simple-phpunit -d --update-snapshots |
| Multiple recipes in one PR | Split into one PR per recipe |
PR targets 2.x |
Retarget to 3.x, move CHANGELOG entry |
| Missing companion PR on ux.symfony.com | Open it, link from recipe PR |
Native <details>/<summary> when upstream has animation/ARIA parity |
Replace with <div> + Stimulus controller |
| Subset of upstream examples | Reuse full set |
hidden class for collapse/expand |
grid-template-rows: 0fr + overflow:hidden + CSS transition |
group-hover + group-focus-within for hover-triggered components |
Stimulus controller with openDelay/closeDelay values |
in-data-[state=open]:visible on nested open-state |
Named Tailwind groups (group/<recipe>-menu, group/<recipe>-sub) |
| Orphan snapshots after recipe rework/rename | git rm stale files after --update-snapshots |
Bad / Good
| Bad | Good |
|---|---|
<button class="..." data-action="click->dialog#open">{% block content %}{% endblock %}</button> |
{%- set dialog_trigger_attrs = { 'data-action': 'click->dialog#open'|html_attr_type('sst'), 'data-dialog-target': 'trigger', 'aria-haspopup': 'dialog' } -%}{%- block content %}{% endblock -%} |
<div class="text-lg leading-none font-semibold {{ attributes.render('class') }}" {{ attributes }}> |
<div class="{{ ('text-lg leading-none font-semibold ' ~ attributes.render('class'))|tailwind_merge }}" {{ attributes.defaults({'data-slot': 'dialog-title'}) }}> |
<twig:RadioGroup:Item value="a" /> reading {% set _radio_group_name = ... %} from parent |
Keep name as explicit prop on RadioGroup:Item (self-closing) |