Introduction & Scenario
While I believe that the ideal scenario is for the application to be self-explanatory, sometimes a guided tour can make the user journey more compelling and easier to navigate.
For this purpose, I implemented a multi-page connected tour using Driver.js: a no-dependency library for creating step-by-step guided tours.
Context
I have a self-hosted Rails app for managing projects and track time that I needed a way to bridge the gap between the frontend tour library and the Rails backend. The requirements were:
- The tour should automatically start after the first workspace setup.
- Even if the user takes a different path, the tour should trigger on their first visit to each page.
- Users should have the option to reactivate the tour later if needed.
- Tours should be organized into multiple files for better maintainability and translations.
- Each page’s tour should be mapped similarly to how routes are defined in Rails.
To test the tour feature or view the code visit the GitHub:
https://github.com/eigenfocus/eigenfocus/
Here’s the tour working in practice:

Driver.js example usage
Here’s a quick example on how to use Driver.js:
import { driver } from "driver.js";
const driverObj = driver({
animate: true,
showProgress: true
});
driverObj.setSteps([
{
element: '#first-element',
popover: {
title: 'Title',
description: 'Description'
}
},
{
element: '#second-element',
popover: {
title: 'Another Title',
description: 'Another Description'
}
}
]);
driverObj.drive();
Driver.js Ruby on Rails – Installation
To install Driver.js in a Rails application, you can use the importmap approach, placing the library inside the vendor folder:
pin "driver.js"
Don’t forget to also import the CSS:
<%= stylesheet_link_tag "driverjs.css", "data-turbo-track": "reload" %>
Solution architecture
In our application, each page can have a corresponding tour that guides users through its features.
By specifying the tour key in the view, we can integrate the frontend and backend to determine which tour should be activated for the current page.
Here’s how you can specify a tour for a page:
<%= start_pending_app_tour_tag("projects/index") %>
This tag communicates with the frontend to identify the appropriate tour key configuration for the page.
It also checks if the tour is on the "pending tour list" and, if so, automatically starts the tour.
Tours are marked as pending after the initial application setup, ensuring that users receive guidance when they first explore the app.
The frontend – Vanilla Javscript
I’m using a custom class AppTour to manage the tour and steps.
PS: The final implementation on the repository is a little more robust with a dedicated tour_config.js file to centralize the tours imports and manage translations.
The AppTour Class
The AppTour class is responsible for:
- Managing the Driver.js instance and the tour steps configurations
- Handling the start, stop and presentation flow
- Managing pending tours
Here’s some comments about the implementation and the code:
constructor– setup driver.js- The
start(tourKey),startIfPending(tourKey)andstopTour()methods should be self explanatory - There are also methods for handling the pendings tours:
markAllToursAsPending,markTourAsCompletedandgetPendingTours - The
TOUR_CONFIGSconstant are an object where the keys are the tour key such asprojects/index,issues/indexand the values are the driverJs tour steps for the given key.
import { driver } from "driver.js"
import projectTours from "app-tours/projects_tours"
import issuesTour from "app-tours/issues_tour"
// ... other tour imports
const TOUR_CONFIGS = {
...projectTours,
...issuesTour,
// ... other tour configurations
}
class AppTour {
constructor() {
this.driverObj = driver({
animate: true,
showProgress: true,
allowClose: true,
overlayClickBehavior: 'nextStep',
disableActiveInteraction: true,
onCloseClick: () => {
this.stopTour()
},
// This adds a button to close the tour at anytime
onPopoverRender: (popover, { config, state }) => {
const firstButton = document.createElement("button");
firstButton.innerText = "Close tour";
popover.footerButtons.appendChild(firstButton);
firstButton.addEventListener("click", () => {
this.stopTour()
});
},
})
// If the user clicks on the higlighted area, it also should go to the next step
window.addEventListener('click', (event) => {
if (this.driverObj.isActive()) {
this.driverObj.moveNext()
}
})
}
startIfPending(targetTourKey) {
const pendingTours = this.getPendingTours()
if (pendingTours.includes(targetTourKey)) {
this.start(targetTourKey)
}
}
start(tourKey) {
if (!tourKey) {
console.log("No tour key provided")
return
}
this.stopTour()
if (this.tourConfigs[tourKey]) {
this.markTourAsCompleted(tourKey)
this.driverObj.setSteps(this.tourConfigs[tourKey])
this.driverObj.drive()
} else {
console.log("No tour config found for key:", tourKey)
}
}
stopTour() {
this.driverObj.destroy()
}
markAllToursAsPending() {
sessionStorage.setItem('pendingTours', JSON.stringify(Object.keys(this.tourConfigs)))
}
markTourAsCompleted(tourKey) {
const pendingTours = this.getPendingTours()
const updatedPendingTours = pendingTours.filter(tour => tour !== tourKey)
sessionStorage.setItem('pendingTours', JSON.stringify(updatedPendingTours))
}
getPendingTours() {
const storedPendingTours = sessionStorage.getItem('pendingTours')
return storedPendingTours ? JSON.parse(storedPendingTours) : []
}
get tourConfigs() {
return TOUR_CONFIGS;
}
}
export { AppTour }
How to use the AppTour class
To use the AppTour class, you instantiate it and make it available in the window object. You do this in your application.js
import { AppTour } from "app-tours"
window.appTour = new AppTour()
Tour steps files
The tour steps files stores the tour steps configurations. Here’s an example from projects_tours.js:
// app/javascript/app-tours/projects_tours.js
export default {
"projects/index": [
{
element: ".tour--add-project",
popover: {
title: "Create New Project",
description: "Start a new project by clicking this button"
}
},
// ... more steps
]
}
The Backend Code
On the backend, we have helper methods to integrate tours in our Rails views.
Our implementation allows us to:
- Specify which tour correspond to the current view/page
- Start a tour when a page loads
- Mark all tours as pending (used after initial setup)
Using the Helpers in the Application Layout
Include this in your application layout:
<%= app_tour_tags %>
Implementing the Helpers
The helper methods execute javascript code and call methods on the AppTour instance stored in window.appTour to trigger (or not) tours for each page load.
module ApplicationHelper
module AppTour
def app_tour_tags
capture do
output = ""
output += start_app_tour_tag(params[:activate_tour]) if params[:activate_tour].present?
output += mark_all_app_tours_as_pending_tag if params[:mark_app_tours_as_pending] == "true"
output.html_safe
end
end
def start_pending_app_tour_tag(tour_key = nil)
javascript_tag <<~JS
document.addEventListener("turbo:load", () => {
if (window.appTour) {
window.appTour.startIfPending("#{tour_key}");
}
}, { once: true });
JS
end
def start_app_tour_tag(tour_key)
javascript_tag <<~JS
document.addEventListener("turbo:load", () => {
window.appTour.start("#{tour_key}");
}, { once: true });
JS
end
def mark_all_app_tours_as_pending_tag
javascript_tag <<~JS
document.addEventListener("turbo:load", () => {
if (window.appTour) {
window.appTour.markAllToursAsPending();
}
}, { once: true });
JS
end
end
end
Triggering the tour after the first setup
After the setup, we trigger the tour system by setting a URL parameter:
redirect_to projects_path(mark_app_tours_as_pending: true)
Creating custom links to trigger a tour
You can create links to trigger the tour by using the activate_tour URL parameter.
<%= link_to "Start project tour", projects_path(activate_tour: "projects/index") %>
Connecting tours across pages
When the user clicks on a highlighted area, the original action (following the link, for example) is discarded due to the disableActiveInteraction: true option from Driver.js setup.

We disable this option for the last step of each tour. Example:
const projectsIndexTour = [
// ... previous steps
{
element: ".tour--workflow-board",
popover: {
title: "Workflow Board",
description: "View your issues in a board."
}
},
{
element: ".tour--issues-list",
popover: {
title: "Issues List",
description: "Start here: list all project issues."
},
disableActiveInteraction: false
},
]
export default {
"projects/index": projectsIndexTour
}
Can’t we accomplish this using only JavaScript?
Yes, we could implement the tour using JavaScript by analyzing the URL. However, I didn’t choose this approach for a few key reasons:
- Relying only on JavaScript means the tour could break if the URL structure changes.
- Defining the tour explicitly in the view:
- Makes our intent clearer.
- Helps us to remember to update the tour when modifying a page.
Why not store ‘pending tours’ in the database?
As an Example Project is created after setup, users are likely to complete the tour within the same session. Persisting it in the database would be an unnecessary overhead.
Conclusion
We’ve customized Driver.js to fit our needs to create a cool guided tour for user onboarding and feature discovery. Our solution allows us to:
- Integrate with the Rails backend
- Connect tours across pages
- Mark tours as pending and trigger them when visiting the corresponding pages
- Trigger a tour wherever we want
- Use separated tour configurations in the frontend code
You can test the tour feature or view the code by visiting the the project Github page:
https://github.com/eigenfocus/eigenfocus/
Subscribe to my newsletter!
I share content about Software Development & Architecture, Entrepreneurship and Lifelong Learning




