Uithing
BayBreezy/ui-thing
Manages UI Thing components, prose, blocks, themes, shortcuts, docs, and MCP-driven workflows in Nuxt projects and in the UI Thing source repo.
Generate, modify, or review Symfony UX Toolkit kit recipes (shadcn, flowbite-4, bootstrap, common).
$ npx skills add symfony/ux --skill symfony-ux-toolkit-kit -a claude-codeProject install by default; add -g for ~/.claude/skills/.
$ gh skill install symfony/ux symfony-ux-toolkit-kit --agent claude-codeProject scope by default; add --scope user for a personal install. Needs GitHub CLI 2.90.0 or later (public preview).
$ git clone --depth 1 https://github.com/symfony/ux.git skills-src && mkdir -p .claude/skills && cp -r skills-src/.agents/skills/symfony-ux-toolkit-kit .claude/skills/symfony-ux-toolkit-kit && rm -rf skills-srcUse ~/.claude/skills/ instead of .claude/skills for a personal install. The folder must contain SKILL.md.
Claude Code skills documentation · loads skills from .claude/skills/
Install the "symfony-ux-toolkit-kit" agent skill from https://github.com/symfony/ux/tree/3.x/.agents/skills/symfony-ux-toolkit-kit into .claude/skills/symfony-ux-toolkit-kit/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "symfony-ux-toolkit-kit", then confirm the skill loads.Claude Code copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$skill-installer install https://github.com/symfony/ux/tree/3.x/.agents/skills/symfony-ux-toolkit-kitType this inside Codex. $skill-installer <name> installs a curated skill from openai/skills. The installer writes to $CODEX_HOME/skills (default ~/.codex/skills). Restart Codex if the skill does not show up.
$ npx skills add symfony/ux --skill symfony-ux-toolkit-kit -a codexProject install goes to .agents/skills/; add -g for ~/.codex/skills/.
$ gh skill install symfony/ux symfony-ux-toolkit-kit --agent codexProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/symfony/ux.git skills-src && mkdir -p .agents/skills && cp -r skills-src/.agents/skills/symfony-ux-toolkit-kit .agents/skills/symfony-ux-toolkit-kit && rm -rf skills-srcUse ~/.agents/skills/ instead of .agents/skills for a personal install.
Codex skills documentation · loads skills from .agents/skills/
Install the "symfony-ux-toolkit-kit" agent skill from https://github.com/symfony/ux/tree/3.x/.agents/skills/symfony-ux-toolkit-kit into .agents/skills/symfony-ux-toolkit-kit/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "symfony-ux-toolkit-kit", then confirm the skill loads.Codex copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ npx skills add symfony/ux --skill symfony-ux-toolkit-kit -a cursorProject install goes to .agents/skills/; add -g for ~/.cursor/skills/.
$ gh skill install symfony/ux symfony-ux-toolkit-kit --agent cursorProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/symfony/ux.git skills-src && mkdir -p .cursor/skills && cp -r skills-src/.agents/skills/symfony-ux-toolkit-kit .cursor/skills/symfony-ux-toolkit-kit && rm -rf skills-srcUse ~/.cursor/skills/ instead of .cursor/skills for a personal install.
Cursor skills documentation · loads skills from .cursor/skills/, .agents/skills/, .claude/skills/, .codex/skills/
Install the "symfony-ux-toolkit-kit" agent skill from https://github.com/symfony/ux/tree/3.x/.agents/skills/symfony-ux-toolkit-kit into .cursor/skills/symfony-ux-toolkit-kit/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "symfony-ux-toolkit-kit", then confirm the skill loads.Cursor copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ gemini skills install https://github.com/symfony/ux.git --path .agents/skills/symfony-ux-toolkit-kit--scope user (default) or --scope workspace; --path is the subfolder of the repo that holds the skill; --consent skips the security confirmation prompt.
$ npx skills add symfony/ux --skill symfony-ux-toolkit-kit -a gemini-cliProject install goes to .agents/skills/; add -g for ~/.gemini/skills/.
$ gh skill install symfony/ux symfony-ux-toolkit-kit --agent gemini-cliProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/symfony/ux.git skills-src && mkdir -p .gemini/skills && cp -r skills-src/.agents/skills/symfony-ux-toolkit-kit .gemini/skills/symfony-ux-toolkit-kit && rm -rf skills-srcUse ~/.gemini/skills/ instead of .gemini/skills for a personal install, then run /skills reload.
Gemini CLI skills documentation · loads skills from .gemini/skills/, .agents/skills/
Install the "symfony-ux-toolkit-kit" agent skill from https://github.com/symfony/ux/tree/3.x/.agents/skills/symfony-ux-toolkit-kit into .gemini/skills/symfony-ux-toolkit-kit/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "symfony-ux-toolkit-kit", then confirm the skill loads.Gemini CLI copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ gh skill install symfony/ux symfony-ux-toolkit-kitInstalls for Copilot at project scope by default; add --scope user for a personal install. Preview a skill first with gh skill preview. Needs GitHub CLI 2.90.0 or later (public preview).
$ npx skills add symfony/ux --skill symfony-ux-toolkit-kit -a github-copilotProject install goes to .agents/skills/; add -g for ~/.copilot/skills/.
$ git clone --depth 1 https://github.com/symfony/ux.git skills-src && mkdir -p .github/skills && cp -r skills-src/.agents/skills/symfony-ux-toolkit-kit .github/skills/symfony-ux-toolkit-kit && rm -rf skills-srcUse ~/.copilot/skills/ instead of .github/skills for a personal install. Commit .github/skills so cloud agent and code review can use it.
GitHub Copilot skills documentation · loads skills from .github/skills/, .claude/skills/, .agents/skills/
Install the "symfony-ux-toolkit-kit" agent skill from https://github.com/symfony/ux/tree/3.x/.agents/skills/symfony-ux-toolkit-kit into .github/skills/symfony-ux-toolkit-kit/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "symfony-ux-toolkit-kit", then confirm the skill loads.GitHub Copilot copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ npx skills add symfony/ux --skill symfony-ux-toolkit-kit -a opencodeOpenCode documents no install command of its own. Project install goes to .agents/skills/; add -g for ~/.config/opencode/skills/.
$ gh skill install symfony/ux symfony-ux-toolkit-kit --agent opencodeProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/symfony/ux.git skills-src && mkdir -p .opencode/skills && cp -r skills-src/.agents/skills/symfony-ux-toolkit-kit .opencode/skills/symfony-ux-toolkit-kit && rm -rf skills-srcUse ~/.config/opencode/skills/ instead of .opencode/skills for a personal install.
OpenCode skills documentation · loads skills from .opencode/skills/, .claude/skills/, .agents/skills/
Install the "symfony-ux-toolkit-kit" agent skill from https://github.com/symfony/ux/tree/3.x/.agents/skills/symfony-ux-toolkit-kit into .opencode/skills/symfony-ux-toolkit-kit/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "symfony-ux-toolkit-kit", then confirm the skill loads.OpenCode copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
symfony-ux-toolkit-kitGenerate, modify, or review Symfony UX Toolkit kit recipes (shadcn, flowbite-4, bootstrap, common).
Symfony UX Toolkit Kit is an agent skill from symfony/ux. Generate, modify, or review Symfony UX Toolkit kit recipes (shadcn, flowbite-4, bootstrap, common). Enforces conventions for manifest, README examples, Twig prop/block doc comments, sub-components, asChild <recipe<roleattrs pattern, provide()/inject() context, Stimulus controllers, snapshots, and PR hygiene. Use when adding/editing files under src/Toolkit/kits/ or reviewing PRs touching the Toolkit.
Its SKILL.md is about 10k tokens, which your agent loads only when the skill is triggered. It is a single SKILL.md file with no bundled scripts.
It sits in Development, covering Design systems and Technical documentation. It works with Symfony and shadcn/ui. The repository describes itself as: Symfony UX initiative: a JavaScript ecosystem for Symfony. The licence is MIT.
7 steps, taken from the step headings in SKILL.md.
Read from SKILL.md and the folder at commit 7da41b9. It shows what the files ask for, not the result of running them.
Pre-approves nothing: there is no allowed-tools line, so your agent's usual permission prompts apply.
From allowed-tools in the SKILL.md frontmatter.
Shell commands in SKILL.md call:
gitphppnpmghFrom the folder's file list and the shell code blocks in SKILL.md.
Hosts in commands or code, which the agent is likely to contact:
flowbite.comgithub.comraw.githubusercontent.comux.symfony.comFrom URLs in SKILL.md, links to its own repository left out.
Names no API keys, tokens, secrets or passwords.
From names ending in _API_KEY, _TOKEN, _SECRET, _KEY or _PASSWORD in SKILL.md.
Symfony UX Toolkit Kit loads about 10k tokens when it runs. Until then it costs about 108 tokens; SKILL.md has 3,808 words of instructions outside code blocks.
Estimates: characters ÷ 4, the usual rule of thumb; real counts depend on the model's tokenizer. Scripts and assets cost tokens only if the agent reads them.
The automated check found no risky patterns in SKILL.md.
Automated static check — not a guarantee. Review scripts before installing. It scans the text of SKILL.md for risky patterns (piping downloads into a shell, reading credential files, hidden Unicode, destructive commands); files beside SKILL.md are not scanned.
The full file from symfony/ux at commit 7da41b9, republished under its MIT licence (© symfony). 3,808 words, ~10,411 tokens.
.claude/skills/symfony-ux-toolkit-kit/SKILL.md (or your agent's skills folder).Author + review recipes for UX Toolkit. Recipes = unit shipped to end-users (Twig components + optional Stimulus controllers). Each recipe carries a README.md — its single doc source (description, live-preview examples, install/API), rendered as-is on ux.symfony.com.
[Toolkit][<Kit>] Add <recipe> recipe or [Toolkit][<Kit>] Align <recipe> with <upstream> reference.3.x. CHANGELOG entry under active 3.x section in src/Toolkit/CHANGELOG.md.README.md (see Examples).symfony/ux.symfony.com. It renders the docs page from the recipe README.md, registers kit Stimulus controllers from a build-time loader, and compiles kit Tailwind classes through @source. Only a new external asset dependency not already vendored there needs one.bin/update_toolkit_tests.sh <kit>/<recipe> from the repository root (Docker required). CI + reviewers reject stale ones.Part of #3233 for shadcn recipes, the shadcn tracking issue). Fabbot fails otherwise.<details>) when parity needs animations, ARIA sync, coordinated state. Native fine only when matches upstream UX exactly.src/Toolkit/kits/<kit>/<recipe>/
├── manifest.json
├── README.md # single doc source: description + inline live-preview examples
├── 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.jsSub-component file path Component/SubName.html.twig consumed as <twig:Component:SubName>.
There is no examples/ directory — examples live inline in README.md (see Examples). Recipe copy-files only copies templates/ (+ assets/); the README is doc-only, never shipped to the user's app.
README.md structure# <Human Name>
<One-sentence description.> <!-- first paragraph; read by Recipe::getDescription(), the manifest has no description -->
```twig {"preview":true,"height":"300px"}
<!-- hero preview: rich showcase -->
<twig:Recipe ... />
```
## Installation
::: installation <!-- directive, expanded by the renderer -->
## Usage
```twig
<!-- STATIC block (no preview): minimal API surface -->
<twig:Recipe prop="a | b" />
```
## Examples
### <Variant Name>
<Optional sentence describing the variant.>
```twig {"preview":true,"height":"150px"}
<!-- one live-preview block per upstream example -->
<twig:Recipe ... />
```
### RTL <!-- always last Examples subsection (see RTL examples) -->
## API Reference
::: api-reference <!-- directive, expanded by the renderer -->
```Info-string options on a preview block (JSON after the language):
"preview":true — required marker that turns the block into a live iframe + Code tab. Without it the block is a plain static snippet."height":"<px>" — iframe height (e.g. "150px", "300px"); default 200px."collapseClass":true — collapse long class="..." attributes in the Code tab (use for examples with long Tailwind class lists, e.g. post-link).READMEs use exactly two directives: ::: installation and ::: api-reference.
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.
Read all source files per recipe: component source carries the structure + data-* surface, the stylesheet carries the classes, examples show usage patterns, MDX drives docs and manifest.
| File | Purpose |
|---|---|
apps/v4/registry/bases/radix/ui/<recipe>.tsx | Component source — sub-component structure, data-slot/data-state surface, variant axes. Carries cn-* class names, not Tailwind utilities |
apps/v4/registry/styles/style-nova.css | Canonical classes — the .cn-<recipe>* rules those names resolve to, written as @apply Tailwind utilities. This is what a recipe's class strings are ported from |
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 as the first paragraph of the recipe README.md |
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>.tsxUpstream ships no RTL class variants. The .cn-* rules in style-nova.css use physical properties (text-left, mr-1, ml-1). RTL support is therefore authored here, not ported — the upstream classes are the LTR reading, and adapting them is the recipe's job.
Reach for a logical utility first. ms-*, me-*, ps-*, pe-*, start-*, end-*, text-start, text-end already flip with the text direction, so they need no variant prefix at all. Porting mr-1 gives me-1, and text-left gives text-start — one token, correct in both directions.
Never pair a physical class with its own logical equivalent. ltr:text-left rtl:text-start renders exactly like a bare text-start, and ltr:before:mr-1 rtl:before:me-1 exactly like before:me-1. The pair costs two tokens for one rule, has to be kept in sync on every edit, and makes a rtl: grep return noise instead of the handful of places that genuinely differ.
Use rtl: only where no logical property exists — a mirrored glyph or transform, typically. Keep those verbatim:
rtl:rotate-180 # a chevron that must point the other way
rtl:translate-x-1/2 # no logical equivalent for translateScope them tightly: an icon inside a vertically-oriented component is not direction-dependent, so rtl:rotate-180 there points the arrow the wrong way.
Kit identifier: flowbite-4.
| 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.
ux and ux.symfony.com must be on matching branches; mismatch causes assetmap failures:
The asset "./vendor/symfony/ux-toolkit/kits/
<kit>/<recipe>/assets/controllers/<recipe>_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 main
# In ux.symfony.com:
php ../link
symfony php bin/console tailwind:build
symfony serve -dmanifest.jsonsrc/Toolkit/kits/<kit>/manifest.json){
"$schema": "../../schema-kit-v1.json",
"name": "<Display Name>",
"description": "...",
"license": "MIT",
"homepage": "https://ux.symfony.com/toolkit/kits/<kit>"
}src/Toolkit/kits/<kit>/<recipe>/manifest.json){
"$schema": "../../../schema-kit-recipe-v1.json",
"type": "component",
"name": "<Human Name>",
"version-added": "<Toolkit version the recipe first ships in, e.g. 3.5>",
"copy-files": {
"assets/": "assets/",
"templates/": "templates/"
},
"dependencies": {
"composer": [
"twig/extra-bundle",
"twig/html-extra:^3.24.0",
"symfony/ux-twig-component:^3.5",
"tales-from-a-dev/twig-tailwind-extra:^1.3.0"
],
"recipe": ["<other-recipe>"]
}
}Rules:
assets/ from copy-files if no Stimulus controller."symfony/ux-icons" to composer whenever templates use <twig:ux:icon>.twig/html-extra to ^3.24.0 for html_attr_type / tailwind_classes. The tailwind_classes class-merge idiom also needs tales-from-a-dev/twig-tailwind-extra:^1.3.0 and symfony/ux-twig-component:^3.5 (enforced by ComposerSymbolChecker).dependencies.recipe only for recipes required by the component templates themselves (e.g. toggle-group depends on toggle). Do NOT declare recipe deps for components used only in examples — examples are demo files, not shipped dependencies.Document every prop with a ## <type> <Description.> comment on the line above it inside the
{% props %} tag, and every rendered block with a {##- <Description.> -#} doc comment on its own
line right above it. These are Twig 3.29 documentation comments — Twig attaches them to the following
node as metadata, so they carry no runtime cost and the Toolkit reads them natively:
{%- props
## string Unique identifier used to generate internal Dialog IDs.
id,
## boolean Whether the dialog is open on initial render.
open = false
-%}
...
<div ...>
{##- The dialog structure, typically includes `Dialog:Trigger` and `Dialog:Content`. -#}
{%- block content %}{% endblock -%}
</div>Format is enforced by bin/ux-toolkit-kit-lint (CI fails on any warning — see Docblock linting):
## <type> <Description.> — one per line, on the line directly above the prop name, inside {% props %}. Type first (camelCase name matches the declared prop), then the description.'default'|'secondary', string|array<string>|null, boolean, number. A space breaks the type/description boundary, so 'a' | 'b' is rejected — write 'a'|'b'.Defaults to in the documentation. Default values live only in {%- props -%} (single source of truth); the linter and ux.symfony.com read them from there.{##- <Description.> -#} — a doc comment (double #) on its own line directly above a block actually rendered in the template ({% block x %}, block(outerBlocks.x), or block('x')). Mirror the block's whitespace-trim so the rendered output is unchanged: {##- ... -#} when the block opens with {%-/{{-, {## ... -#} when it opens with {%/{{. Never leave a rendered block undocumented.\Dialog:Trigger``).twig/twig >= 3.29 (documentation comments) and symfony/ux-twig-component with PropsNode::getPropDocumentation().There is one home for each kind of attribute. attributes.defaults({...}) carries the consumer-overridable values: the merged class (as a tailwind_classes mergeable), data-controller, data-action, aria-label, and genuinely overridable HTML defaults (type: 'button', alt: ''). Everything that identifies or reflects the component's state is rendered directly as a literal attribute, so it is always present and can neither be dropped by the merge nor overridden:
class → merged inside defaults(): class: '<base>'|tailwind_classes (or class: style.apply({...})|tailwind_classes with html_cva). tailwind_classes returns a mergeable value that defaults() merges with the consumer's class (consumer wins). No separate class="..." / render('class').('<base> ' ~ attributes.render('class'))|tailwind_merge when class and the attributes sink are on different elements (base on an outer element, sink on an inner one), or when class is spread onto an external non-mergeable component — e.g. <twig:ux:icon>, which validates class as a scalar string and rejects the tailwind_classes object. Spreading onto a mergeable Toolkit child (<twig:Button>, <twig:Label>, <twig:Separator>, …) is fine: defaults() chains the mergeables as base < wrapper < caller, matching React/Vue's cn(base, className).data-slot → literal attribute (data-slot="<recipe-name>"). Structural Shadcn marker, never overridable.Button, Input, Textarea, Label, Separator, Field:Group, …, e.g. AlertDialog:Action renders <twig:Button data-slot="alert-dialog-action">, or an asChild bag carries 'data-slot': 'dialog-trigger') reads it first: data-slot="{{ attributes.render('data-slot')|default('button') }}". A second data-slot would be lost, since the browser keeps the first one; render() marks it as rendered, so defaults() does not print it again. A parent style hook on such a component cannot rely on its default slot: give it a dedicated marker, as upstream does with data-sidebar="menu-action". ComponentsRenderingTest fails on any element rendered with two data-slot.data-* and state aria-* → literal attributes, always emitted with an explicit value (never {% if %}-guarded, never x ? 'attr="y"'): data-state, data-open, data-closed, data-active, data-disabled, data-orientation, data-size, data-variant, data-side, data-selected, data-checked; aria-expanded, aria-selected, aria-hidden, aria-disabled, aria-checked, aria-pressed, aria-current. Boolean values render as strings: data-open="{{ open ? 'true' : 'false' }}" (a bare : false renders empty/ambiguous — always use : 'false').data-<recipe>-<key>-value), id, role, ARIA id-refs (aria-controls/aria-labelledby/aria-describedby) → literal attributes.aria-label → the one ARIA attribute that belongs inside defaults(): 'aria-label': 'pagination'. It is an accessible name, not a state, so a consumer must be able to replace it to name a landmark more precisely or to translate it. defaults() lets the consumer's value win and emits the attribute only once; written as a literal next to the sink it would be emitted twice, and the browser keeps the first occurrence, so the hard-coded label would always win. Same for a label/ariaLabel prop: pass it through defaults() ('aria-label': label), not as a literal.class + data-controller / data-action + aria-label (+ overridable HTML defaults) → attributes.defaults({...}). A bare {{ attributes }} remains only where there is no class base and no default label.{# WITH a controller/action (interactive component) #}
<div
id="{{ id }}"
data-slot="<recipe-name>"
data-<recipe>-<key>-value="{{ value }}"
data-orientation="{{ orientation }}"
aria-labelledby="{{ _<recipe>_title_id }}"
{{ attributes.defaults({
class: '<base classes>'|tailwind_classes,
'data-controller': '<recipe>',
'data-action': 'click-><recipe>#toggle',
}) }}
>
{%- block content %}{% endblock -%}
</div>
{# WITHOUT a controller/action (static component) #}
<div
data-slot="<recipe-name>"
{{ attributes.defaults({
class: '<base classes>'|tailwind_classes,
}) }}
>
{%- block content %}{% endblock -%}
</div>data-slot, state data-*, state aria-* or Stimulus data-*-value into defaults() — they belong as literal attributes (enforced by AttributesDefaultsChecker). Of the data-*/aria-* keys, only data-controller, data-action and aria-label belong in defaults().type="checkbox") into defaults() — those are structural, not overridable.aria-orientation on a decorative separator, data-bs-parent, presence-marker attributes like data-horizontal/data-vertical.class is merged inside defaults() as a plain string (attributes.defaults({class: '...'}), no tailwind_classes), there is no data-slot, and no tailwind_merge. Only the data-slot rule and the state-attribute rule apply there; the linter's Tailwind-only checks are skipped for them.html_cva{%- set style = html_cva(
base: '<base classes>',
variants: {
variant: { default: '...', outline: '...' },
size: { default: '...', sm: '...', lg: '...' },
},
) -%}
<button {{ attributes.defaults({ class: style.apply({variant: variant, size: size})|tailwind_classes }) }}>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:
'<camelCaseRecipe>.<key>' (e.g. 'inputOtp.maxLength', 'tabs.active', 'toggleGroup.variant'). Prefix avoids collisions across recipes._<camelCaseRecipe>_<key> (e.g. _tabs_defaultValue, _toggleGroup_variant). The _ prefix + recipe name prevents collision with the child's own props or Twig globals.inject() when child can render standalone.provide() at top of parent template, before {% block content %} — descendants only see values published before their render.<recipe>.id, <recipe>.titleId, <recipe>.descriptionId, <recipe>.contentId, <recipe>.triggerId from parent's id prop.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.
<recipe>_<role>_attrs (asChild) patternSub-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 #}
{%- set dialog_trigger_attrs = {
'data-action': 'click->dialog#open'|html_attr_type('sst'),
'data-dialog-target': 'trigger',
'aria-haspopup': 'dialog',
} -%}
{##- The trigger element (e.g., a `Button`) that opens the dialog when clicked. -#}
{%- block content %}{% endblock -%}{# example consumer #}
<twig:Dialog:Trigger>
<twig:Button {{ ...dialog_trigger_attrs }}>Open</twig:Button>
</twig:Dialog:Trigger>Rules:
<snake_case_recipe>_<role>_attrs — dialog_trigger_attrs, dialog_close_attrs, tooltip_trigger_attrs, collapsible_trigger_attrs, alert_dialog_trigger_attrs.|html_attr_type('sst') to data-action. 'sst' = Stimulus Shorthand Token — marks value appendable, so consumer spreading {{ ...dialog_trigger_attrs }} alongside own data-action gets both merged rather than first overwritten.{%- block content %}{% endblock -%} only — no wrapping element, otherwise variable not visible to outer scope.AlertDialog:Action):{%- props
## 'default'|'destructive' The visual style variant.
variant = 'default'
-%}
<twig:Button variant="{{ variant }}" {{ ...attributes }}>
{##- The action button label. -#}
{{- block(outerBlocks.content) -}}
</twig:Button>grid-template-rows, never hiddenhidden (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.
Emit as literal attributes (outside defaults()), always present with an explicit value — never {% if %}-guarded, never x ? 'attr="y"' (see section 2):
role, aria-haspopup, aria-expanded, aria-controls, aria-labelledby, aria-describedby, aria-disabled, aria-pressed, aria-hidden, aria-current, aria-selected, aria-checked.data-state="open|closed|active|inactive", data-orientation="vertical|horizontal", data-variant, data-size, data-disabled, data-open, data-closed, data-active.data-open="{{ open ? 'true' : 'false' }}", aria-expanded="{{ open ? 'true' : 'false' }}" (a bare : false renders empty/ambiguous).id prop (e.g. aria-controls={{ _accordion_item_content_id }}).aria-orientation only when a separator is not decorative).aria-label is not part of this surface: it is an accessible name the consumer must be able to replace, so it goes inside defaults() (see section 2).bin/ux-toolkit-kit-lint validates every component's prop and block documentation against the template. CI runs it per kit with --fail-on-warning, so any violation fails the build.
cd src/Toolkit
php bin/ux-toolkit-kit-lint --fail-on-warning kits/<kit>Checks:
## <type> <Description.> — valid spaceless PHPStan type, description Capitalized + ending with a period. Every declared prop in {%- props -%} has a ## ... doc comment above it and vice versa.{##- <Description.> -#} — description Capitalized + ending with a period. Every rendered block ({% block x %} / block(outerBlocks.x) / block('x')) has a {##- ... -#} doc comment on the line above it.{%- props -%}.assets/controllers/<recipe>_controller.js:
import { Controller } from '@hotwired/stimulus';
/**
* @value open Whether the component is open on initial render.
* @target trigger The element that toggles the component and reflects its expanded state.
* @target content The region shown or hidden when the component toggles.
* @action open Opens the component.
* @action close Closes the component.
*/
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() { /* ... */ }
}@hotwired/stimulus.aria-expanded, data-state).if (el.getAnimations().length > 0) el.addEventListener('transitionend', ..., { once: true });.<recipe>_controller.js, controller identifier <recipe> (kebab-case in Twig).keydown listeners:data-action="keydown.enter->{{ recipe }}#toggle keydown.space->{{ recipe }}#toggle"|html_attr_type('sst') when exposing via <recipe>_<role>_attrs so consumers can append own actions.group-hover + group-focus-within + tabindex=0; use Stimulus controller with openDelay/closeDelay values instead (see anti-patterns).in-data-[state=open]:visible on nested components; use named Tailwind groups (group/<recipe>-menu, group/<recipe>-sub) instead (see anti-patterns).A controller's public API is documented with a /** ... */ docblock placed immediately before export default class, using @value/@target/@action tags. RecipeDocRenderer renders these under ::: api-reference (the same block that documents Twig component props), which is the only way a controller-only recipe — one with no templates/components/*.html.twig — surfaces an API reference. bin/ux-toolkit-kit-lint validates them via StimulusControllerDocChecker (see Docblock linting).
Add the docblock only to controllers whose API you want shown. It is a deliberate, per-controller choice — not a blanket requirement. Document a controller when its data-controller/data-* surface is meant to be used or overridden directly by the consumer (always true for a controller-only recipe). Omit it — leaving the controller undocumented and rendering no API reference — when the controller is an internal implementation detail the consumer never touches directly (they drive it through the recipe's Twig components, which carry their own prop/block documentation). Documenting such a controller would surface data-* internals as if they were public API.
@<tag> <name> <Description.> — name first, then a one-sentence description that starts Capitalized and ends with a period. Align columns for readability (whitespace is normalized). Do not document types or defaults in the docblock — the renderer reads value types/defaults from the static values declaration.@value <name> — one per key in static values. For object-form values (open: { type: Boolean, default: false }), document the top-level key (open), never the inner type/default.@target <name> — one per string in static targets.@action <name> — opt-in: document only the public methods actually wired via data-action in the recipe's templates (find them with grep -rhoE '<identifier>#[a-zA-Z]+' templates). Never document private methods (_/# prefixed) or lifecycle methods (connect/disconnect). Every @action must match a real method.static values key and every static targets string to have a matching tag (and vice versa) — partial documentation fails CI. So the choice is per-controller: document its whole surface, or leave it entirely undocumented.<recipe>_controller.js → <recipe>; nested _ → -, e.g. alert_dialog_controller.js → alert-dialog), and each value's data-* attribute is derived from it (autoClose on widget → data-widget-auto-close-value).Examples are inline in README.md, not separate files. Each is an ### <Variant Name> subsection under ## Examples, followed by an optional one-sentence description and a live-preview block:
### With Icon
You can render an icon inside the badge.
```twig {"preview":true,"height":"150px"}
<twig:Badge variant="secondary">
<twig:ux:icon name="lucide:badge-check" data-icon="inline-start" />
Verified
</twig:Badge>
```With Icon, Custom Colors, Different Sizes, File Tree).## Examples: the hero preview right after the description (rich showcase) and the ## Usage static ```twig block — minimal call surface, no preview.### <Variant> per upstream variant. Match upstream copy/structure where possible.language-selector), replicate intent without inventing new infrastructure (e.g. stack two independent components in one block, see collapsible's ### RTL).### RTL subsection under ## Examples — always last, ### (not ##).dir="rtl"), stacked vertically — no side-by-side LTR/RTL comparison.To enable RTL support, set the \dir="rtl"` attribute on the root element.`Two kinds of baselines guard each recipe:
ComponentsRenderingTest renders every preview block of the recipe README.md. It snapshots the HTML in src/Toolkit/tests/Functional/__snapshots__/, keyed by example index (... Kit shadcn, component badge, example 5__1.html). The key is the position in the README, not a name.kits/<kit>/<recipe>/tests/screenshots/<example>-<theme>.png. The file name comes from the slug of the heading above the example (default under the title). Renaming a heading renames its screenshots.Regenerate both with one command from the repository root (Docker required):
bin/update_toolkit_tests.sh <kit>/<recipe> # or <kit>, or nothing for every kit
git add src/Toolkit/tests/Functional/__snapshots__ src/Toolkit/kits/<kit>/<recipe>/testsThe script only rewrites a screenshot when it no longer matches, using the tolerance of Playwright's comparison, so rendering noise doesn't produce a diff. It also deletes the snapshots and screenshots that no test uses anymore, like the ones of a removed, reordered or renamed example. Check git status for deleted files + commit the deletions too.
After rebase on 3.x: the snapshot formatter may have evolved upstream. Run the script once more after the final rebase to avoid "diff in snapshots" CI failures.
A recipe is interactive in three cases. It ships a Stimulus controller. It depends on a recipe that ships one (shadcn/sheet depends on dialog). Or it uses Bootstrap's JS (data-bs-toggle, data-bs-dismiss, data-bs-ride, data-bs-slide). Every interactive recipe needs a spec in kits/<kit>/<recipe>/tests/<recipe>.spec.ts. src/Toolkit/assets/test/browser/interactions.spec.ts enforces this rule. A new interactive recipe ships with its spec.
A spec follows the flow idle -> screenshot -> action -> screenshot. The generic spec already takes the idle screenshot. The recipe spec performs the action. It asserts the result. Then it screenshots the new state with testState():
import { describeRecipe, expect, test, testState } from '../../../../assets/test/browser/fixtures';
describeRecipe('shadcn/popover', () => {
testState('opens on click', {
example: 'default',
state: 'open',
act: async (page) => {
const trigger = page.getByRole('button', { name: 'Open Popover' });
await trigger.click();
await expect(page.getByRole('dialog')).toBeVisible();
await expect(trigger).toHaveAttribute('aria-expanded', 'true');
},
});
test('closes on Escape and gives focus back to the trigger', async ({ page, gotoExample }) => {
// ...
});
});testState() runs once per theme. It saves <example>-<state>-<theme>.png in the recipe's tests/screenshots/. Behavior that shows no new visual state (closing, focus, keyboard) goes in a plain test(). gotoExample() only fixes the date with timers: 'fake'. Playwright's fake clock also fakes requestAnimationFrame. So keep the default timers: 'real' when a controller waits on animation frames. Always take a screenshot through testState(), never with a direct toHaveScreenshot() call, because bin/update_toolkit_tests.sh deletes any screenshot that no test declares.
manifest.json<recipe>_<role>_attrs), Stimulus controller if neededREADME.md: description + hero preview, ## Installation (::: installation), ## Usage static block, ## Examples with one ### <Variant> live-preview per upstream example (+ ### RTL last), ## API Reference (::: api-reference)bin/update_toolkit_tests.sh <kit>/<recipe>, inspect the HTML diff + the images, commit3.xPart of #3233 for shadcn)3.x{"preview":true} blocks in README.md, ### <Variant> headings Title CaseREADME.md has the hero preview + ## Usage static block + ::: installation / ::: api-reference directivestests/<recipe>.spec.tsphp-cs-fixer, twig-cs-fixer, pnpm run fmt, pnpm run lint cleanbin/ux-toolkit-kit-lint --fail-on-warning kits/<kit> clean## <type> <Description.> above each prop in {% props %} + {##- <Description.> -#} on the line above each rendered block (trim mirrors the block); descriptions Capitalized + ending with a period; prop types are spaceless PHPStan types; no Defaults to (defaults live in {%- props -%}); every rendered block documentedattributes.defaults() holds the merged class ('<base>'|tailwind_classes), data-controller/data-action, aria-label (+ overridable HTML defaults); data-slot, state data-*, state aria-* and Stimulus data-*-value are literal attributes; state attrs always emitted with an explicit value (tailwind_merge kept only in different-element / external-component exceptions)<recipe>_<role>_attrs (no wrapping <button>)data-action Stimulus actions piped through |html_attr_type('sst') when concatenablemanifest.json dependencies.recipebin/update_toolkit_tests.sh committed).html.twig, .json, .js, .css, .md)| Anti-pattern | Fix |
|---|---|
{{ attributes.defaults({}) }} with empty or no meaningful defaults | {{ attributes }} when no defaults needed; {{ attributes.defaults({...}) }} for the merged class (tailwind_classes), data-controller/data-action, aria-label (+ overridable HTML defaults like type) |
data-slot, state aria-*, Stimulus data-*-value or state data-* inside defaults() | Render them as literal attributes outside defaults(); the only data-*/aria-* keys left in defaults() are data-controller/data-action and aria-label (enforced by AttributesDefaultsChecker) |
Literal aria-label="..." / aria-label="{{ label }}" on the element carrying the attributes sink | Move it into defaults() ('aria-label': label) — a literal one is emitted twice and the browser keeps the hard-coded first occurrence |
State attr conditionally emitted ({{ open ? 'data-state="open"' }}) or bare : false (data-open="{{ open ? 'true' : false }}") | Always emit with explicit string value (data-state="{{ open ? 'open' : 'closed' }}", ... : 'false') |
Separate class="{{ ('<base> ' ~ attributes.render('class'))|tailwind_merge }}" + trailing {{ attributes }} (Tailwind kits) | Merge inside defaults(): {{ attributes.defaults({ class: '<base>'|tailwind_classes }) }} (keep tailwind_merge only for different-element cases or spreads onto an external non-mergeable component like <twig:ux:icon>) |
Variant via {% if variant == ... %} chains | attributes.defaults({ class: html_cva(base, variants).apply({...})|tailwind_classes }) |
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 ## <type> <desc> prop comment / {##- <desc> -#} block comment | Add ## ... above the prop in {% props %} / {##- ... -#} above the block |
Defaults to \...`in a## ...` prop comment | Remove it — the default lives only in {%- props -%} |
Prop type with spaces ('a' | 'b') | Remove spaces ('a'|'b') |
| Docblock description not Capitalized / no trailing period | Capitalize + end with a period |
Rendered block ({% block x %}) with no {##- ... -#} doc comment | Add {##- <Description.> -#} on the line above the block |
| Block doc comment that shifts rendered whitespace (wrong trim) | Mirror the block's trim: {##- ... -#} for {%-/{{-, {## ... -#} for {%/{{ |
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 bin/update_toolkit_tests.sh <kit>/<recipe> |
| Multiple recipes in one PR | Split into one PR per recipe |
PR targets 2.x | Retarget to 3.x, move CHANGELOG entry |
Companion PR opened on symfony/ux.symfony.com | Close it: docs, controllers and Tailwind classes are picked up from the recipe automatically |
Native <details>/<summary> when upstream has animation/ARIA parity | Replace with <div> + Stimulus controller |
Example as a separate examples/*.html.twig file or ::: example directive | Inline as a ```twig {"preview":true} block in README.md |
| Subset of upstream examples | Reuse full set, inline in README.md |
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 | Commit the deletions made by bin/update_toolkit_tests.sh |
| 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.defaults({'data-slot': 'dialog-title'}) }}> | <div data-slot="dialog-title" {{ attributes.defaults({ class: 'text-lg leading-none font-semibold'|tailwind_classes }) }}> (data-slot literal; class merged inside defaults() via tailwind_classes) |
<twig:RadioGroup:Item value="a" /> reading {% set _radio_group_name = ... %} from parent | Keep name as explicit prop on RadioGroup:Item (self-closing) |
© symfony, MIT. Rendered from Markdown: HTML in the file is shown as text, images as links, and headings moved down two levels. Raw file
Just SKILL.md in .agents/skills/symfony-ux-toolkit-kit of symfony/ux.
Open the folder on GitHubat commit 7da41b9
Symfony UX Toolkit Kit next to the 5 skills that share the most tags, products or categories with it. Stars are the repository's; “used in” counts other GitHub owners with a copy.
| Skill | Stars | Used in | Tokens | Auto-check | Licence | Repo updated |
|---|---|---|---|---|---|---|
| Symfony UX Toolkit Kit this skillsymfony/ux | 1.1k | — | ~10k | Automated safety check: Pass | MIT | |
| UithingBayBreezy/ui-thing | 729 | — | ~1.3k | Automated safety check: Pass | Custom licence | |
| README Badges and Headersjal-co/shieldcn | 919 | — | ~4.3k | Automated safety check: Pass | MIT | |
| Scaffold Nextjsmblode/agent-skills | 144 | — | ~2.8k | Automated safety check: Pass | MIT | |
| Svelte5 Best PracticesSikandarJODD/cnblocks | 431 | 1 repos | ~810 | Automated safety check: Pass | MIT | |
| Plate UIudecode/plate | 17k | — | ~2.6k | Automated safety check: Pass | Custom licence |
BayBreezy/ui-thing
Manages UI Thing components, prose, blocks, themes, shortcuts, docs, and MCP-driven workflows in Nuxt projects and in the UI Thing source repo.
jal-co/shieldcn
Adds shadcn/ui-styled README badges, badge groups, download charts, header banners and sponsor or contributor grids using the shieldcn service.
mblode/agent-skills
Scaffolds a Next.js turborepo with Blode UI, icons, Ultracite (oxlint/shadcn), workspace hooks, and GitHub/Vercel setup.
SikandarJODD/cnblocks
Svelte 5 runes, snippets, SvelteKit patterns, and modern best practices for TypeScript and component development.
udecode/plate
Build new shadcn-style components for Plate's registry and editor surfaces.
HorusGoul/eslint-plugin-react-render-types
Composition patterns for building React components with @renders type annotations from eslint-plugin-react-render-types.
symfony/ux
Cascade-merge the maintained Symfony UX branches from oldest to newest (2.x - 3.x), resolve conflicts, run the affected packages' tests and prepare the push.
symfony/ux
Principles for rigorously reviewing a Symfony UX pull request and making it merge-ready.
symfony/ux
Triage a security finding in a Symfony UX package into a disposition: a private CVE (coordinated disclosure through the Symfony security process), a public hardening PR (fix in the open, no CVE), or…
symfony/ux
Review a change (a PR, the current branch diff, or a set of files) or audit a Symfony UX package or the whole src/ tree for missing or incorrect security hardening.
Categories
Generate, modify, or review Symfony UX Toolkit kit recipes (shadcn, flowbite-4, bootstrap, common). Symfony UX Toolkit Kit is an agent skill from symfony/ux. Generate, modify, or review Symfony UX Toolkit kit recipes (shadcn, flowbite-4, bootstrap, common).
Symfony UX Toolkit Kit fits situations like: adding/editing files under src/Toolkit/kits/; reviewing PRs touching the Toolkit.
Run `npx skills add symfony/ux --skill symfony-ux-toolkit-kit -a claude-code`. Or copy the skill folder (.agents/skills/symfony-ux-toolkit-kit in symfony/ux) into .claude/skills/symfony-ux-toolkit-kit in your project. Claude Code loads it when a task matches its description.
Run `npx skills add symfony/ux --skill symfony-ux-toolkit-kit -a codex`. Or copy the skill folder (.agents/skills/symfony-ux-toolkit-kit in symfony/ux) into .agents/skills/symfony-ux-toolkit-kit in your project. Codex loads it when a task matches its description.
Cursor, Gemini CLI, GitHub Copilot and OpenCode also load SKILL.md folders. With the skills CLI, run `npx skills add symfony/ux --skill symfony-ux-toolkit-kit -a cursor` (or -a gemini-cli, github-copilot or opencode for the others). To copy it by hand, put the folder in .cursor/skills/symfony-ux-toolkit-kit, .gemini/skills/symfony-ux-toolkit-kit, .github/skills/symfony-ux-toolkit-kit and .opencode/skills/symfony-ux-toolkit-kit in your project.
Going by SKILL.md and its folder, Symfony UX Toolkit Kit needs the command-line tools its instructions call (git, php, pnpm and gh). Our summary lists: Docker.
SKILL.md names 4 domains. In commands or code: flowbite.com, github.com, raw.githubusercontent.com and ux.symfony.com; the agent is likely to contact these when it follows the instructions. This is read from the text; nothing was executed.
Our automated static check of SKILL.md found no risky patterns, such as piping downloads into a shell, reading credential files or hidden Unicode. It is not a guarantee. Review the folder before installing.
Symfony UX Toolkit Kit is published under the MIT licence (the repository's licence). It allows redistribution, so the full SKILL.md is shown on this page.
About 10k tokens (SKILL.md is roughly 42k characters). Agents keep only the skill's name and description in context until a task matches; then they load SKILL.md in full.
Skills that share tags, products or a category with Symfony UX Toolkit Kit: Uithing (BayBreezy/ui-thing, 729 stars), README Badges and Headers (jal-co/shieldcn, 919 stars), Scaffold Nextjs (mblode/agent-skills, 144 stars) and Svelte5 Best Practices (SikandarJODD/cnblocks, 431 stars). The comparison table on this page puts their stars, adoption, token cost, safety result and licence side by side.
symfony (a GitHub organization) maintains it in symfony/ux, which has 1,080 GitHub stars. The repository holds 5 skills in this directory. The repository was last updated on October 10, 2026.
Source: symfony/ux on GitHub. Facts on this page come from the repository at the commit we read; the author's words are quoted as theirs.