symfony/ux-swup
symfony/ux-swup integrates Swup’s AJAX page transitions into Symfony apps, enabling smooth client-side navigation, animated page loads, and preserved JavaScript state with minimal setup. Ideal for adding modern SPA-like transitions to Twig templates.
Install. composer require symfony/ux-swup. The Flex recipe registers Symfony\UX\Swup\SwupBundle in config/bundles.php and, if you're on Symfony AssetMapper, adds the swup package to importmap.php (you can confirm with php bin/console importmap:require swup — it's a no-op if present). On Webpack Encore, npm install swup and make sure your webpack.config.js copies the module into the build.
Wire up a container. Swup only works if every page has the same DOM element to replace. In templates/base.html.twig:
<body>
<main id="swup">
{% block body %}{% endblock %}
</main>
<twig:Swup />
</body>
#swup is the default container. The <twig:Swup /> call renders the Stimulus controller that boots Swup and can appear anywhere in the template (usually right before </body> or in <head> with defer).
First use case. Add a CSS fade on html and confirm in devtools that window.swup exists and that clicking a link swaps <main> without a full reload. That's the whole feature — you're now shipping instant-feeling navigation on top of ordinary Symfony controllers. Where to look first: the Symfony UX Swup docs, then the Swup handbook — the PHP side is thin, the behavior lives in the JS library.
Custom container(s). The component takes containers and options as Twig props, which is how you avoid hardcoding defaults in JS:
<twig:Swup
containers={{ { header: '#site-header', main: '#swup' } }}
options={{ { animateHistoryBrowsing: true, animationSelector: '[class*="transition-"]' } }}
/>
A containers map with several selectors is the standard trick for keeping a sticky header/nav and cart badge persistent while swapping the main region.
Partial updates (cart, flash messages). Because you own the container, you can also swap sidecar fragments from a Swup hook instead of from a controller response. Register a hook on page:load and fetch a tiny endpoint that returns just the cart HTML, then outerHTML it into place. This is how people keep counters and "unread" badges fresh without touching the main page swap.
Custom JS that must re-run. DOMContentLoaded fires once. Third-party scripts (analytics snippets, date pickers, tooltips) need to be re-initialized on Swup events:
import Swup from 'swup'; // already loaded by the UX controller
document.addEventListener('swup:page:view', () => reinitWidgets());
// or, on the global instance:
window.swup?.hooks.on('page:load', () => reinitWidgets());
Prefer page:load for DOM-dependent work and page:view for analytics/scroll/focus. Do not rely on document.addEventListener('load', ...) alone — it fires only on the initial hard navigation.
Plugins. Swup's real power is in plugins (@swup/preload-plugin, @swup/head-plugin, @swup/scripts-plugin, @swup/forms-plugin, @swup/scroll-plugin, @swup/accessibility-plugin). Import them via importmap:require (or npm) and pass through the component:
<twig:Swup options={{ { plugins: plugins } }} />
where plugins is exposed by a small controller or inline JS that imports the plugin classes. For anything beyond simple config, subclass the UX Stimulus controller (or write a one-file controller) and instantiate new Swup({ ..., plugins }) yourself — the UX wrapper is a convenience, not a lock-in.
Stimulus integration. Other Stimulus controllers registered on page content are reconnected automatically by Swup only if you wire the @swup/scripts-plugin or re-evaluate scripts on page:load. If your controllers use connect() for one-time DOM setup, they generally work; if they cache references across navigations, guard with an isConnected check.
Forms, CSRF, and POST. Out of the box Swup only intercepts GET navigations. For progressive form submission add @swup/forms-plugin; Symfony's CSRF tokens in rendered forms are replaced on each swap, so re-render the form container, not just the success message.
Opting out per link. data-no-swup on an <a> (or on a whole <form>/element) forces a normal full-page navigation. Use it for file downloads, OAuth redirects, mailto-links, and anything you don't want to be swapped.
#swup (a bare fragment, an exception page, a login screen), Swup will throw on the next navigation. Keep it in the base layout, or use data-no-swup on links leading to fragment-only pages.<head> is not swapped by default. If you rely on per-page <title>, <meta> descriptions, or per-page CSS, add @swup/head-plugin. Without it, deep-linked titles stay stale and analytics picks up the wrong page title.<script> from innerHTML. Use @swup/scripts-plugin or move per-page JS into Stimulus controllers/data attributes so a hook can invoke it.page:view (not page:load), and remove the hard-coded inline GA snippet from base.html.twig or you'll see the initial page hit twice.dev, pass options: { logLevel: 'debug' } to the component, and inspect window.swup in the console. The Swup hooks (window.swup.hooks.on('visit:start', console.log)) are the fastest way to see whether a navigation is even being intercepted.@swup/preload-plugin is a huge perceived-performance win but preloads on link:hover/touchstart — make sure your controllers are idempotent and cheap for GETs, and exclude any heavy report/export links with data-no-swup.@swup/accessibility-plugin or manually move focus to <main> on page:load. This is easy to forget and hard to catch in a demo.swup in importmap.php and read the Swup changelog before upgrading; breaking changes in the JS library surface here first.swup to package.json. Install it yourself, or importmap:require swup and configure Encore to copy the @swup/* modules; otherwise the Stimulus controller boots but Swup is undefined.containers prop for multi-region pages, options.plugins for Swup plugins, and Swup hooks (visit:start, content:replace, page:load, page:view, visit:end) for anything app-specific. Between those three you can cover almost every customization without forking the bundle.How can I help you explore Laravel packages today?