Symfony UX 3.6 is out, with more than 100 pull requests merged since 3.5.1. It adds three experimental packages, UX Inspector, UX Image and UX Router, aligns the Shadcn kit of the UX Toolkit with the Nova style, and runs every Toolkit recipe in a real browser, which led to about thirty fixes in the kits.
UX Inspector
Stimulus controllers, LiveComponents and Turbo frames are spread
across the DOM as data-* attributes, and the browser devtools give you no
overview of them. UX Inspector is a devtools panel for Symfony UX that outlines
every controller, component and frame of the page.
Pick one on the page to jump to its details, which update while the
application runs. Open the panel with the pull tab on the right edge of the
screen, or type u then x outside an editable field.
- Stimulus: values and their defaults, targets, CSS classes, actions and outlets, and whether the controller is connected;
- LiveComponent: props and parent props, models and their modifiers, actions and their arguments, event listeners, loading and polling;
- Turbo: the source URL, loading strategy and state of each frame, nested frames, and Stream actions.
The activity log lists events in the order they fire: LiveComponent renders, Turbo fetches with their status and duration, controller events. This first version only reads the page, and only runs in debug mode.
The Flex recipe, which registers the bundle in dev and connects your
Stimulus application to it, is still in review (symfony/recipes#1586). The
guided demos on the UX website show the Inspector at work.
UX Image
A responsive image needs several variants of the same file, a srcset that
lists them and a sizes attribute that matches the layout. UX Image
generates all three, but it never decodes, resizes or encodes an image itself:
every variant is a URL, and an image CDN does the work.
Nothing is transformed by default. Every variant comes from an external CDN, through a bridge you install, and switching CDNs never touches a template. Four bridges ship with this release, each in its own package: Cloudflare, Cloudinary, imgix and KeyCDN.
On ux.symfony.com, served through Cloudinary, this template:
1 2 3 4 5 6
<twig:ux:image
src="/uploads/col-de-saint-bonnet.jpg"
alt="View from the Col de Saint-Bonnet"
width="480"
height="270"
/>
renders the following HTML (line breaks added for readability, the s--…--
segment signs each URL):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
<img
src="https://res.cloudinary.com/fnmfvsxv/image/fetch/s--pYfkWjrh--/w_480,h_270,c_fill,f_auto/https://ux.symfony.com/uploads/col-de-saint-bonnet.jpg"
alt="View from the Col de Saint-Bonnet"
srcset="
https://res.cloudinary.com/fnmfvsxv/image/fetch/s--pYfkWjrh--/w_480,h_270,c_fill,f_auto/https://ux.symfony.com/uploads/col-de-saint-bonnet.jpg 480w,
https://res.cloudinary.com/fnmfvsxv/image/fetch/s--IP7ocXoV--/w_640,h_360,c_fill,f_auto/https://ux.symfony.com/uploads/col-de-saint-bonnet.jpg 640w,
https://res.cloudinary.com/fnmfvsxv/image/fetch/s--gCP1dXee--/w_750,h_422,c_fill,f_auto/https://ux.symfony.com/uploads/col-de-saint-bonnet.jpg 750w,
https://res.cloudinary.com/fnmfvsxv/image/fetch/s--An-4f96H--/w_828,h_466,c_fill,f_auto/https://ux.symfony.com/uploads/col-de-saint-bonnet.jpg 828w,
https://res.cloudinary.com/fnmfvsxv/image/fetch/s--fWWMvhjt--/w_960,h_540,c_fill,f_auto/https://ux.symfony.com/uploads/col-de-saint-bonnet.jpg 960w"
sizes="(min-width: 480px) 480px, 100vw"
loading="lazy"
width="480"
height="270"
style="object-fit: cover; max-width: 480px; max-height: 270px; aspect-ratio: 480 / 270; width: 100%; height: auto;"
/>
<twig:ux:picture> renders one <source> per format instead.
ux_image_url() returns a single URL, for an Open Graph image or an email,
and presets give a name to a set of transformations.
The UX Image page has a live demo.
UX Router
Generating a Symfony URL from JavaScript has meant hard-coding paths, or installing FOSJsRoutingBundle, whose documentation covers Webpack Encore but not AssetMapper.
UX Router dumps the routes you expose when the cache warms up, and gives you
path() and url() functions that return the same result as their Twig
counterparts. It works with AssetMapper, Reprise (Vite and
Rsbuild) and Webpack Encore, and with TypeScript, route names and
parameters are type-checked.
No route is exposed by default. To expose a single route, set its expose
option, the same one FOSJsRoutingBundle users already know:
1 2 3
// src/Controller/BlogController.php
#[Route('/blog/{slug}', name: 'app_blog_show', options: ['expose' => true])]
public function show(string $slug): Response
To expose many routes at once, list name patterns; a leading ! excludes
routes. The expose option of a route always wins over the patterns:
1 2 3 4 5
# config/packages/ux_router.yaml
ux_router:
routes:
- 'app_*'
- '!app_admin_*'
The exposed routes are dumped as JavaScript to var/routes/ when the cache
warms up. assets/router.js passes them to createRouter() with the
default locale and exports path() and url():
1 2 3 4 5
// assets/router.js
import { createRouter } from '@symfony/ux-router';
import { routes, defaultLocale } from '../var/routes/index.js';
export const { path, url } = createRouter({ routes, defaultLocale });
Any other JavaScript file imports them from ./router.js:
1 2 3 4 5
// assets/my_file.js
import { path, url } from './router.js';
path('app_blog_show', { slug: 'hello', _query: { page: 2 } }); // '/blog/hello?page=2'
url('app_blog_show', { slug: 'hello' }); // 'https://example.com/blog/hello'
A localized route picks its variant from <html lang>, and falls back to the
framework.default_locale of your application, dumped as defaultLocale.
The UX Router page has a live demo.
Front-End Code Checked Against Web Platform Baseline
The Symfony backward compatibility promise covers the PHP API, but nothing said which browser features the JavaScript and CSS of Symfony UX may rely on. Following an RFC by Simon André, the code shipped by every UX package is now checked in CI against Web Platform Baseline, "Widely available" by default.
A package can declare exceptions. Notify relies on the Notifications API, which
is Baseline "Limited" as long as iOS Safari only exposes it to web apps added to
the home screen. LiveComponent only uses the Navigation API when the browser
supports it. UX Inspector, a development tool, targets Baseline 2024 instead,
with a few CSS exceptions. The build target now comes from Browserslist
instead of a hard-coded es2022. The rebuilt files are byte-identical, so
nothing you install changes.
UX Toolkit: The Shadcn Kit Moves to Nova
shadcn/ui introduced several visual styles in December 2025, and Nova is the compact one, with reduced padding and margins. Every recipe of the Shadcn kit was compared against the upstream Nova sources and aligned with them.
Two recipes were rewritten to match their upstream component. select is
now a listbox built from Select:* sub-components and driven by a Stimulus
controller; use native-select for a native <select>. combobox is
now made of Combobox:* sub-components, with multiple selection. Clicking
an addon of input-group now focuses its input.
1 2 3 4 5 6 7 8 9 10 11 12
<twig:Select id="fruit" name="fruit" value="banana">
<twig:Select:Trigger>
<twig:Select:Value placeholder="Select a fruit" />
</twig:Select:Trigger>
<twig:Select:Content>
<twig:Select:Group id="fruits">
<twig:Select:Label>Fruits</twig:Select:Label>
<twig:Select:Item value="apple">Apple</twig:Select:Item>
<twig:Select:Item value="banana">Banana</twig:Select:Item>
</twig:Select:Group>
</twig:Select:Content>
</twig:Select>
Recipes are copied into your application, so nothing changes until you install them again.
UX Toolkit: Three New Shadcn Recipes
The Shadcn kit gained three recipes:
toaststacks notifications in a corner of the screen;context-menuopens a menu on right click;commandis the command menu Shadcn builds on top of cmdk, with a Stimulus controller in place ofcmdk: it filters items as you type, and handles the keyboard shortcutscmdkusers expect.
A toast can be fired from any element with the toast#add action:
1 2 3 4 5 6 7 8 9 10 11
<twig:Toaster position="bottom-right" duration="5000" limit="3">
<twig:Button
data-action="click->toast#add"
data-toast-type-param="success"
data-toast-title-param="Event created"
data-toast-description-param="Sunday, December 3 at 9:00 AM"
data-toast-action-label-param="Undo"
>
Show Toast
</twig:Button>
</twig:Toaster>
From JavaScript, the toast() helper exported by the controller does the
same: toast.success('Event created'). To free the Toast name, the
sonner recipe's component is now Sonner:Toast.
UX Toolkit: A Diff Before Overwriting a File
ux:install asks before overwriting a file that already exists, but it never
showed what would change. You either trusted the recipe or compared both files
yourself.
When Git is installed, the command now prints a diff first, with the changed part of each line highlighted. When the file already matches the recipe, it no longer asks at all.
1 2 3 4 5 6 7 8 9 10 11 12 13 14
$ php bin/console ux:install button --kit=shadcn
Installing recipe "button" from the Shadcn UI kit...
@@ -1,6 +1,6 @@
{%- props
## 'default'|'secondary'|'destructive'|'outline'|'ghost'|'link' The visual style variant.
- variant = 'outline',
+ variant = 'default',
## 'default'|'xs'|'sm'|'lg'|'icon'|'icon-xs'|'icon-sm'|'icon-lg' The button size.
size = 'default',
## 'button'|'a' The HTML tag to render; use `a` when the button navigates.
File "templates/components/Button.html.twig" already exists. Do you want to overwrite it? (yes/no) [yes]:
>
UX Toolkit: Every Recipe Tested in a Real Browser
Toolkit recipes were tested through snapshots of the HTML they render. That says nothing about what a recipe looks like, or what happens when you click it. A new preview app now renders every example in isolation, and Playwright screenshots each one in light and dark mode, then compares it with a committed baseline: more than 1,800 screenshots across the four kits. Every interactive recipe also has its own interaction spec, and a test fails when a new one ships without it.
Writing those tests surfaced about thirty bugs in the Shadcn and Flowbite kits, all fixed in this release, among them:
- a closed
sheetordrawerstaying reachable with the keyboard and exposed to screen readers; dialog,alert-dialog,drawer,sheetand the Flowbitemodalno longer being modal once moved in the DOM;alert-dialogopening on page load;- a disabled
sliderstill reacting to the keyboard, andsliderlosing the focus after a click on its track; - Tab skipping the panels of
navigation-menu; tooltipoverflowing the viewport;date-pickerfocusing "Previous month" instead of a day when it opens;- the Flowbite
modalhaving no accessible name, and the inactive triggers of the Flowbitetabsbeing unreadable in dark mode.
Turbo and Notify Speak the Mercure Protocol 1.0
MercureBundle 0.6 makes the Mercure protocol 1.0 the default. That
protocol dropped URI Templates, so turbo_stream_from('App
stayed silent on such a hub, and the Notify Stimulus controller, which only
knew the topic query parameter, subscribed to nothing.
UX Turbo and UX Notify now support MercureBundle 0.5 and 0.6, and speak the
protocol of the hub they use. Listening to every entity of a class subscribes
to a URL Pattern (:id) instead of a URI Template ({id}), and Notify
subscribes with match instead of topic. On a hub that speaks the
protocol 0.x, nothing changes. The deprecated turbo_stream_listen()
throws an exception on a 1.0 hub: use turbo_stream_from() instead.
Searching ux.symfony.com
ux.symfony.com had no search: finding a package, a recipe or a demo meant
browsing the site. It now has one, powered by Algolia DocSearch. Open it from
the header, or press Ctrl+K (⌘K on macOS).
It covers the packages, the Toolkit kits and their recipes, the demos and resources such as the changelog, and groups the results into Packages, Kits, Demos and Resources.
Another Performance Pass
TwigComponent and UX Icons got another round of optimizations, mostly by skipping work nobody needs: walking the call stack, running mount hooks and copying render variables when no listener cares, or rendering the same icon twice.
On an admin dashboard built with the Shadcn kit, which renders about 1,800 components and 400 icons per request, a request takes 22% less CPU time with 3.6 than with 3.5.1, from 50-53 ms down to 39-41 ms, for the same HTML. These numbers are the medians of six alternating runs of 60 requests per version, in the prod environment, with PHP 8.4 and Twig 3.30. Your templates must be recompiled to benefit, which a cache clear does.
Templates that use the HTML syntax also compile faster. UX Icons now warms local
icons on cache:warmup, so applications built with Webpack Encore, or that
clear their cache after compiling assets, no longer start with an empty icon
cache.
Full Changelog
- #4086 Update the tooling dependencies (@Kocal)
- #3533 [LiveComponent] Return StimulusAttributes from live_action() so it can be spread in a Twig component tag (@Amoifr)
- #4074 [Toolkit] Fix GitHub kits pinned to a tag prefixed with "v" (@guillaume-sainthillier)
- #3930 [LiveComponent] Export the ComponentHooks type (@Pechynho)
- #4083 [Toolkit] Run the browser tests on 8 shards (@Kocal)
- #4064 [Inspector] Reposition overlays when the panel opens or closes (@Kocal)
- #4081 [Translator] Register the configured dump directory with AssetMapper (@Kocal)
- #4082 [Router] Dump the routes when the container is built without a dump directory (@Kocal)
- #4068 [Toolkit][Shadcn] Add command recipe (@Enz000)
- #4073 [Translator] Dump the translations when the container is built without a dump directory (@guillaume-sainthillier)
- #4071 [Toolkit][Shadcn] Fix the focus lost on open in dropdown-menu and combobox, and two timing-dependent tests (@Enz000)
- #4076 [Router] Fall back to the application default locale (@feymo)
- #4080 [TwigComponent] Fix the pre-lexer mistaking
<twig:inside a{% %}tag for a component (@javiereguiluz) - #4079 [Pagination] Remove deprecated
defaultPriorityMethodfrom the adapter iterator (@tacman) - #4040 Add the imgix bridge (@kbond)
- #4063 [Toolkit][Shadcn] Add context-menu recipe (@Enz000)
- #4039 [Image] Add the Cloudinary bridge (@kbond, @Kocal)
- #4067 [Translator] Fix
ux:translator:warm-cachewriting empty translations (@Kocal) - #4066 Make release.sh handle packages npm does not have yet (@Kocal)
- #4065 [Inspector] Drop unused version from package.json (@Kocal)
- #1234 [Router] Add Symfony UX Router package (@Kocal)
- #3927 [LiveComponent] Read the id attribute of elements, not the id property (@Pechynho)
- #3928 [LiveComponent] Do not update the URL from a response that arrives after a navigation (@Pechynho)
- #4061 [Toolkit][Shadcn] Align the kit with the upstream Shadcn Nova reference (@ker0x)
- #4059 [Translator] Fully escape translation IDs (@tnytown)
- #4062 [Toolkit][Shadcn] Fix the sheet width and the overlay exit transition (@ker0x)
- #3945 [Image] Add KeyCDN Secure Token support (@kbond)
- #4022 [LiveComponent] Reset the deterministic id counters between requests (@Pechynho)
- #4060 [LiveComponent] Sort the template map written by the cache warmer (@nicolas-grekas)
- #4050 [TwigComponent] Avoid copying the render variables when nobody
listens to
PreRenderEvent(@Kocal) - #4047 [Icons] Cache the rendered HTML of repeated icons in
IconRenderer(@Kocal) - #4049 [TwigComponent] Find the host template of embedded blocks without scanning the call stack (@Kocal)
- #4048 [TwigComponent] Skip mount hooks on anonymous components when nobody listens to mount events (@Kocal)
- #4057 [Turbo] Add support for MercureBundle ^0.6 and Mercure ^0.9 (@Kocal)
- #4056 [Notify] Add support for MercureBundle ^0.6 (@Kocal)
- #4058 [Toolkit][Shadcn] Fix a caller's aria-label being ignored on pagination, breadcrumb and questionnaire (@Kocal)
- #4036 [Notify] Add support for MercureBundle ^0.5 and Mercure ^0.8 (@Chris8934)
- #4055 [CalendarLink] Fix UTC detection for VTIMEZONE (@tnytown)
- #3902 [Toolkit][Shadcn] Add toast recipe (@ker0x)
- #4043 [Autocomplete] Fix Polish translations (@TmszLBmrv)
- #4042 [Toolkit] Fix the drawer snapshots broken by twig-tailwind-extra 1.5.0 (@Kocal)
- #4041 [Toolkit] Fix the kit linter crashing on Twig 4 (@Kocal)
- #4037 [Icons][LiveComponent][StimulusBundle][TwigComponent] Describe the types listed by debug:autowiring (@nicolas-grekas)
- #3926 [LiveComponent] Finish the loading state when a request fails (@Pechynho)
- #3924 [LiveComponent] Fix child components lost inside a data-skip-morph element (@Pechynho)
- #3922 [LiveComponent] Add the
render:startedhook to theComponentHookstype (@Pechynho) - #3908 [Turbo] Only trigger the
TurboStreamListenRendererdeprecation when the service is used (@jmsche) - #3935 [Turbo] Support the Mercure protocol 1.0 in turbo_stream_from() (@lyrixx)
- #3903 [Pagination] Fix Cursor pagination with
DATETIMETZ_MUTABLEcolumns (@Huluti) - #4034 [Pagination] Add Ukrainian translations (@Menelion)
- #4033 [Pagination] Add Russian translations (@Menelion)
- #3900 [LiveComponent] Fix
AssertDispatchedEvent::withPayloadSubset()crashing on non-scalar payload values (@Arkalo2) - #4027 [Toolkit][Shadcn] Fix questionnaire freeform answer losing its name once moved in the DOM (@Kocal)
- #4031 [Toolkit][Shadcn] Fix alert-dialog no longer being modal once moved in the DOM (@Kocal)
- #4026 [Toolkit][Shadcn] Fix navigation-menu never opening again once moved in the DOM (@Kocal)
- #4032 [Toolkit][Flowbite] Fix modal no longer being modal once moved in the DOM (@Kocal)
- #4030 [Toolkit][Shadcn] Fix dialog no longer being modal once moved in the DOM (@Kocal)
- #4029 [Toolkit][Shadcn] Fix sonner losing its toasts once moved in the DOM (@Kocal)
- #4028 [Toolkit][Shadcn] Fix resizable handle moving twice as far once moved in the DOM (@Kocal)
- #4023 [Toolkit][Shadcn] Keep the tooltip inside the viewport and fit it to its text (@Kocal)
- #4019 [Toolkit] Show a diff when
ux:installasks to overwrite a file (@Kocal) - #3910 [Toolkit][Shadcn] Align select with the upstream Shadcn Select (@ker0x)
- #3905 [Toolkit][Shadcn] Fix
data-slotbeing shadowed when forwarded to a child component (@ker0x) - #3938 [Toolkit][Shadcn] Align input-group with the upstream Shadcn InputGroup (@ker0x)
- #3906 [Toolkit][Shadcn] Align
comboboxwith the upstream Shadcn UI component (@ker0x) - #3934 [TwigComponent] Copy plain text in one go when pre-lexing templates (@nicolas-grekas)
- #3914 [Icons] Warm local icons on
cache:warmup(@nicolas-grekas) - #3937 [Image] Add presets (@Kocal)
- #4018 [Toolkit] Fix
ux:installnot overwriting a file newer than the recipe (@Kocal) - #3971 [Toolkit][Shadcn] Fix the tooltip of the disabled button example never opening (@Kocal)
- #3970 [Toolkit][Shadcn] Fix the bookmark icon not filling in the toggle examples (@Kocal)
- #3969 [Toolkit][Shadcn] Fix the close button and the action of a toast ignoring mouse clicks (@Kocal)
- #3968 [Toolkit][Shadcn] Fix the slider losing the focus after a click on its track (@Kocal)
- #3967 [Toolkit][Shadcn] Fix a disabled slider still reacting to the keyboard (@Kocal)
- #3966 [Toolkit][Shadcn] Fix a closed sheet staying reachable with the keyboard (@Kocal)
- #3965 [Toolkit][Shadcn] Fix the resizable handle missing its separator state (@Kocal)
- #3964 [Toolkit][Shadcn] Fix Tab skipping the panels of the navigation menu (@Kocal)
- #3958 [Toolkit][Shadcn] Fix the dialog trigger's aria-expanded getting out of sync (@Kocal)
- #3963 [Toolkit][Shadcn] Fix menubar submenus wrapping long labels (@Kocal)
- #3962 [Toolkit][Shadcn] Fix the radio indicator of the menubar being a ring instead of a dot (@Kocal)
- #3957 [Toolkit][Shadcn] Fix the date picker focusing "Previous month" instead of a day (@Kocal)
- #3961 [Toolkit][Shadcn] Fix dropdown submenus wrapping their labels (@Kocal)
- #3960 [Toolkit][Shadcn] Fix the radio indicator of the dropdown menu being a ring instead of a dot (@Kocal)
- #3956 [Toolkit][Shadcn] Fix the combobox list not lining up with its trigger (@Kocal)
- #3959 [Toolkit][Shadcn] Fix a closed drawer staying reachable with the keyboard (@Kocal)
- #3955 [Toolkit][Shadcn] Fix alert dialogs opening on page load (@Kocal)
- #3954 [Toolkit][Shadcn] Fix the arrow keys getting stuck before a disabled accordion item (@Kocal)
- #3953 [Toolkit][Flowbite] Fix inactive tabs being unreadable in dark mode (@Kocal)
- #3952 [Toolkit][Flowbite] Fix the modal having no accessible name (@Kocal)
- #3950 [Toolkit] Shard the browser tests and keep the server logs out of the CI output (@Kocal)
- #3949 [Toolkit][Shadcn] Fix the popover not focusing its content on a busy browser (@Kocal)
- #3948 [Toolkit] Add visual tests for recipes (@Kocal)
- #3947 [Toolkit] Ship kit preview assets and add a preview app (@Kocal)
- #3838 [Image] Add the UX Image package and its Cloudflare and KeyCDN bridges (@Kocal)
- #3866 [Inspector] Add a browser inspector for Symfony UX (@smnandre)
- #3918 [CI] Harden the npm release workflow (@Kocal)
- #3919 Conflict with
symfony/asset-mapper8.2 and later (@Kocal) - #3916 Backport
release.sh(@Kocal) - #3917 Fix the 2.x CI (@Kocal)
- #3913 [Icons] Fetch each icon from the cache once per request (@nicolas-grekas)
- #3911 Refresh agent instructions and import skills from symfony/symfony (@Kocal)
- #3907 Use a X.Y.Z version placeholder in the assets README install commands (@Kocal)
- #3904 Check shipped assets against Web Platform Baseline (@Kocal)
- #3896 Keep the npm releases in step with the split repositories (@Kocal)
UX Inspector, UX Image and UX Router are experimental, so their API can still change, and now is when your feedback is worth the most. Try them, and open an issue if something does not fit your application.
Fantastic work as always 👏
Thanks to all contributors and to all Symfony UX maintainers!