Tutorial: Build a server-driven guided product tour
The standard guided-tour libraries (Intro.js, Shepherd, Driver.js) all live in JavaScript-land: a JSON config of selectors and tooltip text, a JS module that walks them, and a separate stylesheet for the highlight cutouts. Every step is duplicated between your Django views (which know what data exists for the user) and the JS config (which knows where to point arrows).
djust flips this with self.push_commands(chain) — the
server can send any JS Command chain
to the client and have it execute. The tour script lives in
Python, branches on user state in Python, and the framework
ships the DOM ops down the WebSocket as needed.
By the end of this tutorial you'll have:
- A "Show me around" button on a fresh user's dashboard.
- A 4-step tour that highlights the Create button, then scrolls to the Activity feed, then pops a tooltip on the search box, then opens the help menu.
- Branching: if the user has zero projects, the tour talks about creating one; if they have projects, it talks about managing them.
- Pause / resume — the user can click "Skip" mid-tour, state is preserved server-side.
- One-time-only — the tour fires for new users; once completed, it doesn't fire again.
| You'll learn | Documented in |
|---|---|
self.push_commands(JS.…) to drive client UI from Python | Server-Driven UI |
| Branching tour scripts based on server state | This tutorial |
JS.transition() chains for highlight + scroll + tooltip | JS Commands |
| When server-driven UI beats hooks (and when it doesn't) | This tutorial |
Prerequisites: Quickstart, the JS Commands tutorial, and familiarity with
state(...)from earlier tutorials. A dashboard view to bolt the tour onto.
The core idea
JS Commands (tutorial #11) are
chains of DOM operations the client runs. Normally you wire one
to a dj-click and the user triggers it. With
push_commands, the server triggers it — same chain,
different fire mechanism.
class DashboardView(LiveView):
@event_handler
def show_tour_step_1(self, **kwargs):
self.push_commands(
JS.add_class("tour-highlight", to="#btn-create")
.scroll_to("#btn-create")
.focus("#btn-create")
)
The user clicks "Show me around" → the server runs
show_tour_step_1 → it pushes the chain → the client adds the
class, scrolls, and focuses. No JS authored.
The key insight: the same JS builder that powers
dj-click="{{ chain }}" (client-triggered) also powers
self.push_commands(chain) (server-triggered). One mental model,
two trigger mechanisms.
Step 1 — Tour state model
# myapp/models.py
from django.conf import settings
from django.db import models
class TourProgress(models.Model):
user = models.OneToOneField(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE,
related_name="tour_progress",
)
completed = models.BooleanField(default=False)
skipped_at_step = models.PositiveIntegerField(null=True)
last_seen_at = models.DateTimeField(auto_now=True)
A single row per user tracks tour completion. The tour fires
only when this row is missing or completed=False.
Step 2 — The view
# myapp/views.py
from djust import LiveView, action, event_handler, state
from djust.js import JS
from .models import Project, TourProgress
TOUR_STEPS = [
"step_1_create_button",
"step_2_activity_feed",
"step_3_search_box",
"step_4_help_menu",
]
class DashboardView(LiveView):
template_name = "dashboard.html"
show_tour_button = state(False)
tour_active = state(False)
tour_step = state(0)
has_projects = state(False)
def mount(self, request, **kwargs):
if not request.user.is_authenticated:
self.redirect("/login/")
return
self.has_projects = Project.objects.filter(owner=request.user).exists()
progress, _ = TourProgress.objects.get_or_create(user=request.user)
# Show the "Show me around" button only to users who haven't
# completed (or skipped past) the tour yet.
self.show_tour_button = not progress.completed
@event_handler
def start_tour(self, **kwargs):
self.tour_active = True
self.tour_step = 1
self._dispatch_step(1)
@event_handler
def next_tour_step(self, **kwargs):
self.tour_step += 1
if self.tour_step > len(TOUR_STEPS):
self._finish_tour()
return
self._dispatch_step(self.tour_step)
@event_handler
def skip_tour(self, **kwargs):
TourProgress.objects.filter(user=self.request.user).update(
skipped_at_step=self.tour_step,
completed=True, # don't show again — they explicitly opted out
)
self.tour_active = False
self.show_tour_button = False
# Clean up any lingering tour classes.
self.push_commands(
JS.remove_class("tour-highlight", to=".tour-highlight")
)
def _finish_tour(self):
TourProgress.objects.filter(user=self.request.user).update(completed=True)
self.tour_active = False
self.show_tour_button = False
self.push_commands(
JS.remove_class("tour-highlight", to=".tour-highlight")
.show("#tour-complete-toast")
.transition(
("opacity-100", "opacity-0"),
to="#tour-complete-toast",
time=2000, # 2s display
)
.hide("#tour-complete-toast", time=2000)
)
def _dispatch_step(self, n: int):
# Each step builds and pushes a JS chain. The chains are
# written here in Python, but they execute in the browser.
if n == 1:
verb = "Create your first project" if not self.has_projects \
else "You can create more projects from"
self.push_commands(
JS.remove_class("tour-highlight", to=".tour-highlight")
.add_class("tour-highlight", to="#btn-create")
.scroll_to("#btn-create")
.show("#tour-tooltip")
.set_attr("data-text", verb + " here.", to="#tour-tooltip")
)
elif n == 2:
self.push_commands(
JS.remove_class("tour-highlight", to=".tour-highlight")
.add_class("tour-highlight", to="#activity-feed")
.scroll_to("#activity-feed")
.set_attr(
"data-text",
"Recent updates from your team show up here.",
to="#tour-tooltip",
)
)
elif n == 3:
self.push_commands(
JS.remove_class("tour-highlight", to=".tour-highlight")
.add_class("tour-highlight", to="#search-box")
.scroll_to("#search-box")
.focus("#search-box")
.set_attr(
"data-text",
"Search anything in your workspace from here. "
"Tip: ⌘K opens it from any page.",
to="#tour-tooltip",
)
)
elif n == 4:
self.push_commands(
JS.remove_class("tour-highlight", to=".tour-highlight")
.add_class("tour-highlight", to="#help-menu-trigger")
.scroll_to("#help-menu-trigger")
.show("#help-menu", display="block")
.set_attr(
"data-text",
"Stuck? Help, docs, and support live here.",
to="#tour-tooltip",
)
)
Five things to call out:
push_commands(chain)is the one piece of new API. It's on everyLiveViewviaPushEventMixin. Takes aJSbuilder, pushes adjust:execevent the client interprets.- The chain always starts with
JS.remove_class("tour-highlight", to=".tour-highlight")— clean-up of the previous step's highlight before applying the new one. Without it, every step's highlight would accumulate and the page would look like a Christmas tree. - Branching on server state (
if not self.has_projects) happens in Python — this is the whole point. The tour text adapts to the user's actual data. - No JS module to write or maintain. No JSON config of selectors. The selectors live next to the Python that knows what to highlight.
scroll_toandset_attrare part of the standard JS Commands set — see the JS Commands guide for the full list.
Step 3 — The template
<!-- myapp/templates/dashboard.html -->
<header class="dash-head">
<h1>Dashboard</h1>
<input id="search-box" type="search" placeholder="Search…" />
<button id="btn-create">+ New project</button>
<button id="help-menu-trigger" type="button">?</button>
<div id="help-menu" hidden>{# … help links … #}</div>
</header>
<section id="activity-feed">
<h2>Activity</h2>
{# … activity items … #}
</section>
{# Tour-only UI — hidden when not active. #}
{% if show_tour_button and not tour_active %}
<button class="tour-launcher" type="button" dj-click="start_tour">
Show me around →
</button>
{% endif %}
{% if tour_active %}
<div id="tour-tooltip" class="tour-tooltip" data-text="">
<p class="tour-text">{# populated by JS chain via set_attr #}</p>
<p class="tour-step-meta">Step {{ tour_step }} of {{ TOUR_STEPS|length }}</p>
<div class="tour-actions">
<button type="button" dj-click="skip_tour">Skip</button>
<button type="button" dj-click="next_tour_step">
{% if tour_step < 4 %}Next →{% else %}Done{% endif %}
</button>
</div>
</div>
{% endif %}
{# Toast that flashes after tour completion. Hidden by default,
shown by the _finish_tour chain. #}
<div id="tour-complete-toast" class="toast opacity-100" hidden>
Tour complete. Welcome aboard!
</div>
The tooltip element is rendered once when the tour is active.
Its data-text attribute is populated via JS.set_attr from
each step's chain — so the same DOM node holds the
current-step text without re-rendering the whole template on
every step change.
Step 4 — The CSS
.tour-highlight {
outline: 3px solid #f59e0b;
outline-offset: 4px;
border-radius: 4px;
transition: outline-color 0.2s, outline-offset 0.2s;
position: relative;
z-index: 100; /* lift above page content for visual prominence */
}
.tour-tooltip {
position: fixed;
bottom: 24px;
right: 24px;
width: 320px;
padding: 16px;
background: #fff;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0,0,0,0.15);
z-index: 1000;
}
.tour-tooltip::before {
content: attr(data-text);
display: block;
font-size: 14px;
line-height: 1.5;
margin-bottom: 12px;
}
.tour-step-meta {
font-size: 11px;
color: #6b7280;
margin: 4px 0 12px;
}
.tour-actions {
display: flex;
justify-content: space-between;
gap: 8px;
}
The ::before pseudo-element + attr(data-text) is the trick —
the text is rendered from the data-text attribute via CSS, so
when the JS chain calls set_attr("data-text", "..."), the
displayed text updates instantly without any JS Templating or
innerHTML mutation.
Step 5 — Try it as a new user
Sign up a fresh user, log in, hit /dashboard/. The "Show me
around" button is visible (no TourProgress row → defaults to
completed=False).
Click it:
- Step 1: the Create button gets a yellow outline; the page scrolls to it; the tooltip says "Create your first project here." (or "You can create more projects from here." if you already have some).
- Click Next: outline moves to the activity feed; tooltip updates.
- Click Next: outline moves to the search box; cursor focuses it; tooltip says to use ⌘K.
- Click Next: outline moves to the help menu; the menu opens; tooltip explains it.
- Click Done: toast flashes "Tour complete. Welcome aboard!"
for 2s and fades;
TourProgress.completed = True. The "Show me around" button never appears again.
Reload the page. The button is gone. The tour is one-time-only.
When server-driven UI beats other patterns
You could implement this tour with JS Commands chains stored
in template variables (no
push_commands):
<button dj-click="{{ tour_step_1_chain }}">Step 1</button>
That works for fixed scripts. The win of push_commands is
branching on server state — the tour script changes based on
self.has_projects, the user's plan tier, recent activity, or
anything else the server knows. The browser doesn't need to.
Compare to hooks: hooks are
right when you're integrating a third-party library. They
require authoring JS. push_commands is right when the server
needs to drive purely-DOM changes from Python without any
custom JS at all. They overlap on a Venn diagram, but the
common-case answer is:
- "I need this third-party library mounted" → hook
- "I need to flash this button from the server" →
push_commands
What just happened, end to end
Browser Server
│ │
│ click "Show me around" │
│ ──────────────────────────────────► start_tour
│ │ self.tour_active = True
│ │ self.tour_step = 1
│ │ self._dispatch_step(1)
│ │ branches on self.has_projects
│ │ builds JS chain in Python
│ │ self.push_commands(chain)
│ ◄ djust:exec event ────────────────────│
│ client interprets ops: │
│ 1. remove_class tour-highlight │
│ 2. add_class tour-highlight on │
│ #btn-create │
│ 3. scroll_to #btn-create │
│ 4. show #tour-tooltip │
│ 5. set_attr data-text on #tour-tooltip│
│ (no further round-trip; │
│ template re-render also lands │
│ to mount the tooltip element) │
│ │
│ click "Next" │
│ ──────────────────────────────────► next_tour_step
│ │ self.tour_step = 2
│ │ self._dispatch_step(2) → push chain
│ ◄ djust:exec ──────────────────────────│
│ highlight moves, tooltip text │
│ updates, page scrolls │
│ │
│ ... 2 more steps ... │
│ │
│ click "Done" on step 4 │
│ ──────────────────────────────────► next_tour_step
│ │ self.tour_step = 5 > len(TOUR_STEPS)
│ │ _finish_tour()
│ │ TourProgress.completed = True
│ │ push_commands(toast chain)
│ ◄ djust:exec (toast + cleanup) ────────│
│ ◄ patch (tour UI removed) ─────────────│
Two separate transports doing distinct things at once: HTML
patches (the tour UI mounting/unmounting via state changes) and
djust:exec events (the server driving DOM ops on existing
elements). They're complementary; nothing competes.
Where to go next
- AI-driven tours: the
_dispatch_stepbody could call an LLM ("describe what's on screen for this user") and use the reply to build the chain. The server has full context; the LLM picks the next step. - Remote support handoff: a support agent in a separate
view could
push_commandsto a customer's session — "let me highlight the button you need to click." Drop the chain through Channels.group_send and the support agent's view becomes a remote control. - Onboarding A/B: branch the tour script on
request.user.experiment_group. The server holds the treatment assignment; the client never needs to know which variant is active. - Automated UI testing: record a
push_commandssequence as a fixture; replay it in your test suite to drive a headless browser through the same DOM ops a real user would trigger. - Voice / accessibility integration: wire a speech-to-text
hook to a server handler that builds the appropriate JS chain
from the recognized intent ("show me my recent activity" →
push_commands(scroll_to("#activity-feed"))).
The shape — self.push_commands(JS.…) from any handler — is
the entire surface. It's small, but the patterns it enables
(server-driven tours, remote handoffs, AI-orchestrated UI) are
patterns that don't have a clean equivalent in client-side
frameworks. Worth knowing it exists; reach for it when "the
server should drive the next DOM op" is the right model.