Every app ends up needing a product tour. A new dashboard ships, support tickets pile up, and someone asks
for "a little walkthrough for first-time users". You reach for Driver.js, write a
controller, hardcode a list of CSS selectors in JavaScript, and then discover that the copy needs
translating and the steps depend on the user's role.
That is the part I wanted to move back into Symfony. UX Driver is a Symfony UX bundle that lets you
declare tours, highlights and hints in Twig or PHP, and leaves Driver.js to do the rendering.
What is UX Driver?
UX Driver wraps Driver.js behind a Symfony API:
- Tours, an ordered sequence of steps with popovers.
- Highlights, a single spotlit element with one popover and nothing to complete.
- Hints, small beacons that sit next to a control and wait to be clicked.
-
Three authoring modes, Twig Components, the
create_tour()Twig builder, and autowired PHP builders, all producing the same payload. -
Popover content escaped by default, because Driver.js writes titles and descriptions with
innerHTML. - DOM events for every Driver.js callback, so you can hook in without ever constructing a tour yourself.
Requirements are PHP 8.2+, Symfony 7 or 8
Installation
composer require pentiminax/ux-driver
With AssetMapper you are done. The bundle autoimports driver.js/dist/driver.css and
driver.js/dist/hints.css through StimulusBundle.
With Webpack Encore, import both stylesheets once in your entry file:
import 'driver.js/dist/driver.css'
import 'driver.js/dist/hints.css'
A first tour, in Twig
The component mode is the one to start with. Wrap the steps, and each step wraps the element it points at:
<twig:Driver:Tour id="orders-tour" :once="true">
<button type="button" {{ ux_tour_action('start') }}>
Start tour
</button>
<twig:Driver:Step :order="1" title="Orders" description="Review current customer orders">
<h1 class="orders-title">Orders</h1>
</twig:Driver:Step>
<twig:Driver:Step :order="2" title="Filters" description="Narrow the list before export" side="bottom">
<form class="filters">...</form>
</twig:Driver:Step>
<twig:Driver:Step :order="3" title="Done" description="Export the filtered result" side="left">
<button type="button" class="export">Export</button>
</twig:Driver:Step>
</twig:Driver:Tour>
No selector to maintain. The Stimulus controller collects the steps from its targets in DOM order, so
moving a block in the template moves it in the tour.
:once="true" is a UX Driver option rather than a Driver.js one. When someone clicks the done button on
the last step, the controller writes ux-driver:seen:orders-tour to localStorage and refuses to start
that tour again. Abandoning it halfway does not count, so the tour comes back next time. And if
localStorage is unavailable, private browsing, blocked cookies, a full quota, the controller catches the
failure and treats the tour as unseen. The page keeps working, the tour just stops being once-only.
The same tour, in PHP
Steps that depend on the user, on the data, or on a feature flag belong in a controller. TourBuilder is
an autowired service:
namespace App\Controller;
use Pentiminax\UX\Driver\Builder\TourBuilder;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class OrderController extends AbstractController
{
public function __construct(private readonly TourBuilder $tourBuilder)
{
}
#[Route('/orders', name: 'orders')]
public function index(): Response
{
$tour = $this->tourBuilder->create('orders-tour')
->addStep('.orders-title', 'Orders', 'Review current customer orders')
->addStep('.filters', 'Filters', 'Narrow the list before export')
->addStep('.export', 'Done', 'Export the filtered result', side: 'left')
->once();
return $this->render('order/index.html.twig', ['tour' => $tour]);
}
}
The template prints two attributes and nothing else:
<button type="button" {{ ux_tour(tour) }} {{ ux_tour_action('start') }}>
Start tour
</button>
The Tour object is ordinary PHP, so you can assemble it in a service and unit-test it. If you want the
same fluent chain without leaving the template, create_tour() gives you the builder in Twig:
{% set tour = create_tour('orders-tour')
.addStep('.orders-title', 'Orders', 'Review current customer orders')
.addStep('.filters', 'Filters', 'Narrow the list before export')
.once() %}
<button type="button" {{ ux_tour(tour) }} {{ ux_tour_action('start') }}>Start tour</button>
Three ways in, one serialized payload, one Stimulus controller.
Highlights, when one popover is enough
Sometimes there is no walkthrough, just one thing to point at. A renamed button, a field that changed
meaning, a validation error worth spotlighting. ux_highlight() is a tour of one step with no next button
and no progress counter:
<button type="button"
{{ ux_highlight('#invoice-export', 'Export', 'CSV and PDF are both available here') }}
{{ ux_tour_action('highlight') }}>
Where is export?
</button>
The first argument is the selector of the element to spotlight, not the element carrying the attributes.
Hints, for help that outlives onboarding
A tour interrupts. A hint waits. Hints put a small beacon next to a control and open a popover when someone
asks for it:
<twig:Driver:Hints id="orders-help" buttonText="Done" :overlay="true">
<twig:Driver:Hint hintId="filters" title="Filters" description="Save time by narrowing the list first">
<button type="button">Filters</button>
</twig:Driver:Hint>
<twig:Driver:Hint hintId="export" title="Export" description="Download the current view as a CSV" side="left">
<button type="button">Export</button>
</twig:Driver:Hint>
</twig:Driver:Hints>
Hints autostart, tours do not. The asymmetry is deliberate: a tour takes the screen away from someone, a
beacon does not. Pass :autostart="false" when the beacons should only appear after something else
happens, and drive them with ux_hints_action():
<button {{ ux_hints_action('show') }}>Show hints</button>
<button {{ ux_hints_action('hide') }}>Hide hints</button>
<button {{ ux_hints_action('open', {hintId: 'filters'}) }}>Open the filters hint</button>
HintsBuilder mirrors TourBuilder for the PHP side.
Events, and the ones you can cancel
Driver.js exposes its lifecycle through callbacks, which a server-rendered tour has nowhere to attach. UX
Driver dispatches them as DOM events on document instead. Nine for tours, six for hints.
Five tour events are cancelable, which turns any step into a gate:
document.addEventListener('ux-driver:next', (event) => {
const { tourId, index } = event.detail
if (tourId === 'checkout-tour' && index === 1 && !form.reportValidity()) {
event.preventDefault()
}
})
The tour stays where it is, the form shows its own validation, and you never touched the Driver.js API.
The rest are for observation, analytics being the obvious one:
document.addEventListener('ux-driver:highlighted', (event) => {
const { tourId, index, element, driver } = event.detail
analytics.track('tour_step_viewed', { tour: tourId, step: index })
})
That driver handle is the live Driver.js instance, and it is there on purpose. It is the escape hatch for
anything the bundle does not serialize.
Popover content is escaped
This one is worth calling out because it is easy to get wrong by hand. Driver.js writes popover titles,
descriptions and button labels with innerHTML. Left alone, every one of them is an HTML injection sink,
and tour copy very often comes from a CMS or a database.
UX Driver escapes plain strings before serializing them:
$tour = $this->tourBuilder->create('billing-tour')
->addStep('.billing', '<b>Billing</b>', '<script>alert(1)</script>');
That popover shows the literal characters. No bold text, no script. When you do want markup, and you know
the source, ux_driver_html() is the single explicit opt-out.
Targets that are not there yet
Turbo Frames, modals and lazily rendered panels break the assumption that a step's target is in the DOM
when the tour reaches it. Driver.js 1.8 added two step options for that, and UX Driver passes both through:
$tour = $this->tourBuilder->create('invoice-tour')
->addStep('#open-invoice', 'Invoices', 'Open one to see the preview')
->addStep('#invoice-preview', 'Preview', 'This renders after the modal opens', options: [
'waitForElement' => 2000,
]);
The tour pauses on that step until the selector matches or the timeout expires.
Translated labels, no JS locale bundle
Every string Driver.js renders comes from an option UX Driver serializes, so translation happens in Twig
with the rest of the page:
<twig:Driver:Tour
id="checkout-tour"
progressText="{{ '{{current}}' }} / {{ '{{total}}' }}"
nextBtnText="{{ 'tour.next'|trans }}"
prevBtnText="{{ 'tour.back'|trans }}"
doneBtnText="{{ 'tour.done'|trans }}"
>
...
</twig:Driver:Tour>
Set them on the tour for the whole walkthrough, or on a single step to override it there.
Thanks for reading
The full documentation, with the reference tables for every tour, step and hint option, is here, and the source is on GitHub.
If the bundle is useful to you, a star helps a lot. Issues and pull requests are very welcome, and I am
curious to hear what you end up building with it.
Happy touring with Symfony!
Top comments (0)