Turbo
Hotwire Turbo for Symfony UX -- SPA-like speed with zero JavaScript. Covers Drive (navigation), Frames (partial page sections), and Streams (multi-target updates). Use when building ajax navigation, lazy-loaded sections, inline editing, pagination without reload, modals from the server, flash messages via streams, or real-time updates via Mercure/SSE. Code triggers: turbo-frame, turbo-stream, data-turbo-frame, data-turbo, data-turbo-action, turbo-stream-source, TurboStreamResponse, <twig:Turbo:Frame>, <twig:Turbo:Stream:Append>, <twig:Turbo:Stream:Replace>, turbo:before-fetch-request. Also trigger when the user asks "how to update part of the page without reload", "how to make navigation feel like SPA", "how to lazy-load a section", "how to do inline editing", "how to push real-time updates from server", "how to use Mercure with Turbo". Do NOT trigger for client-side JS behavior (use stimulus), server-rendered reactive components (use live-component), or reusable static UI (use twig-component).From its SKILL.md
npx -y skills add smnandre/symfony-ux-skills --skill turboAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- runs commandsInstructs the agent to run 1 command, including `composer require symfony/ux-turbo`.
What its file declares
Copied from the file, not written here
The file declares its own license as MIT. That is the author’s claim about this one file, and it is not the same thing as the license GitHub reports for the repository, which is listed with the other numbers below.
SKILL.md
11.2 KB, ~2.6k tokens by cl100k_base, as published. Nobody here has run it
Turbo
Hotwire Turbo provides SPA-like speed with server-rendered HTML. No JavaScript to write. Three components work together:
- Drive -- Automatic AJAX navigation for all links and forms (zero config)
- Frames -- Scoped navigation that updates only one section of the page
- Streams -- Server-pushed DOM mutations (append, replace, remove, etc.)
Decision Tree
Need to update the page?
+-- Full page navigation -> Turbo Drive (automatic, already active)
+-- Single section from user click -> Turbo Frame
+-- Multiple sections from action -> Turbo Stream (HTTP response)
+-- Real-time from server/others -> Turbo Stream (Mercure / SSE)
Installation
composer require symfony/ux-turbo
That's it. Turbo Drive is active immediately -- all links and forms become AJAX.
Turbo Drive
Automatic SPA-like navigation. Every <a> click and <form> submit is intercepted, fetched via AJAX, and the <body> is swapped. The browser URL and history update normally.
Disabling for Specific Elements
<!-- Disable on link/form -->
<a href="/external" data-turbo="false">External Link</a>
<!-- Disable for entire section -->
<div data-turbo="false">
<a href="/normal">Normal link (no Turbo)</a>
</div>
History and Caching
<!-- Replace history instead of push -->
<a href="/page" data-turbo-action="replace">Replace History</a>
<!-- Force full reload when asset changes -->
<link rel="stylesheet" href="/app.css" data-turbo-track="reload">
<script src="/app.js" data-turbo-track="reload"></script>
Turbo Frames
Scope navigation to a section of the page. Links and forms inside a frame update only that frame's content. The rest of the page stays untouched.
Basic Frame
<!-- Page with frame -->
<turbo-frame id="messages">
<h2>Messages</h2>
<a href="/messages/1">View Message 1</a> <!-- Updates only this frame -->
</turbo-frame>
<!-- /messages/1 response must contain a matching frame ID -->
<turbo-frame id="messages">
<h2>Message 1</h2>
<p>Content here...</p>
<a href="/messages">Back to list</a>
</turbo-frame>
The server response is a full HTML page, but Turbo extracts only the matching <turbo-frame> and swaps it in.
Lazy Loading
Load frame content asynchronously after the page renders:
<turbo-frame id="notifications" src="/notifications" loading="lazy">
<p>Loading...</p>
</turbo-frame>
Target Another Frame
A link inside one frame can update a different frame:
<turbo-frame id="sidebar">
<a href="/item/1" data-turbo-frame="main-content">View Item</a>
</turbo-frame>
<turbo-frame id="main-content">
<!-- Content replaced here -->
</turbo-frame>
Break Out of Frame
Navigate the entire page from within a frame:
<turbo-frame id="modal">
<a href="/dashboard" data-turbo-frame="_top">Go to Dashboard</a>
</turbo-frame>
Frame with Form
Forms inside frames submit and update within that frame:
<turbo-frame id="search-results">
<form action="/search" method="get">
<input type="search" name="q">
<button>Search</button>
</form>
<ul>
{% for item in results %}
<li>{{ item.name }}</li>
{% endfor %}
</ul>
</turbo-frame>
URL Sync
Update the browser URL when a frame navigates (useful for bookmarkable state):
<turbo-frame id="products" data-turbo-action="advance">
<!-- Browser URL updates when this frame navigates -->
</turbo-frame>
Turbo Streams
Update multiple DOM elements from a single server response. Eight actions available (append, prepend, replace, update, remove, before, after, refresh), each targeting elements by ID or CSS selector.
Stream Actions
<turbo-stream action="append" target="messages">
<template><div id="msg_1">New message</div></template>
</turbo-stream>
<turbo-stream action="prepend" target="messages">
<template><div id="msg_0">First!</div></template>
</turbo-stream>
<turbo-stream action="replace" target="notification">
<template><div id="notification">Updated!</div></template>
</turbo-stream>
<turbo-stream action="update" target="counter">
<template>42</template>
</turbo-stream>
<turbo-stream action="remove" target="msg_5"></turbo-stream>
<turbo-stream action="before" target="msg_3">
<template><div id="msg_2">Inserted before</div></template>
</turbo-stream>
<turbo-stream action="after" target="msg_3">
<template><div id="msg_4">Inserted after</div></template>
</turbo-stream>
<turbo-stream action="refresh"></turbo-stream>
replace and update support an optional method="morph" attribute for smooth DOM morphing instead of full replacement:
<turbo-stream action="replace" method="morph" target="user-card">
<template><div id="user-card">Updated content</div></template>
</turbo-stream>
Target Multiple Elements (CSS Selector)
Use targets (plural) with a CSS selector to affect multiple elements:
<turbo-stream action="remove" targets=".notification.read"></turbo-stream>
<turbo-stream action="update" targets=".price">
<template>99.00 EUR</template>
</turbo-stream>
Twig Component Syntax for Streams
Since Symfony UX 2.22+, you can use <twig:Turbo:Stream:*> components instead of raw HTML:
<twig:Turbo:Stream:Append target="comments">
{{ include('comment/_comment.html.twig') }}
</twig:Turbo:Stream:Append>
<twig:Turbo:Stream:Update target="comment-count">
{{ count }}
</twig:Turbo:Stream:Update>
<twig:Turbo:Stream:Remove target="msg_5" />
Symfony Integration
Stream Response from Controller
use Symfony\UX\Turbo\TurboBundle;
#[Route('/messages', name: 'message_create', methods: ['POST'])]
public function create(Request $request): Response
{
$message = new Message();
// ... handle form
$this->em->persist($message);
$this->em->flush();
// Return stream response for Turbo requests
$request->setRequestFormat(TurboBundle::STREAM_FORMAT);
return $this->render('message/create.stream.html.twig', [
'message' => $message,
'count' => $count,
]);
}
You can also use the TurboStreamResponse helper or TurboStream helper methods for programmatic stream building.
Stream Template
{# templates/message/create.stream.html.twig #}
<turbo-stream action="append" target="messages">
<template>
{{ include('message/_message.html.twig', {message: message}) }}
</template>
</turbo-stream>
<turbo-stream action="update" target="message-count">
<template>{{ count }}</template>
</turbo-stream>
<turbo-stream action="replace" target="new-message-form">
<template>
{{ include('message/_form.html.twig', {message: null}) }}
</template>
</turbo-stream>
Detect Frame Request
public function show(Request $request, int $id): Response
{
if ($request->headers->has('Turbo-Frame')) {
$frameId = $request->headers->get('Turbo-Frame');
// Return only the frame content (or a full page -- Turbo extracts the frame)
}
return $this->render('page/show.html.twig');
}
Mercure Broadcasts (Real-time)
Push changes to all connected browsers via SSE:
use Symfony\UX\Turbo\Attribute\Broadcast;
#[Broadcast]
class Message
{
// Entity changes broadcast automatically to subscribed clients
}
{# Subscribe to Mercure topic #}
<turbo-stream-source src="{{ mercure('chat-room-1')|escape('html_attr') }}">
</turbo-stream-source>
<div id="messages">
{# Messages appear here in real-time #}
</div>
Common Patterns
Inline Edit
<!-- Display mode -->
<turbo-frame id="task_{{ task.id }}">
<span>{{ task.title }}</span>
<a href="/tasks/{{ task.id }}/edit">Edit</a>
</turbo-frame>
<!-- Edit mode (response from /tasks/1/edit) -->
<turbo-frame id="task_1">
<form action="/tasks/1" method="post">
<input name="title" value="Task title">
<button>Save</button>
<a href="/tasks/1">Cancel</a>
</form>
</turbo-frame>
Modal in Frame
<turbo-frame id="modal"><!-- Empty by default --></turbo-frame>
<a href="/items/1/delete" data-turbo-frame="modal">Delete</a>
<!-- /items/1/delete response -->
<turbo-frame id="modal">
<dialog open>
<p>Confirm delete?</p>
<form method="post">
<button>Delete</button>
</form>
<a href="/items" data-turbo-frame="modal">Cancel</a>
</dialog>
</turbo-frame>
Flash Messages with Stream
<turbo-stream action="prepend" target="flash-messages">
<template>
<div class="alert alert-success" role="alert">
Item saved successfully!
</div>
</template>
</turbo-stream>
Key Principles
Server returns full HTML pages. Turbo works best when the server always returns a complete, valid HTML page. Turbo Drive replaces the body, Turbo Frames extract the matching frame. Don't try to return partial HTML snippets (except for Stream templates).
Frame IDs must match. The frame in the response must have the same id as the frame on the page. If they don't match, Turbo shows an error.
Streams are for side effects. Use Streams when a single action needs to update multiple unrelated parts of the page. If you're only updating one section, a Frame is simpler.
Stimulus complements Turbo. Turbo handles navigation and server communication. Stimulus handles client-side behavior (animations, toggles, clipboard). They work together -- Stimulus controllers survive Turbo Frame swaps within their scope, and reconnect properly on Drive navigation.
References
- Full API (Drive events, Frame attributes, Stream actions, Mercure): references/api.md
- Patterns (forms, modals, search, pagination, infinite scroll): references/patterns.md
- Gotchas (caching issues, form handling, Stimulus integration): references/gotchas.md
See Also
- UX Map works inside Turbo Frames. The map Stimulus controller reconnects properly on frame swaps.
- UX Icons are inline SVG and survive Turbo Drive navigation and Frame swaps with no special handling.
What ships with it: 3 files
31.5 KB alongside SKILL.md
references/
- api.md10.8 KB
- gotchas.md9.2 KB
- patterns.md11.6 KB
Gives 0 of the 12 instructions most design frontend skills give in ~2.6k tokens
Counted across 1,179 of the 2,086 authors here whose files we hold, read 2026-09-06
- Commit to a bold aesthetic directionin 31 of 1179, across 24 files
- Prefer component composition over inheritancein 28 of 1179, across 14 files
- Animate only transform and opacity propertiesin 27 of 1179, across 22 files
- Memoize expensive computations with useMemoin 26 of 1179, across 13 files
- Use semantic HTML elementsin 24 of 1179, across 23 files
- Virtualize long lists for performancein 21 of 1179, across 10 files
- Use CSS variables for design tokensin 20 of 1179, across 14 files
- Implement loading, empty, and error statesin 20 of 1179
- Lazy load heavy components with Suspensein 19 of 1179, across 8 files
- Respect prefers-reduced-motion media queriesin 18 of 1179, across 10 files
- Prioritize CSS-only animations for HTMLin 18 of 1179, across 16 files
- Use compound components for related UI elementsin 18 of 1179, across 7 files
Said here and by no other author read
- Use Turbo Drive for automatic AJAX navigation
- Use Turbo Frames for scoped section updates
- Use Turbo Streams for multi-target DOM updates
- Return full HTML pages from the server
- Ensure frame IDs match between request and response
- Use Streams for side effects and multiple updates
Grouped from the skills themselves: near-identical wordings counted once, and counted by distinct author, so one author publishing three of these counts once. Length counted with cl100k_base; the agent that loads this file may tokenize it differently.