Documentation · See the demo · Changelog · RubyGems · GitHub
Turbo Tour
turbo_tour is a lean Rails engine for guided onboarding tours built around Hotwire Turbo, a single Stimulus controller, YAML-defined journeys, and a framework-agnostic tooltip surface.
Features
- Multiple journeys loaded from YAML files in
config/turbo_tours - One shared Stimulus controller for every tour on the page
- Targeting via
data-tour-step="..."instead of IDs or CSS selectors - Configurable highlight classes for any host-app CSS approach
- Default tooltip partial with semantic classes that can be overridden in the host app
- Optional non-skippable tours for flows that must be completed
- Per-journey completion hooks and lightweight runtime extensions
- DOM analytics events with session and progress metadata
- Keyboard support, focus management, and lightweight positioning
Installation
Add the gem to your Rails app:
gem "turbo_tour"Then install it:
bundle install
bin/rails generate turbo_tour:installTurbo Tour now expects the host app to use the standard Rails importmap + Stimulus setup for loading the shared controller from the gem.
The installer will:
- create
config/turbo_tours/example.yml - create
config/initializers/turbo_tour.rb - make the shared Stimulus controller available from the gem in importmap-based apps
- register the controller only when your Stimulus setup uses manual registration
If you want a local copy of the default tooltip partial to style, run:
bin/rails generate turbo_tour:install:viewsDefine Tour Targets
Use data-tour-step attributes on the elements you want to spotlight:
<button data-tour-step="create-project">
Create Project
</button>
<section data-tour-step="dashboard-metrics">
...
</section>How Targeting Works
Turbo Tour uses two small data attributes when you launch tours from markup:
-
data-tour-step="create-project"marks a DOM element that a YAML step can target -
data-tour-journey="dashboard_intro"tellsclick->turbo-tour#startwhich preloaded journey to start
That means this button:
<button data-tour-step="create-project">
Create Project
</button>is resolved by this YAML step:
- name: create_project
target: create-project
title: "Create your first project"
body: "Click here to begin."At runtime, Turbo Tour turns the step's target into the selector [data-tour-step="create-project"].
Create Journeys
Journeys live in YAML. Step order is determined by array order, so you do not need explicit indexes.
journeys:
dashboard_intro:
- name: create_project
target: create-project
title: "Create your first project"
body: "Click here to create your first project."
- name: dashboard_metrics
target: dashboard-metrics
title: "Track performance"
body: "This area shows your analytics metrics."
invite_team:
- name: invite_button
target: invite-button
title: "Invite your team"
body: "Bring collaborators into your workspace."Each step key has one job:
-
nameis the step identifier used in events and analytics payloads -
targetmaps to the matchingdata-tour-stepvalue in the DOM -
titleis the tooltip heading -
bodyis the tooltip copy
Step order comes from the YAML array order. The first item is step 1, the second item is step 2, and so on. No explicit index is needed.
Render a Tour
Render one or more journeys into a view:
<%= turbo_tour "dashboard_intro" %>To preload multiple journeys into one controller root:
<%= turbo_tour "dashboard_intro", "invite_team", auto_start: false %>By default, the first journey auto-starts. Set auto_start: false when you want to trigger tours manually.
If a tour should not be dismissible, pass skippable: false:
<%= turbo_tour "security_setup", auto_start: false, skippable: false do %>
...
<% end %>When a tour is not skippable, Turbo Tour hides the skip control and ignores the Escape key for that helper root.
If one helper root preloads several journeys, you can override skip behavior per journey:
<%= turbo_tour "dashboard_intro", "security_setup",
auto_start: false,
skippable: { dashboard_intro: true, security_setup: false } do %>
...
<% end %>Start Tours Manually
The cleanest manual-start pattern is to wrap the relevant page markup with the helper and trigger the shared controller directly:
<%= turbo_tour "dashboard_intro", auto_start: false do %>
<button type="button" data-action="click->turbo-tour#start" data-tour-journey="dashboard_intro">
Start tour
</button>
<button data-tour-step="create-project">
Create Project
</button>
<% end %>This keeps the launch button, step targets, and tooltip template inside the same Stimulus scope without adding a second host-app controller.
Turbo Tour also exposes a browser API when you need to start a rendered journey from separate JavaScript:
TurboTour.start("dashboard_intro")It also exposes a completion hook API so host apps can add behavior in separate JavaScript modules instead of editing the base controller:
TurboTour.onComplete("dashboard_intro", ({ detail }) => {
window.analytics?.track("Dashboard Intro Completed", detail)
})If you prefer module imports over globals, the gem-provided controller module also exports onComplete and registerExtension, so you can keep host-specific behavior in a separate file.
The helper renders one controller root for the wrapped content:
<div
data-controller="turbo-tour"
data-turbo-tour-journey="dashboard_intro"
...
></div>With the default Rails importmap + Stimulus setup, no controller file needs to be copied into the host app. Turbo Tour pins controllers/turbo_tour_controller from the gem, so eagerLoadControllersFrom("controllers", application) will pick it up automatically.
If your app uses manual Stimulus registration instead of eager or lazy loading, import the gem controller like this:
import TurboTourController from "controllers/turbo_tour_controller"
application.register("turbo-tour", TurboTourController)Completion Hooks and Extensions
Register a reusable extension when you want grouped lifecycle behavior:
TurboTour.registerExtension({
name: "onboarding-follow-ups",
journeys: {
dashboard_intro: {
onComplete() {
window.location.assign("/projects/new")
}
},
invite_team: {
onComplete({ detail }) {
window.app?.celebrate(detail.journey_name)
}
}
}
})Completion hooks receive a context object with:
-
detail, which matches the DOM event payload -
journeyName,stepName,stepIndex,totalSteps -
progress,progressPercentage,sessionId -
controller,target,panel,step, andsteps
Extensions can implement these lifecycle methods:
onStartonNextonPreviousonCompleteonSkip
Multiple Journeys on the Same Page
More than one journey can exist on the same page. You can either:
- render separate helper roots for each journey
- preload several journeys into one helper root and start them by name
Every runtime path still uses the same controllers/turbo_tour_controller module from the gem unless the host app intentionally overrides that pin locally.
Styling
Turbo Tour does not require Tailwind or any other CSS framework.
Highlighting is class-based so you can plug in whatever styling approach your host app already uses. The default highlight class string is empty:
""Override them in the initializer:
TurboTour.configure do |config|
config.highlight_classes = "is-tour-highlighted"
endIf your app uses utility classes, component classes, or design-system hooks, pass those classes here. Turbo Tour only adds and removes the configured class string.
You can also make tours non-skippable by default:
TurboTour.configure do |config|
config.skippable = false
endOverride the Tooltip Partial
Turbo Tour renders turbo_tour/tooltip, so the host app can override it by adding:
app/views/turbo_tour/_tooltip.html.erb
The fastest way to start from the gem's default structure is:
bin/rails generate turbo_tour:install:viewsThe shipped partial is intentionally framework-agnostic and exposes semantic classes such as:
turbo-tour-tooltipturbo-tour-tooltip__contentturbo-tour-tooltip__titleturbo-tour-tooltip__bodyturbo-tour-tooltip__button
Keep these hooks in your override so the controller can populate and control the UI:
data-turbo-tour-paneldata-turbo-tour-titledata-turbo-tour-bodydata-turbo-tour-progressdata-turbo-tour-prevdata-turbo-tour-next
Include data-turbo-tour-skip if you want the partial to render a skip control. Turbo Tour can run without it.
The default partial already includes the right data-action bindings, so host apps can copy and restyle it without reworking the controller contract.
Analytics Events
Turbo Tour dispatches DOM events on document:
turbo-tour:startturbo-tour:nextturbo-tour:previousturbo-tour:completeturbo-tour:skip-tour
Each event includes:
{
session_id: "abc123",
journey_name: "dashboard_intro",
step_name: "create_project",
step_index: 0,
total_steps: 3,
progress: 0.33,
progress_percentage: 33
}Example analytics hook:
document.addEventListener("turbo-tour:complete", ({ detail }) => {
window.analytics?.track("Turbo Tour Completed", detail)
})Or, if the analytics call should only run for one specific journey:
TurboTour.onComplete("dashboard_intro", ({ detail }) => {
window.analytics?.track("Dashboard Intro Completed", detail)
})Accessibility
The default controller and partial provide:
- keyboard navigation with left and right arrows, plus Escape when the tour is skippable
- focus transfer into the tooltip while a tour is active
- focus restoration when the tour ends
-
role="dialog"and ARIA labeling on the tooltip panel
Notes
- Journeys are loaded from
config/turbo_tours/**/*.ymland**/*.yaml - Duplicate journey names across files raise an error to keep behavior deterministic
- Missing target elements are skipped so partially-rendered pages do not crash the tour
Example
<%= turbo_tour "dashboard_intro", auto_start: false do %>
<button type="button" data-action="click->turbo-tour#start" data-tour-journey="dashboard_intro">
Start tour
</button>
<button data-tour-step="create-project">Create Project</button>
<% end %>journeys:
dashboard_intro:
- name: create_project
target: create-project
title: "Create your first project"
body: "Click here to begin."