Building a product tour in Phoenix LiveView — without the usual headaches

Luca ·

SlashFeed has a rich feature surface: feed graphs, newsletter inboxes, topic-based tagger rules, reading statistics, agentic feeds, keyboard shortcuts. When a new user signs up, lands on an empty entry list, and sees no content and no guidance, the probability of them leaving before discovering any of it is very high.

We knew we needed onboarding. What we didn’t want was to bolt on a third-party SaaS tour tool, pollute every LiveView with boolean flags, or ship something that looked like it belonged in a 2015 starter kit. This post is an honest account of how we designed and built the feature, the mistakes we made along the way, and the architectural decisions that made the final result clean.


The four layers

Before writing a line of code, we defined exactly what “onboarding” meant for us. We settled on four distinct layers, each solving a different problem:

Tooltip tour — a sequential, arrow-decorated walk through the UI that fires automatically on the first login. The goal is to put a name to every major section before the user has to discover them by accident.

Spotlight / overlay — a dark overlay that cuts out a single element and focuses the user’s attention on one high-value moment: the keyboard shortcuts on the first visit to the entry list, the graph refresh button on the first visit to the feed graph.

Empty-state guidance — server-rendered contextual messages inside every page that can be empty. Instead of a generic “nothing here yet”, each empty state explains why it is empty and gives a direct CTA. No JavaScript needed.

Welcome checklist — a small dismissible panel in the sidebar listing seven beginner tasks. Each task auto-completes when the user performs the corresponding action. A progress bar exploits the “almost done” effect.

All four layers are repeatable from the user’s settings page.


Choosing the right library

We evaluated three options:

Library Bundle Spotlight Licence
Shepherd.js ~20 KB External CSS only MIT
Intro.js ~12 KB Commercial for some uses
Driver.js 5 KB ✓ built-in MIT

Driver.js won on every dimension that mattered. It covers spotlights and tooltip tours from a single API, has zero runtime dependencies, and its MIT licence has no commercial restrictions. We vendored the ESM build directly rather than adding npm to the project.

# Fetch the minified ESM build
curl -o assets/vendor/driver.js \
  https://cdn.jsdelivr.net/npm/[email protected]/dist/driver.js.mjs
curl -o assets/vendor/driver.css \
  https://cdn.jsdelivr.net/npm/[email protected]/dist/driver.css

One esbuild alias later, the import resolves correctly:

# config/config.exs
config :esbuild,
  slashfeed: [
    args: ~w(
      js/app.js --bundle --target=es2022
      --alias:driver.js=./vendor/driver.js
      ...
    )
  ]
// assets/js/hooks/onboarding.js
import { driver } from "driver.js";

The CSS overrides deserved attention. Driver.js ships with plain white popovers that clash with any dark-themed app. We replaced every colour with DaisyUI’s oklch(var(--p)) / oklch(var(--b2)) tokens so the tour adapts to light mode, dark mode, and any custom theme without maintenance:

.slashfeed-popover {
  background: oklch(var(--b2)) !important;
  color: oklch(var(--bc)) !important;
  border: 1px solid oklch(var(--b3) / 80%) !important;
  border-radius: 0.75rem !important;
}

.slashfeed-popover .driver-popover-next-btn {
  background: oklch(var(--p)) !important;
  color: oklch(var(--pc)) !important;
}

The data model

Onboarding state lives in a single JSONB column on the users table:

ALTER TABLE users
  ADD COLUMN onboarding jsonb NOT NULL DEFAULT '{}'::jsonb;

The Ecto migration is straightforward:

defmodule SlashFeed.Repo.Migrations.AddOnboardingToUsers do
  use Ecto.Migration

  def change do
    alter table(:users) do
      add :onboarding, :map, default: %{}
    end
  end
end

The stored structure looks like this after a few interactions:

{
  "tour_completed_at": "2026-06-15T10:30:00Z",
  "checklist_dismissed_at": null,
  "checklist_tasks": {
    "added_first_feed": true,
    "read_3_entries": true,
    "starred_entry": false,
    "created_category": false,
    "viewed_graph": false,
    "viewed_stats": false,
    "created_newsletter": false
  },
  "spotlights_seen": ["entries_keyboard"],
  "restart_count": 0
}

We chose a JSONB map over a separate user_onboarding table for two reasons. First, adding a new task requires no migration — you just start writing a new key. Second, every access is a single-row lookup that PostgreSQL handles with the user’s existing index.

The SlashFeed.Onboarding context wraps all reads and writes:

defmodule SlashFeed.Onboarding do
  alias SlashFeed.Accounts.User
  alias SlashFeed.Repo

  @checklist_tasks ~w[
    added_first_feed read_3_entries starred_entry
    created_category viewed_graph viewed_stats created_newsletter
  ]

  def show_tour?(%User{} = user) do
    get(user)["tour_completed_at"] == nil
  end

  def show_checklist?(%User{} = user) do
    ob = get(user)
    ob["checklist_dismissed_at"] == nil and not all_tasks_done?(ob)
  end

  def checklist_tasks(%User{} = user) do
    completed = get(user)["checklist_tasks"] || %{}
    Map.new(@checklist_tasks, &{&1, Map.get(completed, &1, false)})
  end

  def complete_task(%User{} = user, task) when task in @checklist_tasks do
    ob = get(user)
    tasks = (ob["checklist_tasks"] || %{}) |> Map.put(task, true)
    update_onboarding(user, Map.put(ob, "checklist_tasks", tasks))
  end

  # complete_task is idempotent — safe to call multiple times
  def complete_task(%User{} = user, _unknown), do: {:ok, user}

  defp update_onboarding(%User{} = user, new_ob) do
    user
    |> Ecto.Changeset.change(onboarding: new_ob)
    |> Repo.update()
  end
end

The central event hub — attach_hook

This is where the architecture got interesting. A naive implementation would add handle_event("dismiss_checklist", ...) and handle_event("tour_completed", ...) to every LiveView in the application. We have around 30 authenticated LiveViews. That is 30 × 4 = 120 lines of boilerplate that all do the same thing.

Phoenix LiveView’s attach_hook lets you intercept events before they reach the LiveView’s own handle_event. We put an on_mount hook in the router’s live_session block that attaches a shared event handler to every socket:

# router.ex
live_session :require_authenticated_user,
  on_mount: [
    {SlashFeedWeb.Hooks.AllowEctoSandbox, :default},
    {SlashFeedWeb.UserAuth, :require_authenticated},
    SlashFeedWeb.FeedRefresh,
    {SlashFeedWeb.Hooks.OnboardingMount, :default},  # ← one addition
    {SlashFeedWeb.Analytics.OnMount, :default}
  ] do
  # ... all 30 authenticated routes, unchanged
end

The hook itself handles every onboarding event and — crucially — uses a put_user/2 helper that writes the updated user back into current_scope:

defmodule SlashFeedWeb.Hooks.OnboardingMount do
  import Phoenix.Component, only: [assign: 3]
  alias SlashFeed.Onboarding
  alias Phoenix.LiveView

  def on_mount(:default, _params, _session, socket) do
    user = get_user(socket)
    socket = if user, do: attach_onboarding_hooks(socket), else: socket
    {:cont, socket}
  end

  defp attach_onboarding_hooks(socket) do
    LiveView.attach_hook(socket, :onboarding_events, :handle_event, fn
      "tour_completed", _params, sock ->
        {:ok, updated} = Onboarding.complete_tour(get_user(sock))
        {:halt, put_user(sock, updated)}

      "dismiss_checklist", _params, sock ->
        {:ok, updated} = Onboarding.dismiss_checklist(get_user(sock))
        {:halt, put_user(sock, updated)}

      "spotlight_dismissed", %{"name" => name}, sock ->
        {:ok, updated} = Onboarding.record_spotlight(get_user(sock), name)
        {:halt, put_user(sock, updated)}

      "checklist_task_done", %{"task" => task}, sock ->
        {:ok, updated} = Onboarding.complete_task(get_user(sock), task)
        {:halt, put_user(sock, updated)}

      "request_tour_steps", _params, sock ->
        steps = SlashFeedWeb.OnboardingSteps.main_tour()
        {:halt, LiveView.push_event(sock, "start_tour", %{steps: steps})}

      _event, _params, sock ->
        {:cont, sock}
    end)
  end

  defp get_user(socket) do
    scope = socket.assigns[:current_scope]
    scope && scope.user
  end

  defp put_user(socket, updated_user) do
    scope = socket.assigns.current_scope
    assign(socket, :current_scope, Map.put(scope, :user, updated_user))
  end
end

{:halt, socket} stops event propagation — the LiveView’s own handle_event never sees it. {:cont, socket} passes it through unchanged. The result: zero changes to any individual LiveView for the four core onboarding events.

The request_tour_steps handler is equally elegant. The JavaScript hook sends this event when it starts up (via data-autostart="true"). The attached hook catches it, looks up the step definitions server-side, and pushes start_tour back to the client. Tour copy lives in Elixir, not JavaScript:

defmodule SlashFeedWeb.OnboardingSteps do
  def main_tour do
    [
      %{
        element: "[data-onboarding='add-feed-btn']",
        title: "Start here — add your first feed",
        description: "Paste any RSS, Atom, or website URL. SlashFeed auto-detects the feed.",
        side: "right"
      },
      %{
        element: "[data-onboarding='entries-link']",
        title: "Your reading inbox",
        description: "Every new entry from every feed you follow lands here.",
        side: "right"
      },
      # ...
    ]
  end
end

This means changing tour copy never requires rebuilding the JavaScript bundle.


The hardest bug: disappearing checklist

The checklist box kept disappearing. We spent more debugging time on this than on any other part of the feature.

The root cause was subtle. Layouts.app is a Phoenix function component:

attr :show_checklist, :boolean, default: false
# ...
def app(assigns) do
  ~H"""
  <div :if={@show_checklist}>...</div>
  """
end

Function components in Phoenix LiveView only receive attributes that are explicitly passed at the call site. They do not automatically inherit the calling LiveView’s socket assigns. OnboardingMount added show_checklist: true to the socket, but since no LiveView passed it to Layouts.app, the component always saw its default value of false.

The naive fix — add show_checklist={assigns[:show_checklist] || false} to every <Layouts.app ...> call — would have required editing 34 files. Instead, we took a different approach: remove the four onboarding attrs from the component declaration entirely, and compute the values directly from @current_scope.user inside the template:

<%!-- Inside layouts.ex, inside <aside :if={@current_scope}> --%>
<%
  ob_user = @current_scope.user
  ob = (ob_user && ob_user.onboarding) || %{}
  ob_tasks = Map.get(ob, "checklist_tasks") || %{}
  all_keys = ~w[added_first_feed read_3_entries starred_entry ...]
  task_map = Map.new(all_keys, &{&1, Map.get(ob_tasks, &1, false)})
  ob_done = Enum.count(task_map, fn {_, v} -> v end)
  ob_progress = ob_done / max(map_size(task_map), 1)
  all_tasks_done = ob_done == map_size(task_map)
  show_checklist =
    ob_user != nil and
      not all_tasks_done and
      Map.get(ob, "checklist_dismissed_at") == nil
%>
<div :if={show_checklist} id="welcome-checklist">
  <%!-- ... --%>
</div>

@current_scope is already passed to Layouts.app by every caller. And OnboardingMount.put_user/2 updates current_scope.user in the socket after every mutation. So the checklist now reads directly from the freshest version of the user without any additional plumbing.

The <aside :if={@current_scope}> wrapper guarantees that @current_scope is non-nil inside that block, which eliminates a compiler warning about the redundant nil-check.

This change required zero edits to any of the 34 calling LiveViews. The component became self-sufficient.


The JavaScript hook

The OnboardingHook is deliberately thin. All business logic lives on the server; the hook’s only job is to call Driver.js when the server says to:

import { driver } from "driver.js";

const OnboardingHook = {
  mounted() {
    this._driverObj = null;

    this.handleEvent("start_tour", ({ steps }) => {
      this._destroyDriver();
      this._driverObj = driver({
        animate: true,
        showProgress: true,
        progressText: "{{current}} / {{total}}",
        popoverClass: "slashfeed-popover",
        steps: steps.map((s) => ({
          element: s.element,
          popover: {
            title: s.title,
            description: s.description,
            side: s.side || "bottom",
          },
        })),
        onDestroyStarted: () => {
          this.pushEvent("tour_completed", {});
          this._destroyDriver();
        },
      });
      this._driverObj.drive();
    });

    this.handleEvent("start_spotlight", ({ element, title, description, name }) => {
      this._destroyDriver();
      this._driverObj = driver({
        steps: [{ element, popover: { title, description, showButtons: ["close"] } }],
        onDestroyStarted: () => {
          if (name) this.pushEvent("spotlight_dismissed", { name });
          this._destroyDriver();
        },
      });
      this._driverObj.drive();
    });

    if (this.el.dataset.autostart === "true") {
      setTimeout(() => this.pushEvent("request_tour_steps", {}), 1000);
    }
  },

  destroyed() { this._destroyDriver(); },

  _destroyDriver() {
    if (this._driverObj) {
      try { this._driverObj.destroy(); } catch (_) {}
      this._driverObj = null;
    }
  },
};

The hook is mounted on a single hidden <div> inside the sidebar layout. Because it is always in the DOM for authenticated users, it can receive events pushed from any server-side LiveView, regardless of which page the user is on. The handleEvent listeners are registered once during mounted() and persist across LiveView navigations.

The 1,000 ms delay before request_tour_steps is intentional: we wait for the DOM to fully paint before Driver.js starts scanning for elements. Without it, elements in the sidebar might not be visible yet and Driver.js falls back to a centered dummy popover.


Wiring up the checklist tasks

Auto-completing tasks requires hooking into seven distinct actions across five LiveViews. The pattern is always the same:

# In the success branch of any LiveView handler:
{:ok, updated_user} = SlashFeed.Onboarding.complete_task(user, "task_name")
socket = assign(socket, :current_scope, %{socket.assigns.current_scope | user: updated_user})

Updating current_scope.user triggers a re-render of Layouts.app, which recomputes the checklist state from the fresh user struct — the progress bar animates, the completed task gets a green checkmark, and the user gets immediate feedback that the action was recognised.

Some tasks are straightforward: created_newsletter fires in the success branch of NewsletterLive.Index‘s create handler. Others required more care.

read_3_entries

This task has two paths to completion. Users can mark entries as read by clicking the ✓ button (fires mark_read) or by pressing the keyboard shortcut m (fires toggle_read). The original implementation only wired up mark_read. We found the bug when a user reported that reading ten articles with the keyboard shortcut never checked the box.

The fix was to add the same Feeds.total_reads(user, 365) >= 3 check to toggle_read:

def handle_event("toggle_read", %{"entry-id" => entry_id}, socket) do
  # ... existing logic ...
  if was_unread do
    Feeds.mark_as_read(user, entry)
  else
    Feeds.mark_as_unread(user, entry)
  end

  socket =
    if was_unread and Feeds.total_reads(user, 365) >= 3 do
      {:ok, updated_user} = SlashFeed.Onboarding.complete_task(user, "read_3_entries")
      assign(socket, :current_scope, %{socket.assigns.current_scope | user: updated_user})
    else
      socket
    end

  # ... rest of handler ...
end

Feeds.total_reads/2 is a lightweight COUNT query. We run it only when was_unread is true, so marking-as-unread never triggers it.

created_category

The category task completes when a user assigns a category to a feed via FeedLive.Edit. This handler calls push_navigate(to: ~p"/feeds") on success, which remounts FeedLive.Index. We don’t need to update the socket’s current_scope here — UserAuth.on_mount always reloads the user from the database on every LiveView mount, so FeedLive.Index will automatically see the updated onboarding map:

case Feeds.update_user_feed(user_feed, attrs) do
  {:ok, _updated} ->
    if category_id not in [nil, ""] do
      SlashFeed.Onboarding.complete_task(
        socket.assigns.current_scope.user,
        "created_category"
      )
    end

    {:noreply,
     socket
     |> put_flash(:info, "Feed updated!")
     |> push_navigate(to: ~p"/feeds")}

No socket update needed because the navigate causes a full remount with a fresh DB load.


Repeatability

Every part of the onboarding can be replayed from /users/settings. The Onboarding.restart/1 function resets tour_completed_at, checklist_dismissed_at, and all task flags, while incrementing a restart_count for analytics:

def restart(%User{} = user) do
  count = (get(user)["restart_count"] || 0) + 1
  update_onboarding(user, %{"restart_count" => count})
end

The settings card uses two event handlers wired into OnboardingMount‘s centralised mechanism. “Replay tour” resets the tour_completed_at flag; the next time the user navigates to any page, data-autostart="true" fires and the tour restarts. “Restart checklist” resets everything and brings the panel back to the sidebar.


What we learned

Vendor your JS dependencies. Adding Driver.js as a vendor file took five minutes and means no npm install step in CI, no supply chain risk, and a fully self-contained asset bundle. For a 5 KB library that changes rarely, this is almost always the right call.

Keep step definitions server-side. Tour copy is product copy. It changes as frequently as button labels and error messages. By keeping it in Elixir, it lives in version control alongside tests that verify the tour steps exist, and it never requires a JS rebuild to update.

attach_hook is underused. The on_mount + attach_hook pattern is the right way to share event-handling logic across LiveViews. It is exactly what it was designed for. We should have reached for it immediately instead of the naive “add to every handler” instinct.

JSONB state beats a new table. For per-user state with an evolving schema (we added restart_count three days after the initial release), a JSONB column is the pragmatic choice. A new field costs nothing. The downside — no indexed queries on individual task completion — is a real one, but for onboarding analytics, a periodic batch aggregation over the column is perfectly sufficient.

Compute from the source of truth. The disappearing checklist bug existed because we were trying to thread four boolean assigns through a 34-file component prop chain. The moment we changed the question from “how do I pass this data to the component?” to “does the component already have access to everything it needs?” the solution became obvious. @current_scope.user was always there. We were just not looking at it.


The complete implementation is in lib/slashfeed/onboarding.ex, lib/slashfeed_web/hooks/onboarding_mount.ex, lib/slashfeed_web/onboarding_steps.ex, and the <% ob_user = ... %> block in lib/slashfeed_web/components/layouts.ex. The design document is at docs/product-tour-onboarding.md.