
JB
Muke Johnbaptist
Go (Golang) expert · Creator of the Grit Framework
Getting-Started Guide and Guided Tour: A Playbook
A step-by-step playbook for adding a getting-started guide and guided tour to any Grit or Next.js app. Build a welcome dialog, a sidebar launcher with a progress ring, a data-driven checklist, and short tours that dim the screen and outline the exact button to click. Every snippet is type-checked, linted and tested — Go model, service, handler and test, plus the React hooks, tours, component, CSS tokens and app-shell wiring.
How to add the getting-started guide and guided tour from Outpost to any Grit or Next.js app. Follow the steps in order. Every snippet here was type-checked, linted and tested before it went into this file.
What you are building
Four pieces, each covering a gap the others leave:
| Piece | What the person sees | Why it is there |
|---|---|---|
| Welcome | A dialog on the first visit. It lists the first few steps and offers "Show me around" or "I'll look around myself". | It answers "what do I do first?" before the person has to ask. |
| Launcher | A card at the foot of the sidebar with a progress ring ("2/8") and "Next: Invite your team". | The guide is always there, and progress is always visible. |
| Checklist | A panel with every step. The next step is expanded with a reason, a Show me button and Skip. Done steps are ticked, and skipped ones can be undone. | It gives a clear order and a sense of progress. |
| Tour | Show me goes to the right page, dims the screen and draws a moving dashed outline around the exact link or button, with a short card beside it. The person can click the outlined thing directly. | It shows the next button instead of describing it. |
┌─────────────── sidebar ──┐ ┌──────────── page ──────────────────────────┐
│ Clients │ │░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░│
│ ┌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┐ │ │░░ ┌──────────────────────────────┐ ░░░░░░░░│
│ ╎ Settings ╎◄┼──┼░░ │ Add your logo · 1 of 2 ✕ │ ░░░░░░░░│
│ └╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┘ │ │░░ │ Settings │ ░░░░░░░░│
│ ┌──────────────────────┐ │ │░░ │ Everything about your work- │ ░░░░░░░░│
│ │ (2/8) Getting started│ │ │░░ │ space lives here. │ ░░░░░░░░│
│ │ Next: Add your logo │ │ │░░ │ ━ · [Next →] │ ░░░░░░░░│
│ └──────────────────────┘ │ │░░ └──────────────────────────────┘ ░░░░░░░░│
└──────────────────────────┘ └────────────────────────────────────────────┘
the outlined link stays clickable; everything else is dimmedDecisions that make it work
- Steps are ticked from real data, not clicks. "Add your first client" is done when a client exists, however it got there: the form, an import, or the API. The guide asks again whenever the page changes, so the tick appears soon after the work is done.
- Two paths. Whoever sets the workspace up gets setup steps (logo, team, and so on). An invited member gets steps about their own work. A step the person has no permission for is left out.
- Short tours. Two or three stops each. The last stop is the button that does the job.
- The highlighted thing stays clickable. The dim is four rectangles around a hole, not one sheet. Clicking the outlined link or button is the next step.
- Everything can be skipped or hidden, and brought back. Skipped steps count toward progress, so nobody feels stuck. "Getting started" in the account menu brings a hidden guide back.
Research behind these choices (checklists of about 5 to 8 steps, tick from actions, short contextual tours, skippable):
- https://www.brand.dev/blog/saas-onboarding-best-practices
- https://userflow.com/blog/the-ultimate-guide-to-product-tours-boost-user-onboarding-and-engagement
- https://www.mailmodo.com/guides/product-tours/
- https://designrevision.com/blog/saas-onboarding-best-practices.md
Before you start
| You need | Notes |
|---|---|
| Next.js App Router | The code uses usePathname and useRouter from next/navigation. |
| Tailwind CSS v4 | It uses @theme tokens. On Tailwind v3, put the same tokens in tailwind.config under theme.extend. |
@tanstack/react-query | It is used for the guide query. Grit web apps already have it. |
lucide-react | It provides the icons. |
| A Go API (Grit) | Optional. Option B below keeps everything in the browser. |
Files you will add:
apps/api/internal/models/user_guide.go the saved state (welcomed, hidden, seen, skipped)
apps/api/internal/services/guide.go the steps, and whether each is done
apps/api/internal/services/guide_test.go
apps/api/internal/handlers/guide.go GET and PUT /api/guide
apps/web/components/guide/use-guide.ts React Query hooks
apps/web/components/guide/tours.ts the stops of each tour
apps/web/components/guide/guide.tsx provider, launcher, checklist, welcome, tour
apps/web/app/globals.css tokens and the moving-outline animationStep 1: The model (Go)
apps/api/internal/models/user_guide.go
package models
import (
"time"
"gorm.io/datatypes"
)
// GuideUserID is the type of your users' primary key. Change it to uint if
// your User model uses numeric ids.
type GuideUserID = string
// UserGuide is where one person has got to with the getting-started guide.
// Most steps are worked out from the records themselves; Seen holds the few
// that are done by looking, and Skipped those the person chose to skip.
type UserGuide struct {
ID uint `gorm:"primarykey" json:"-"`
UserID GuideUserID `gorm:"size:36;not null;uniqueIndex" json:"-"`
Seen datatypes.JSONSlice[string] `gorm:"type:json" json:"seen"`
Skipped datatypes.JSONSlice[string] `gorm:"type:json" json:"skipped"`
WelcomedAt *time.Time `json:"welcomed_at"`
HiddenAt *time.Time `json:"hidden_at"`
CreatedAt time.Time `json:"-"`
UpdatedAt time.Time `json:"-"`
}Register it for migration. In a Grit project, add it to the model list at the // grit:models marker (usually in internal/models/user.go):
&UserGuide{},
// grit:modelsMultitenant projects (the user belongs to several organisations): add OrgID and put both columns in one unique index, so each person has one row per workspace. Then filter by both in row() (Step 2):
UserID string `gorm:"size:36;not null;uniqueIndex:idx_user_guide" json:"-"`
OrgID string `gorm:"size:36;not null;uniqueIndex:idx_user_guide" json:"-"`Step 2: The service (Go): the steps and whether each is done
apps/api/internal/services/guide.go
This file is the brain. Replace setupSteps and memberSteps with your app's steps. Each step has:
key: it must match a key intours.ts(Step 6) and appear inguideKeys.titleandwhy: short, in plain words.whyappears only on the next step.show: whether this person gets the step at all (a permission or an enabled module).done: a query against the real tables. Leave itnilfor a step done by looking (it is then marked seen when its tour ends).
package services
import (
"context"
"errors"
"slices"
"time"
"gorm.io/gorm"
"yourapp/apps/api/internal/models"
)
// The getting-started guide: the next thing to do, for this person. Whoever
// sets the app up (an admin) gets the setup path; everyone else gets the
// member path. A step the person may not do is left out. Whether a step is
// done is read from the records themselves, so it is done however it was
// done. The tour that shows each step is the web app's (components/guide).
// GuideStep is one thing to do.
type GuideStep struct {
Key string `json:"key"`
Title string `json:"title"`
Why string `json:"why"`
Href string `json:"href"`
Done bool `json:"done"`
Skipped bool `json:"skipped"`
}
// Guide is the person's guide.
type Guide struct {
Path string `json:"path"` // "setup" or "member"
Steps []GuideStep `json:"steps"`
Done int `json:"done"`
Total int `json:"total"`
Complete bool `json:"complete"`
Welcomed bool `json:"welcomed"`
Hidden bool `json:"hidden"`
}
// GuideCaller is who is asking: their id and what they may do.
type GuideCaller struct {
UserID models.GuideUserID
Can func(perm string) bool
}
type guideCtx struct {
db *gorm.DB
GuideCaller
}
// exists says whether the query finds at least one row.
func (g guideCtx) exists(q *gorm.DB) bool {
var n int64
q.Limit(1).Count(&n)
return n > 0
}
type guideDef struct {
key, title, why, href string
show func(g guideCtx) bool // does this person get the step at all
done func(g guideCtx) bool // nil: done by looking (Seen)
}
// The steps. Replace these with your app's: keep them to five to eight, each
// one a real outcome, and make done() read the records, not a click.
var setupSteps = []guideDef{
{"profile", "Add your logo", "It goes on everything you send.", "/app/settings",
func(g guideCtx) bool { return g.Can("settings.manage") },
func(g guideCtx) bool {
return g.exists(g.db.Table("settings").Where("logo IS NOT NULL AND logo <> ''"))
}},
{"team", "Invite your team", "Give each person a login, with a role that decides what they see.", "/app/team",
func(g guideCtx) bool { return g.Can("users.manage") },
func(g guideCtx) bool {
var n int64
g.db.Table("users").Where("deleted_at IS NULL").Count(&n)
return n > 1
}},
{"first_record", "Add your first client", "The people and companies you work for, in one place.", "/app/clients",
func(g guideCtx) bool { return g.Can("clients.create") },
func(g guideCtx) bool { return g.exists(g.db.Table("clients")) }},
{"notifications", "Turn on notifications", "Hear about what matters as it happens.", "/app",
func(g guideCtx) bool { return true }, nil},
}
var memberSteps = []guideDef{
{"first_record", "Add your first client", "The people and companies you work for, in one place.", "/app/clients",
func(g guideCtx) bool { return g.Can("clients.create") },
func(g guideCtx) bool { return g.exists(g.db.Table("clients").Where("created_by = ?", g.UserID)) }},
{"notifications", "Turn on notifications", "Hear about what matters as it happens.", "/app",
func(g guideCtx) bool { return true }, nil},
}
// guideKeys are the keys Update accepts: every key above.
var guideKeys = []string{"profile", "team", "first_record", "notifications"}
// ErrUnknownStep answers a key that is not a step of the guide.
var ErrUnknownStep = errors.New("not a step of the guide")
// GuideService works out and keeps the guide.
type GuideService struct {
DB *gorm.DB
}
func (s *GuideService) row(db *gorm.DB, userID models.GuideUserID) models.UserGuide {
var r models.UserGuide
if db.Where("user_id = ?", userID).Take(&r).Error != nil {
r = models.UserGuide{UserID: userID}
}
return r
}
// Get is the person's guide.
func (s *GuideService) Get(ctx context.Context, who GuideCaller) (*Guide, error) {
db := s.DB.WithContext(ctx)
g := guideCtx{db: db, GuideCaller: who}
r := s.row(db, who.UserID)
out := &Guide{Path: "member", Steps: []GuideStep{}, Welcomed: r.WelcomedAt != nil, Hidden: r.HiddenAt != nil}
defs := memberSteps
if who.Can("settings.manage") {
out.Path, defs = "setup", setupSteps
}
for _, d := range defs {
if !d.show(g) {
continue
}
st := GuideStep{Key: d.key, Title: d.title, Why: d.why, Href: d.href}
if d.done == nil {
st.Done = slices.Contains([]string(r.Seen), d.key)
} else {
st.Done = d.done(g)
}
st.Skipped = !st.Done && slices.Contains([]string(r.Skipped), d.key)
if st.Done || st.Skipped {
out.Done++ // a skipped step counts towards the progress
}
out.Steps = append(out.Steps, st)
}
out.Total = len(out.Steps)
out.Complete = out.Done == out.Total
return out, nil
}
// GuideInput changes where the person is: Seen marks a look-at step done,
// Skip leaves a step out (Unskip puts it back), Welcomed records that the
// welcome was shown, Hidden hides the guide (false shows it again).
type GuideInput struct {
Seen string `json:"seen"`
Skip string `json:"skip"`
Unskip string `json:"unskip"`
Welcomed bool `json:"welcomed"`
Hidden *bool `json:"hidden"`
}
// Update keeps a change and answers with the whole guide.
func (s *GuideService) Update(ctx context.Context, who GuideCaller, in GuideInput) (*Guide, error) {
for _, k := range []string{in.Seen, in.Skip, in.Unskip} {
if k != "" && !slices.Contains(guideKeys, k) {
return nil, ErrUnknownStep
}
}
db := s.DB.WithContext(ctx)
r := s.row(db, who.UserID)
add := func(list []string, k string) []string {
if k == "" || slices.Contains(list, k) {
return list
}
return append(list, k)
}
r.Seen = add([]string(r.Seen), in.Seen)
r.Skipped = add([]string(r.Skipped), in.Skip)
if in.Unskip != "" {
r.Skipped = slices.DeleteFunc([]string(r.Skipped), func(k string) bool { return k == in.Unskip })
}
now := time.Now()
if in.Welcomed && r.WelcomedAt == nil {
r.WelcomedAt = &now
}
if in.Hidden != nil {
if *in.Hidden {
r.HiddenAt = &now
} else {
r.HiddenAt = nil
}
}
if r.ID == 0 {
if err := db.Create(&r).Error; err != nil {
return nil, err
}
} else if err := db.Model(&models.UserGuide{}).Where("id = ?", r.ID).Updates(map[string]any{
// A map, not the struct: GORM skips zero values in a struct, so an
// emptied list or a cleared HiddenAt would never be saved.
"seen": r.Seen, "skipped": r.Skipped, "welcomed_at": r.WelcomedAt, "hidden_at": r.HiddenAt, "updated_at": now,
}).Error; err != nil {
return nil, err
}
return s.Get(ctx, who)
}Tips for done():
- Use
Limit(1).Count(theexistshelper): you only need to know whether a row exists. - For per-person steps on the member path, filter by the person:
Where("owner_id = ?", g.UserID). - If your tables are scoped to a tenant, scope these queries the same way. In Outpost each query ran inside the tenant scope, with
tenant.Unscoped(db).Where("org_id = ?", orgID)for tables without automatic scoping. - If "is this module on" is cached (it was in Outpost), a test that switches a module off directly in the database must clear that cache.
Step 3: The handler and routes (Go)
apps/api/internal/handlers/guide.go
package handlers
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"yourapp/apps/api/internal/models"
"yourapp/apps/api/internal/services"
)
// GuideHandler serves the getting-started guide: each person's own.
type GuideHandler struct {
DB *gorm.DB
}
// caller is who is asking. Wire Can to your RBAC: check which keys your auth
// middleware sets with `grep -rn "c.Set(" internal/middleware`. In Outpost,
// with per-permission grants, it is authz.Granted(authz.CallerGrants(c), perm).
func caller(c *gin.Context) (services.GuideCaller, bool) {
v, _ := c.Get("user_id")
id, ok := v.(models.GuideUserID)
if !ok {
return services.GuideCaller{}, false
}
role, _ := c.Get("user_role")
return services.GuideCaller{
UserID: id,
Can: func(perm string) bool {
return role == "ADMIN" || role == "SUPER_ADMIN"
},
}, true
}
// Get answers GET /api/guide.
func (h *GuideHandler) Get(c *gin.Context) {
who, ok := caller(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": gin.H{"code": "UNAUTHORIZED", "message": "Sign in first"}})
return
}
out, err := (&services.GuideService{DB: h.DB}).Get(c.Request.Context(), who)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": gin.H{"code": "INTERNAL_ERROR", "message": "Could not read the guide"}})
return
}
c.JSON(http.StatusOK, gin.H{"data": out})
}
// Update answers PUT /api/guide.
func (h *GuideHandler) Update(c *gin.Context) {
who, ok := caller(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": gin.H{"code": "UNAUTHORIZED", "message": "Sign in first"}})
return
}
var in services.GuideInput
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"code": "BAD_REQUEST", "message": "Send the request as JSON"}})
return
}
out, err := (&services.GuideService{DB: h.DB}).Update(c.Request.Context(), who, in)
if errors.Is(err, services.ErrUnknownStep) {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": gin.H{"code": "VALIDATION_ERROR", "message": err.Error()}})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": gin.H{"code": "INTERNAL_ERROR", "message": "Could not save the guide"}})
return
}
c.JSON(http.StatusOK, gin.H{"data": out})
}Wire Can to your real permissions. The placeholder above treats ADMIN as able to do everything. Check which keys your auth middleware sets on the context first:
grep -rn "c.Set(" apps/api/internal/middlewareIn Outpost, with per-permission grants, it was:
Can: func(perm string) bool { return authz.Granted(authz.CallerGrants(c), perm) },Routes: they go on the signed-in group, with no module or role guard, because every signed-in person has a guide. In a Grit project, add them at the // grit:routes:protected marker in internal/routes/routes.go:
guide := &handlers.GuideHandler{DB: db}
protected.GET("/guide", guide.Get)
protected.PUT("/guide", guide.Update)
// grit:routes:protectedThen refresh Grit's route table:
grit routes --writeCheck it with curl and a signed-in token. GET /api/guide should answer:
{
"data": {
"path": "setup",
"steps": [
{
"key": "profile",
"title": "Add your logo",
"why": "It goes on everything you send.",
"href": "/app/settings",
"done": false,
"skipped": false
}
],
"done": 0,
"total": 4,
"complete": false,
"welcomed": false,
"hidden": false
}
}Step 4: A test (Go)
apps/api/internal/services/guide_test.go. It runs on in-memory SQLite. Change the CREATE TABLE lines to match the tables your done() functions read, or use your project's own test setup.
package services_test
import (
"context"
"testing"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"yourapp/apps/api/internal/models"
"yourapp/apps/api/internal/services"
)
func TestTheGuideTicksStepsFromTheRecords(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.UserGuide{}))
require.NoError(t, db.Exec("CREATE TABLE clients (id INTEGER PRIMARY KEY, created_by TEXT)").Error)
require.NoError(t, db.Exec("CREATE TABLE users (id INTEGER PRIMARY KEY, deleted_at DATETIME)").Error)
require.NoError(t, db.Exec("CREATE TABLE settings (id INTEGER PRIMARY KEY, logo TEXT)").Error)
ctx := context.Background()
gs := &services.GuideService{DB: db}
admin := services.GuideCaller{UserID: "u1", Can: func(string) bool { return true }}
step := func(g *services.Guide, key string) services.GuideStep {
for _, s := range g.Steps {
if s.Key == key {
return s
}
}
t.Fatalf("no step %q", key)
return services.GuideStep{}
}
g, err := gs.Get(ctx, admin)
require.NoError(t, err)
require.Equal(t, "setup", g.Path)
require.Equal(t, 0, g.Done)
require.False(t, g.Welcomed)
// Done from the records: a client added anywhere is the step done.
require.NoError(t, db.Exec("INSERT INTO clients (created_by) VALUES ('u1')").Error)
g, err = gs.Get(ctx, admin)
require.NoError(t, err)
require.True(t, step(g, "first_record").Done)
require.Equal(t, 1, g.Done)
// Skipped counts towards the progress; unskipped, it is back.
g, err = gs.Update(ctx, admin, services.GuideInput{Skip: "team", Welcomed: true})
require.NoError(t, err)
require.True(t, step(g, "team").Skipped)
require.True(t, g.Welcomed)
require.Equal(t, 2, g.Done)
g, err = gs.Update(ctx, admin, services.GuideInput{Unskip: "team"})
require.NoError(t, err)
require.False(t, step(g, "team").Skipped)
_, err = gs.Update(ctx, admin, services.GuideInput{Skip: "nonsense"})
require.ErrorIs(t, err, services.ErrUnknownStep)
// Done by looking.
g, err = gs.Update(ctx, admin, services.GuideInput{Seen: "notifications"})
require.NoError(t, err)
require.True(t, step(g, "notifications").Done)
// Hidden, and shown again.
yes, no := true, false
g, err = gs.Update(ctx, admin, services.GuideInput{Hidden: &yes})
require.NoError(t, err)
require.True(t, g.Hidden)
g, err = gs.Update(ctx, admin, services.GuideInput{Hidden: &no})
require.NoError(t, err)
require.False(t, g.Hidden)
// Someone who is not an admin gets the member path.
member := services.GuideCaller{UserID: "u2", Can: func(perm string) bool { return perm == "clients.create" }}
g, err = gs.Get(ctx, member)
require.NoError(t, err)
require.Equal(t, "member", g.Path)
require.False(t, step(g, "first_record").Done) // u1's client is not u2's
}cd apps/api && go test ./internal/services/ -run TestTheGuide -count=1If production runs Postgres, run the test there too. JSON columns and Count behave slightly differently.
Step 5: The hooks (web)
apps/web/components/guide/use-guide.ts
In a Grit web app, apiClient from @/lib/api already sends the signed-in token and refreshes it:
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiClient } from "@/lib/api";
// The getting-started guide, as the API works it out: this person's steps,
// each ticked from the records themselves.
export interface GuideStep {
key: string;
title: string;
why: string;
href: string;
done: boolean;
skipped: boolean;
}
export interface Guide {
path: "setup" | "member";
steps: GuideStep[];
done: number;
total: number;
complete: boolean;
welcomed: boolean;
hidden: boolean;
}
export interface GuideInput {
seen?: string;
skip?: string;
unskip?: string;
welcomed?: boolean;
hidden?: boolean;
}
export const GUIDE_KEY = ["guide"];
export function useGuide() {
return useQuery<Guide>({
queryKey: GUIDE_KEY,
queryFn: async () =>
(await apiClient.get<{ data: Guide }>("/api/guide")).data.data,
staleTime: 15_000,
});
}
export function useUpdateGuide() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (input: GuideInput) =>
(await apiClient.put<{ data: Guide }>("/api/guide", input)).data.data,
// The API answers with the whole guide: no refetch needed.
onSuccess: (g) => qc.setQueryData(GUIDE_KEY, g),
});
}Not a Grit app? Swap the two request lines for fetch:
const send = async (method: string, body?: unknown) => {
const r = await fetch("/api/guide", {
method,
credentials: "include",
headers: body ? { "Content-Type": "application/json" } : undefined,
body: body ? JSON.stringify(body) : undefined,
});
if (!r.ok) throw new Error(`guide ${r.status}`);
return ((await r.json()) as { data: Guide }).data;
};
// queryFn: () => send("GET"),
// mutationFn: (input: GuideInput) => send("PUT", input),Option B: no backend (browser only)
For an app without an API, or to try the UI first, keep the guide's state in localStorage. Work out done from data you already fetch. Replace the two hooks with these and keep the same Guide shape; nothing else changes:
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
const STORE = "guide-state";
type Saved = {
seen: string[];
skipped: string[];
welcomed: boolean;
hidden: boolean;
};
const read = (): Saved => {
try {
return {
seen: [],
skipped: [],
welcomed: false,
hidden: false,
...JSON.parse(localStorage.getItem(STORE) ?? "{}"),
};
} catch {
return { seen: [], skipped: [], welcomed: false, hidden: false };
}
};
// Your steps, with done worked out from what the app already knows.
async function steps(): Promise<Omit<GuideStep, "skipped">[]> {
const clients = await fetch("/api/clients?page_size=1").then((r) => r.json());
return [
{
key: "first_record",
title: "Add your first client",
why: "The people you work for.",
href: "/app/clients",
done: clients.meta?.total > 0,
},
{
key: "notifications",
title: "Turn on notifications",
why: "Hear about what matters.",
href: "/app",
done: read().seen.includes("notifications"),
},
];
}
async function build(): Promise<Guide> {
const s = read();
const list = (await steps()).map((x) => ({
...x,
skipped: !x.done && s.skipped.includes(x.key),
}));
const done = list.filter((x) => x.done || x.skipped).length;
return {
path: "setup",
steps: list,
done,
total: list.length,
complete: done === list.length,
welcomed: s.welcomed,
hidden: s.hidden,
};
}
export function useGuide() {
return useQuery<Guide>({
queryKey: GUIDE_KEY,
queryFn: build,
staleTime: 15_000,
});
}
export function useUpdateGuide() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (input: GuideInput) => {
const s = read();
if (input.seen && !s.seen.includes(input.seen)) s.seen.push(input.seen);
if (input.skip && !s.skipped.includes(input.skip))
s.skipped.push(input.skip);
if (input.unskip) s.skipped = s.skipped.filter((k) => k !== input.unskip);
if (input.welcomed) s.welcomed = true;
if (input.hidden !== undefined) s.hidden = input.hidden;
try {
localStorage.setItem(STORE, JSON.stringify(s));
} catch {}
return build();
},
onSuccess: (g) => qc.setQueryData(GUIDE_KEY, g),
});
}Trade-off: browser-only progress does not follow the person to another device, and "done" can only use data the browser can fetch.
Step 6: The tours (web)
apps/web/components/guide/tours.ts. One entry per step key. These are examples; write your own.
// The tour for each step of the guide: a few stops, each a place on the
// screen with a word about it. Keep each tour to two or three stops, the
// last one the button that does the thing.
//
// target a data-tour="..." marker on the element to outline.
// path where the stop is; the tour navigates there first.
// click the person clicking the outlined element is the next step (a
// link), and on the last stop it ends the tour (a button that opens
// a form). Next also clicks it for them.
//
// A target starting "nav:" is a sidebar link (data-tour="nav:/app/clients").
// The sidebar is asked to unfold its section; on a phone, where the sidebar
// is folded away, such a stop is passed over.
export interface TourStop {
target: string;
title: string;
text: string;
path?: string;
click?: boolean;
}
// Keys match the step keys the API sends. Replace these with your app's.
export const TOURS: Record<string, TourStop[]> = {
profile: [
{
target: "nav:/app/settings",
title: "Settings",
text: "Everything about your workspace lives here.",
click: true,
},
{
path: "/app/settings",
target: "brand-logo",
title: "Your logo",
text: "Upload your logo. It goes on everything you send. Save at the foot of the page.",
},
],
team: [
{
target: "nav:/app/team",
title: "Your team",
text: "Everyone with a login, and what each person can see.",
click: true,
},
{
path: "/app/team",
target: "invite",
title: "Invite someone",
text: "Type their email and choose a role. They get a link to join, and their own guide when they arrive.",
},
],
first_record: [
{
target: "nav:/app/clients",
title: "Clients",
text: "The people and companies you work for.",
click: true,
},
{
path: "/app/clients",
target: "add-client",
title: "Add your first client",
text: "Click Add client. You can fill in the rest later.",
click: true,
},
],
notifications: [
{
target: "bell",
title: "Your notifications",
text: "Anything given to you arrives here.",
click: true,
},
{
target: "notify-settings",
title: "Turn them on",
text: "Choose how you want to hear about things.",
},
],
};How to write a good stop:
- Stop 1 is usually the sidebar link (
nav:/app/...,click: true). The person learns where the page lives, not only what to do this once. - The last stop is the button that does the job (
click: true). Clicking it, or "Open it" on the card, opens the form and ends the tour. - If a stop shows a section rather than a button, leave
clickoff. The card then says Next or Done. - Keep
textto one or two sentences. Say what to click and what happens next.
Step 7: The component (web)
apps/web/components/guide/guide.tsx. This is the whole UI: provider, launcher, checklist, welcome and tour. Copy it as is.
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import { usePathname, useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import {
ArrowLeft,
ArrowRight,
Check,
Compass,
Minus,
Sparkles,
X,
} from "lucide-react";
import {
GUIDE_KEY,
type Guide,
type GuideStep,
useGuide,
useUpdateGuide,
} from "./use-guide";
import { TOURS, type TourStop } from "./tours";
// The getting-started guide. Three parts, started from one place:
//
// - The launcher, in the sidebar: a ring that fills as steps are done, and
// the next step by name. It opens the checklist.
// - The checklist: every step for this person, the next one open with why
// it matters, Show me and Skip. Done steps are ticked by the API from the
// records themselves, so a step done any way is done.
// - The tour: Show me goes to the page and draws a dashed outline round
// the very thing to click, the rest of the screen dimmed, with a card
// saying what it is. The outlined thing stays clickable.
//
// The welcome opens once, on the first visit.
// The sidebar listens for this to unfold the section holding a link.
export const REVEAL_NAV_EVENT = "guide:reveal-nav";
interface GuideUI {
guide: Guide | undefined;
next: GuideStep | undefined;
justDone: string;
openPanel: (from?: HTMLElement | null) => void;
start: (key: string) => void;
touring: boolean;
}
const Ctx = createContext<GuideUI | null>(null);
export function useGuideUI() {
return useContext(Ctx);
}
const button =
"inline-flex h-11 items-center justify-center gap-1.5 rounded-g-control px-4 text-sm font-medium transition-colors duration-200 disabled:cursor-not-allowed disabled:opacity-60 md:h-9";
const primary = `${button} bg-g-brand text-g-on-brand hover:bg-g-brand-hover`;
const secondary = `${button} border border-g-line bg-g-page text-g-fg hover:bg-g-hover`;
const quiet = `${button} px-3 text-g-fg-2 hover:bg-g-hover hover:text-g-fg`;
const EASE = "cubic-bezier(0.17, 0.55, 0.55, 1)";
export function GuideProvider({
children,
appName,
userName,
noWelcomeOn = [],
lookedAtSteps = [],
}: {
children: React.ReactNode;
// Shown in the welcome: "Welcome to <appName>, <userName>".
appName: string;
userName?: string;
// Paths where the welcome must not open (an onboarding wizard, say).
noWelcomeOn?: string[];
// Steps done by looking: finishing their tour marks them seen.
lookedAtSteps?: string[];
}) {
const guide = useGuide();
const update = useUpdateGuide();
const qc = useQueryClient();
const pathname = usePathname() ?? "";
const [tour, setTour] = useState<{ key: string; i: number } | null>(null);
const [panel, setPanel] = useState<{ left: number | null } | null>(null);
const [justDone, setJustDone] = useState("");
const g = guide.data;
// A step is done where the work is done: ask again on each page.
// biome-ignore lint/correctness/useExhaustiveDependencies: on each new page.
useEffect(() => {
void qc.invalidateQueries({ queryKey: GUIDE_KEY });
}, [pathname]);
// A step that has just been done is named in the launcher for a moment.
const before = useRef<Set<string> | null>(null);
useEffect(() => {
if (!g) return;
const now = new Set(g.steps.filter((s) => s.done).map((s) => s.key));
if (before.current) {
const fresh = g.steps.find((s) => s.done && !before.current?.has(s.key));
if (fresh) {
setJustDone(fresh.title);
const t = setTimeout(() => setJustDone(""), 6000);
before.current = now;
return () => clearTimeout(t);
}
}
before.current = now;
}, [g]);
const next = g?.steps.find((s) => !s.done && !s.skipped);
const start = useCallback((key: string) => {
setPanel(null);
if (TOURS[key]) setTour({ key, i: 0 });
}, []);
const openPanel = useCallback((from?: HTMLElement | null) => {
// On a desktop the panel opens beside the launcher; on a phone, across the foot.
const r = from?.getBoundingClientRect();
setPanel({
left: r && window.innerWidth >= 768 ? Math.round(r.right + 12) : null,
});
}, []);
const endTour = (finished: boolean) => {
const key = tour?.key;
setTour(null);
if (finished && key && lookedAtSteps.includes(key))
update.mutate({ seen: key });
void qc.invalidateQueries({ queryKey: GUIDE_KEY });
};
const welcome =
!!g &&
!g.welcomed &&
!g.hidden &&
!g.complete &&
!noWelcomeOn.some((p) => pathname.startsWith(p));
const value = useMemo<GuideUI>(
() => ({ guide: g, next, justDone, openPanel, start, touring: !!tour }),
[g, next, justDone, openPanel, start, tour]
);
return (
<Ctx.Provider value={value}>
{children}
{g && panel && (
<GuidePanel
guide={g}
left={panel.left}
onClose={() => setPanel(null)}
onShow={start}
onSkip={(key) => update.mutate({ skip: key })}
onUnskip={(key) => update.mutate({ unskip: key })}
onHide={() => {
update.mutate({ hidden: true });
setPanel(null);
}}
/>
)}
{g && welcome && !tour && (
<Welcome
guide={g}
appName={appName}
userName={userName}
onTour={() => {
update.mutate({ welcomed: true });
const first = g.steps.find((s) => !s.done && !s.skipped);
if (first) start(first.key);
}}
onLater={() => update.mutate({ welcomed: true })}
/>
)}
{tour && (
<Tour
step={g?.steps.find((s) => s.key === tour.key)}
stops={TOURS[tour.key] ?? []}
index={tour.i}
onIndex={(i) => setTour((t) => (t ? { ...t, i } : t))}
onEnd={endTour}
/>
)}
</Ctx.Provider>
);
}
// --- The ring ---------------------------------------------------------------
function Ring({
done,
total,
size = 36,
}: {
done: number;
total: number;
size?: number;
}) {
const r = (size - 5) / 2;
const c = 2 * Math.PI * r;
const part = total ? done / total : 0;
return (
<svg
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
aria-hidden="true"
className="shrink-0 -rotate-90"
>
<circle
cx={size / 2}
cy={size / 2}
r={r}
fill="none"
strokeWidth="4"
className="stroke-g-muted"
/>
<circle
cx={size / 2}
cy={size / 2}
r={r}
fill="none"
strokeWidth="4"
strokeLinecap="round"
strokeDasharray={c}
strokeDashoffset={c * (1 - part)}
className="stroke-g-brand"
style={{ transition: `stroke-dashoffset 500ms ${EASE}` }}
/>
</svg>
);
}
// --- The launcher ------------------------------------------------------------
// GuideLauncher sits in the sidebar until the guide is hidden. rail is the
// collapsed, icons-only sidebar.
export function GuideLauncher({ rail = false }: { rail?: boolean }) {
const ui = useGuideUI();
const ref = useRef<HTMLButtonElement>(null);
const g = ui?.guide;
if (!ui || !g || g.hidden || g.total === 0) return null;
const label = g.complete
? "You're all set"
: ui.justDone
? `Done: ${ui.justDone}`
: ui.next
? `Next: ${ui.next.title}`
: "Getting started";
if (rail) {
return (
<div className="flex justify-center px-2 pb-2">
<button
ref={ref}
type="button"
onClick={() => ui.openPanel(ref.current)}
aria-label={`Getting started, ${g.done} of ${g.total} done. ${label}`}
title={label}
className="rounded-g-control hover:bg-g-hover relative inline-flex h-11 w-11 items-center justify-center"
>
<Ring done={g.done} total={g.total} size={34} />
<span className="text-g-fg absolute text-xs font-semibold tabular-nums">
{g.total - g.done || (
<Check className="text-g-success-ink h-4 w-4" />
)}
</span>
</button>
</div>
);
}
return (
<div className="px-2 pb-2">
<button
ref={ref}
type="button"
onClick={() => ui.openPanel(ref.current)}
className={`rounded-g-card flex w-full items-center gap-3 border p-2.5 text-left transition-colors duration-200 ${
ui.justDone
? "border-g-success/40 bg-g-success/10"
: "border-g-line bg-g-page hover:bg-g-hover"
}`}
>
<span className="relative inline-flex items-center justify-center">
<Ring done={g.done} total={g.total} />
<span className="text-g-fg absolute text-xs font-semibold tabular-nums">
{g.complete ? (
<Check
className="text-g-success-ink h-4 w-4"
aria-hidden="true"
/>
) : (
`${g.done}/${g.total}`
)}
</span>
</span>
<span className="min-w-0 flex-1">
<span className="text-g-fg block text-sm font-medium">
Getting started
</span>
<span
aria-live="polite"
className={`block truncate text-xs ${ui.justDone ? "text-g-success-ink" : "text-g-fg-2"}`}
>
{label}
</span>
</span>
</button>
</div>
);
}
// --- The checklist -----------------------------------------------------------
function GuidePanel({
guide,
left,
onClose,
onShow,
onSkip,
onUnskip,
onHide,
}: {
guide: Guide;
left: number | null;
onClose: () => void;
onShow: (key: string) => void;
onSkip: (key: string) => void;
onUnskip: (key: string) => void;
onHide: () => void;
}) {
const box = useRef<HTMLDivElement>(null);
const next = guide.steps.find((s) => !s.done && !s.skipped);
useEffect(() => {
box.current?.focus();
const key = (e: KeyboardEvent) => e.key === "Escape" && onClose();
const out = (e: MouseEvent) => {
if (box.current && !box.current.contains(e.target as Node)) onClose();
};
window.addEventListener("keydown", key);
window.addEventListener("mousedown", out);
return () => {
window.removeEventListener("keydown", key);
window.removeEventListener("mousedown", out);
};
}, [onClose]);
const pct = guide.total ? Math.round((guide.done / guide.total) * 100) : 0;
return createPortal(
<div
ref={box}
role="dialog"
aria-labelledby="guide-title"
tabIndex={-1}
style={left !== null ? { left } : undefined}
className={`max-h-g-panel rounded-g-card border-g-line bg-g-surface shadow-g-raised fixed bottom-4 z-50 flex flex-col overflow-hidden border outline-none ${
left !== null ? "w-96" : "inset-x-4"
}`}
>
<div className="border-g-line border-b px-5 pt-4 pb-3">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-g-fg-3 flex items-center gap-1.5 text-xs font-medium tracking-wider uppercase">
<Compass className="h-3.5 w-3.5" aria-hidden="true" />
{guide.path === "setup" ? "Set up your workspace" : "Get going"}
</p>
<h2
id="guide-title"
className="text-g-fg mt-0.5 text-lg font-semibold tracking-tight"
>
{guide.complete ? "You're all set" : "Getting started"}
</h2>
</div>
<button
type="button"
onClick={onClose}
aria-label="Close the guide"
className="rounded-g-control text-g-fg-3 hover:bg-g-hover hover:text-g-fg inline-flex h-11 w-11 items-center justify-center md:h-9 md:w-9"
>
<X className="h-4 w-4" aria-hidden="true" />
</button>
</div>
<div className="mt-3 flex items-center gap-3">
<div
className="bg-g-muted h-2 flex-1 overflow-hidden rounded-full"
role="progressbar"
aria-valuenow={guide.done}
aria-valuemin={0}
aria-valuemax={guide.total}
aria-label="Steps done"
>
<div
className="bg-g-brand h-full rounded-full"
style={{ width: `${pct}%`, transition: `width 500ms ${EASE}` }}
/>
</div>
<span className="text-g-fg-2 shrink-0 text-xs tabular-nums">
{guide.done} of {guide.total}
</span>
</div>
</div>
<ol className="flex-1 overflow-y-auto px-3 py-2">
{guide.steps.map((s, i) => {
const isNext = s.key === next?.key;
return (
<li
key={s.key}
className={`rounded-g-control px-2 py-2.5 ${isNext ? "bg-g-brand-soft" : ""}`}
>
<div className="flex items-start gap-3">
<span
className={`mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-semibold ${
s.done
? "bg-g-success text-g-page"
: s.skipped
? "bg-g-muted text-g-fg-3"
: isNext
? "bg-g-brand text-g-on-brand"
: "border-g-line text-g-fg-2 border"
}`}
aria-hidden="true"
>
{s.done ? (
<Check className="h-3.5 w-3.5" strokeWidth={3} />
) : s.skipped ? (
<Minus className="h-3.5 w-3.5" />
) : (
i + 1
)}
</span>
<div className="min-w-0 flex-1">
<p
className={`text-sm font-medium ${s.done || s.skipped ? "text-g-fg-3 decoration-g-fg-3/60 line-through" : "text-g-fg"}`}
>
<span className="sr-only">
{s.done ? "Done: " : s.skipped ? "Skipped: " : ""}
</span>
{s.title}
</p>
{isNext && (
<p className="text-g-fg-2 mt-0.5 text-sm">{s.why}</p>
)}
{isNext && (
<div className="mt-2.5 flex flex-wrap gap-2">
<button
type="button"
onClick={() => onShow(s.key)}
className={primary}
>
Show me
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</button>
<button
type="button"
onClick={() => onSkip(s.key)}
className={quiet}
>
Skip
</button>
</div>
)}
</div>
{!isNext && !s.done && (
<button
type="button"
onClick={() =>
s.skipped ? onUnskip(s.key) : onShow(s.key)
}
className="rounded-g-control text-g-brand-ink hover:bg-g-hover inline-flex h-11 shrink-0 items-center px-2 text-xs font-medium md:h-7"
>
{s.skipped ? "Undo skip" : "Show me"}
</button>
)}
</div>
</li>
);
})}
</ol>
<div className="border-g-line flex items-center justify-between gap-3 border-t px-5 py-3">
<p className="text-g-fg-3 text-xs">
{guide.complete
? "Nice work. Hide this, or keep it to look back on."
: "Ticks itself off as you go."}
</p>
<button
type="button"
onClick={onHide}
className="rounded-g-control text-g-fg-2 hover:bg-g-hover hover:text-g-fg inline-flex h-11 shrink-0 items-center px-2 text-xs font-medium md:h-8"
>
Hide the guide
</button>
</div>
</div>,
document.body
);
}
// --- The welcome -------------------------------------------------------------
function Welcome({
guide,
appName,
userName,
onTour,
onLater,
}: {
guide: Guide;
appName: string;
userName?: string;
onTour: () => void;
onLater: () => void;
}) {
const ref = useRef<HTMLDialogElement>(null);
useEffect(() => {
const d = ref.current;
if (d && !d.open) d.showModal();
return () => d?.close();
}, []);
const todo = guide.steps.filter((s) => !s.done && !s.skipped).slice(0, 4);
const left = guide.total - guide.done;
return (
<dialog
ref={ref}
aria-labelledby="guide-welcome-title"
onCancel={(e) => {
e.preventDefault();
onLater();
}}
className="rounded-g-card border-g-line bg-g-surface text-g-fg shadow-g-raised backdrop:bg-g-fg/50 m-auto w-full max-w-md border p-6"
>
<h2
id="guide-welcome-title"
className="text-lg font-semibold tracking-tight"
>
Welcome to {appName}
{userName ? `, ${userName}` : ""}
</h2>
<p className="text-g-fg-2 mt-1 text-sm">
{guide.path === "setup"
? "A few steps get your workspace ready. The guide shows you each one, right where it happens, and ticks it off as you go."
: "A few steps get you going. The guide shows you each one, right where it happens, and ticks it off as you go."}
</p>
<ol className="mt-4 space-y-2">
{todo.map((s, i) => (
<li
key={s.key}
className="rounded-g-control border-g-line bg-g-subtle flex items-start gap-3 border p-3"
>
<span
className="bg-g-brand text-g-on-brand flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-semibold"
aria-hidden="true"
>
{i + 1}
</span>
<span className="min-w-0">
<span className="text-g-fg block text-sm font-medium">
{s.title}
</span>
<span className="text-g-fg-2 block text-xs">{s.why}</span>
</span>
</li>
))}
</ol>
<p className="text-g-fg-3 mt-3 text-xs">
{left} {left === 1 ? "step" : "steps"} in all. The guide stays in the
sidebar under Getting started.
</p>
<div className="mt-5 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<button type="button" onClick={onLater} className={secondary}>
I'll look around myself
</button>
<button type="button" onClick={onTour} className={primary} autoFocus>
<Sparkles className="h-4 w-4" aria-hidden="true" />
Show me around
</button>
</div>
</dialog>
);
}
// --- The tour ----------------------------------------------------------------
const PAD = 8; // room between the target and its outline
const FIND_FOR = 6000; // how long to wait for a target to appear
function visible(el: Element) {
const r = el.getBoundingClientRect();
if (r.width === 0 || r.height === 0) return false;
const st = getComputedStyle(el);
return st.visibility !== "hidden" && st.display !== "none";
}
// The same marker can be on two elements (the desktop sidebar and the phone
// drawer): the visible one is the one to outline.
function find(target: string): HTMLElement | null {
const all = Array.from(
document.querySelectorAll<HTMLElement>(
`[data-tour="${CSS.escape(target)}"]`
)
);
return all.find(visible) ?? null;
}
function Tour({
step,
stops,
index,
onIndex,
onEnd,
}: {
step: GuideStep | undefined;
stops: TourStop[];
index: number;
onIndex: (i: number) => void;
onEnd: (finished: boolean) => void;
}) {
const router = useRouter();
const pathname = usePathname() ?? "";
const stop = stops[index];
const last = index === stops.length - 1;
const beside = !!stop?.target.startsWith("nav:");
const [el, setEl] = useState<HTMLElement | null>(null);
const [lost, setLost] = useState(false);
const [rect, setRect] = useState<DOMRect | null>(null);
const card = useRef<HTMLDivElement>(null);
const [cardPos, setCardPos] = useState<{ top: number; left: number } | null>(
null
);
const [small, setSmall] = useState(false);
const reduce =
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const go = useCallback(
(i: number) => {
if (i >= stops.length) onEnd(true);
else if (i >= 0) onIndex(i);
},
[stops.length, onEnd, onIndex]
);
// Go to the stop's page, then look for its target.
useEffect(() => {
if (!stop) return;
setEl(null);
setLost(false);
setRect(null);
if (stop.path && !pathname.startsWith(stop.path)) {
router.push(stop.path);
return; // runs again when the pathname changes
}
if (stop.target.startsWith("nav:"))
window.dispatchEvent(
new CustomEvent(REVEAL_NAV_EVENT, { detail: stop.target.slice(4) })
);
const began = Date.now();
const t = setInterval(() => {
const found = find(stop.target);
if (found) {
clearInterval(t);
found.scrollIntoView({
block: "nearest",
inline: "nearest",
behavior: reduce ? "auto" : "smooth",
});
setEl(found);
} else if (Date.now() - began > FIND_FOR) {
clearInterval(t);
// A sidebar link folded away on a phone: on to the next stop.
if (stop.target.startsWith("nav:") && !last) go(index + 1);
else setLost(true);
}
}, 150);
return () => clearInterval(t);
}, [stop, pathname, router, index, last, go, reduce]);
// Follow the target as the page scrolls, resizes or re-renders.
useEffect(() => {
if (!el) return;
let raf = 0;
const tick = () => {
if (!el.isConnected) {
setEl(null);
return;
}
const r = el.getBoundingClientRect();
setRect((prev) =>
prev &&
prev.top === r.top &&
prev.left === r.left &&
prev.width === r.width &&
prev.height === r.height
? prev
: r
);
setSmall(window.innerWidth < 768);
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [el]);
// Clicking the outlined thing is the next step.
useEffect(() => {
if (!el || !stop?.click) return;
const on = () => {
if (last) onEnd(true);
else setTimeout(() => go(index + 1), 0);
};
el.addEventListener("click", on, { once: true });
return () => el.removeEventListener("click", on);
}, [el, stop, last, index, go, onEnd]);
// Where the card goes: beside a sidebar link; otherwise below the target,
// or above it, or beside it, kept on screen. A phone gets a bottom sheet.
useLayoutEffect(() => {
const c = card.current;
if (!c) return;
const w = c.offsetWidth;
const h = c.offsetHeight;
if (!rect || small) {
setCardPos(null);
return;
}
const gap = 14;
if (beside) {
const top = Math.min(
Math.max(16, rect.top + rect.height / 2 - h / 2),
window.innerHeight - h - 16
);
setCardPos({
top,
left: Math.min(rect.right + PAD + gap, window.innerWidth - w - 16),
});
return;
}
let top = rect.bottom + PAD + gap;
if (top + h > window.innerHeight - 16) top = rect.top - PAD - gap - h;
if (top < 16) {
top = Math.min(Math.max(16, rect.top), window.innerHeight - h - 16);
const right = rect.right + PAD + gap;
setCardPos({
top,
left:
right + w < window.innerWidth - 16
? right
: Math.max(16, rect.left - PAD - gap - w),
});
return;
}
const left = Math.min(
Math.max(16, rect.left + rect.width / 2 - w / 2),
window.innerWidth - w - 16
);
setCardPos({ top, left });
}, [rect, small, beside]);
// The card takes the focus at each stop; Escape ends the tour.
// biome-ignore lint/correctness/useExhaustiveDependencies: at each stop.
useEffect(() => {
card.current?.focus();
}, [index, el, lost]);
useEffect(() => {
const key = (e: KeyboardEvent) => {
if (e.key === "Escape") onEnd(false);
};
window.addEventListener("keydown", key);
return () => window.removeEventListener("keydown", key);
}, [onEnd]);
if (!stop) return null;
const hole =
rect && el
? {
top: rect.top - PAD,
left: rect.left - PAD,
width: rect.width + PAD * 2,
height: rect.height + PAD * 2,
}
: null;
const nextAction = () => {
if (stop.click && el) el.click();
else go(index + 1);
};
const dim = "pointer-events-auto fixed bg-g-fg/55";
return createPortal(
// The wrapper lets clicks through (pointer-events-none): only the four
// dim pieces and the card catch them, so the hole stays clickable.
<div className="pointer-events-none fixed inset-0 z-60" aria-live="polite">
{hole ? (
<>
<div
className={dim}
style={{ top: 0, left: 0, right: 0, height: Math.max(0, hole.top) }}
/>
<div
className={dim}
style={{
top: hole.top + hole.height,
left: 0,
right: 0,
bottom: 0,
}}
/>
<div
className={dim}
style={{
top: hole.top,
left: 0,
width: Math.max(0, hole.left),
height: hole.height,
}}
/>
<div
className={dim}
style={{
top: hole.top,
left: hole.left + hole.width,
right: 0,
height: hole.height,
}}
/>
<svg
className="pointer-events-none fixed overflow-visible"
style={{
top: hole.top,
left: hole.left,
width: hole.width,
height: hole.height,
}}
aria-hidden="true"
>
{/* The marching dashed outline, and a soft halo round it. */}
<rect
x="1"
y="1"
width={Math.max(0, hole.width - 2)}
height={Math.max(0, hole.height - 2)}
rx="10"
fill="none"
strokeWidth="2.5"
strokeDasharray="8 6"
className="guide-ants stroke-g-brand"
/>
<rect
x="-3"
y="-3"
width={hole.width + 6}
height={hole.height + 6}
rx="13"
fill="none"
strokeWidth="6"
className="stroke-g-brand/25"
/>
</svg>
</>
) : (
<div className="bg-g-fg/55 pointer-events-auto fixed inset-0" />
)}
<div
ref={card}
role="dialog"
aria-labelledby="tour-title"
aria-describedby="tour-text"
tabIndex={-1}
style={cardPos ? { top: cardPos.top, left: cardPos.left } : undefined}
className={`max-w-g-gutter rounded-g-card border-g-line bg-g-surface shadow-g-raised pointer-events-auto fixed w-80 border p-4 outline-none ${
cardPos
? ""
: small || !hole
? "inset-x-4 bottom-4 mx-auto"
: "invisible"
}`}
>
<div className="flex items-start justify-between gap-2">
<p className="text-g-fg-3 text-xs font-medium">
{step?.title ?? "Getting started"} · {index + 1} of {stops.length}
</p>
<button
type="button"
onClick={() => onEnd(false)}
aria-label="End the tour"
className="rounded-g-control text-g-fg-3 hover:bg-g-hover hover:text-g-fg -m-2 inline-flex h-11 w-11 shrink-0 items-center justify-center md:h-8 md:w-8"
>
<X className="h-4 w-4" aria-hidden="true" />
</button>
</div>
<h2 id="tour-title" className="text-g-fg mt-1 text-base font-semibold">
{stop.title}
</h2>
<p id="tour-text" className="text-g-fg-2 mt-1 text-sm">
{lost
? `${stop.text} It is not on the screen just now: it may need a wider window, or a permission you do not have.`
: !el
? "Taking you there..."
: stop.text}
</p>
<div className="mt-4 flex items-center justify-between gap-3">
<span className="flex gap-1.5" aria-hidden="true">
{stops.map((s, i) => (
<span
key={s.target + i}
className={`h-1.5 rounded-full ${i === index ? "bg-g-brand w-5" : i < index ? "bg-g-brand/60 w-1.5" : "bg-g-muted w-1.5"}`}
/>
))}
</span>
<span className="flex gap-2">
{index > 0 && (
<button
type="button"
onClick={() => go(index - 1)}
className={secondary}
aria-label="Back"
>
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
</button>
)}
<button
type="button"
onClick={nextAction}
disabled={!el && !lost}
className={primary}
>
{last ? (stop.click && el ? "Open it" : "Done") : "Next"}
{!last && <ArrowRight className="h-4 w-4" aria-hidden="true" />}
</button>
</span>
</div>
</div>
</div>,
document.body
);
}How the tour works, in order:
- Navigate. If the stop has a
pathand the person is elsewhere, it callsrouter.push(path). The effect runs again when the pathname changes. - Unfold. For a
nav:stop it sends theguide:reveal-navevent, so a collapsed sidebar section opens (Step 9). - Find. Every 150 ms, for up to 6 seconds, it looks for a visible
[data-tour="..."]element. The visibility check matters because the same link is often in both the desktop sidebar and the phone drawer. If a sidebar stop is not found, it is skipped (on a phone the sidebar is folded away). Otherwise the card says it is not on the screen. - Track. A
requestAnimationFrameloop reads the element's rectangle every frame, so the outline follows scrolling, resizing and layout shifts. - Draw. Four dim rectangles around the hole, an SVG dashed
rectwith the moving-dash animation, and a soft halo. - Place the card. Beside a sidebar link, otherwise below, above or beside the target, always kept on screen. Under 768 px it becomes a bottom sheet.
- Advance. A
clicklistener on the target moves to the next stop (on the last stop it ends the tour). Next clicks the target for the person whenclickis set, so the link or button does its normal job.
Step 8: Tokens and the animation (CSS)
The component uses its own g- tokens so it does not clash with your theme. Pick one of the mappings below and add it to apps/web/app/globals.css.
Grit web app (uses the Grit web token names):
@theme inline {
--color-g-brand: var(--color-accent);
--color-g-brand-hover: var(--color-accent-hover);
--color-g-brand-soft: var(--color-accent-soft);
--color-g-brand-ink: var(--color-accent-ink);
--color-g-on-brand: var(--color-accent-fg);
--color-g-page: var(--color-background);
--color-g-surface: var(--color-bg-elevated);
--color-g-subtle: var(--color-bg-secondary);
--color-g-muted: var(--color-bg-tertiary);
--color-g-hover: var(--color-bg-hover);
--color-g-fg: var(--color-foreground);
--color-g-fg-2: var(--color-text-secondary);
--color-g-fg-3: var(--color-text-muted);
--color-g-line: var(--color-border);
--color-g-success: var(--color-success);
--color-g-success-ink: var(--color-success);
}If your project lacks one of these (--color-accent-soft, --color-accent-ink and --color-accent-fg may be missing), use the color-mix values from the shadcn mapping below.
shadcn/ui app:
@theme inline {
--color-g-brand: var(--primary);
--color-g-brand-hover: color-mix(in oklab, var(--primary) 88%, black);
--color-g-brand-soft: color-mix(in oklab, var(--primary) 10%, transparent);
--color-g-brand-ink: var(--primary);
--color-g-on-brand: var(--primary-foreground);
--color-g-page: var(--background);
--color-g-surface: var(--popover);
--color-g-subtle: var(--muted);
--color-g-muted: var(--muted);
--color-g-hover: var(--accent);
--color-g-fg: var(--foreground);
--color-g-fg-2: var(--muted-foreground);
--color-g-fg-3: var(--muted-foreground);
--color-g-line: var(--border);
--color-g-success: oklch(0.63 0.15 150);
--color-g-success-ink: oklch(0.5 0.13 150);
}Both need these, plus the animation:
@theme {
--radius-g-control: 10px;
--radius-g-card: 16px;
--shadow-g-raised:
0 4px 12px rgb(16 24 40 / 0.06), 0 2px 4px rgb(16 24 40 / 0.04);
/* The checklist never taller than the screen; the tour card never wider. */
--spacing-g-panel: 85vh;
--container-g-gutter: calc(100vw - 2rem);
}
/* The dashed outline marches round the thing to click; it stays still for
anyone who has asked for less motion. */
@keyframes guide-ants {
to {
stroke-dashoffset: -28; /* the dash pattern (8 + 6) twice: a seamless loop */
}
}
.guide-ants {
animation: guide-ants 1.1s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.guide-ants {
animation: none;
}
}If you change strokeDasharray="8 6" in the component, keep stroke-dashoffset at a multiple of the pattern length (8 + 6 = 14), or the loop visibly jumps.
Step 9: Wire it into the app shell (web)
9a. Wrap the signed-in layout in the provider
Wrap the shell that holds the sidebar and the page, not the root layout, so the guide exists only for signed-in pages:
import {
GuideLauncher,
GuideProvider,
REVEAL_NAV_EVENT,
} from "@/components/guide/guide";
export function AppShell({ children }: { children: React.ReactNode }) {
const me = useMe().data; // your current-user hook
return (
<GuideProvider
appName="Acme"
userName={me?.first_name}
noWelcomeOn={["/app/onboarding"]}
lookedAtSteps={["notifications"]}
>
<div className="min-h-screen md:grid md:grid-cols-[15rem_minmax(0,1fr)]">
<Sidebar />
<main className="min-w-0">{children}</main>
</div>
</GuideProvider>
);
}(The md:grid-cols-[...] arbitrary value is only for the example. Use your shell's own layout.)
9b. Put the launcher at the foot of the sidebar
function Sidebar() {
return (
<div className="flex h-full flex-col">
<nav className="flex-1 overflow-y-auto">{/* links */}</nav>
<GuideLauncher /> {/* above the user menu */}
<SidebarUser />
</div>
);
}
// In a collapsed, icons-only rail:
<GuideLauncher rail />;9c. Mark the sidebar links
Every nav link gets a data-tour attribute set to nav: plus its href (shown below as a template literal). Do it once in the loop that renders them, on both the full sidebar and the rail:
<Link
href={item.href}
data-tour={`nav:${item.href}`}
aria-current={active ? "page" : undefined}
>
{item.label}
</Link>9d. Let the guide unfold a collapsed section
If your sidebar has foldable groups, open the group that holds the link the tour points at:
useEffect(() => {
const reveal = (e: Event) => {
const href = (e as CustomEvent<string>).detail;
const group = NAV.find((g) => g.items.some((i) => i.href === href))?.group;
if (group) setFolded((f) => f.filter((x) => x !== group));
};
window.addEventListener(REVEAL_NAV_EVENT, reveal);
return () => window.removeEventListener(REVEAL_NAV_EVENT, reveal);
}, []);9e. Add "Getting started" to the account menu, on desktop and phone
This is how someone brings back a guide they hid. On a phone it is also the main way in, because the sidebar is folded away. Add it to every account menu: Outpost has one in the sidebar and a separate one in the phone top bar, and the phone one was missed at first.
import { Compass } from "lucide-react";
import { useGuideUI } from "@/components/guide/guide";
import { useUpdateGuide } from "@/components/guide/use-guide";
const guide = useGuideUI();
const showGuide = useUpdateGuide();
{
guide && (
<button
type="button"
onClick={() => {
setOpen(false); // close the menu
if (guide.guide?.hidden) showGuide.mutate({ hidden: false });
guide.openPanel(null);
}}
className={row}
>
<Compass className="h-4 w-4 shrink-0" aria-hidden="true" />
Getting started
</button>
);
}Step 10: Put a data-tour marker on every target
Each target in tours.ts needs exactly one element with that marker on the page the stop points to. Put it on the element you want outlined:
<button type="button" data-tour="add-client" onClick={() => setAdding(true)}>
<Plus className="h-4 w-4" aria-hidden="true" /> Add client
</button>
<section data-tour="brand-logo" className="rounded-card border p-5">
<h3>Logo and colour</h3>
...
</section>
<button aria-label="Notifications" data-tour="bell">...</button>- Outline a button when the person should click it, and a section when they should look at it or fill it in.
- Pick an element whose box is the visible thing. If an outer wrapper is much bigger than its button, the outline floats in empty space.
- Find any marker without a matching element:
grep -o 'target: "[^"]*"' apps/web/components/guide/tours.ts | cut -d'"' -f2 | grep -v '^nav:' | sort -u |
while read t; do grep -rq "data-tour=\"$t\"" apps/web/app apps/web/components || echo "missing: $t"; doneStep 11: Check it in a browser
Run through this at 1440 px and at 375 px:
- A brand-new user sees the welcome once. After "I'll look around myself" it does not come back on reload.
- The launcher shows
0/Nand "Next: ...". Clicking it opens the checklist beside it on desktop and across the bottom on a phone. - Show me on a step: the page changes, the sidebar link is outlined with moving dashes, and the card sits beside the link, not over the sidebar.
- Clicking the outlined link itself goes to the next stop. Clicking the outlined button opens its form and ends the tour.
- Next, Back, the X and Escape all work, and the card has keyboard focus at each stop.
- On a phone: the sidebar stop is skipped, the button is outlined, and the card is a bottom sheet.
- Do the step for real (add a client), go to another page, and the step is ticked. The launcher flashes "Done: ...".
- Skip, then Undo skip. Hide the guide, then bring it back from the account menu.
- With reduced motion turned on, the outline does not move.
- A member (not an admin) gets the member path, without steps they have no permission for.
Mistakes I made, so you don't have to
| Symptom | Cause | Fix |
|---|---|---|
| The outlined button can't be clicked. | The tour's outer wrapper was fixed inset-0 and covered the hole. | The wrapper is pointer-events-none; only the dim pieces and the card are pointer-events-auto. |
| The tour card covers the sidebar link it points at. | The card was placed below or above, which for a sidebar link is on top of the sidebar. | nav: stops put the card beside the link, over the page. |
| "Next" in a browser test clicked the wrong button. | Another "Next" button was on the page behind the overlay. | In automated tests, click the card's button by its position, or give it a test id. |
| A step stays unticked after the work is done. | The guide query was cached. | The provider invalidates the guide on every pathname change, and staleTime is 15 seconds. |
A cleared HiddenAt or an emptied Skipped list is never saved. | GORM Updates(struct) skips zero values. | Update with a map[string]any. |
| A test turns a module off in the database, but its step still shows. | The "is this module on" check is cached. | Clear that cache in the test after changing it. |
| The outline lands on an invisible element. | The same marker was on the desktop sidebar and the hidden phone drawer. | find() returns the first visible match. |
| The animation jumps once per loop. | stroke-dashoffset was not a multiple of the dash pattern. | Use −28 for 8 6 (two full patterns). |
| On a phone, the guide can't be opened. | "Getting started" was only in the sidebar's account menu. | Add it to the top-bar menu too. |
Known small issue
When a sidebar stop is skipped on a phone, the card still counts it ("2 of 2" where the person saw only one stop). To fix it, filter out nav: stops before the tour starts when window.innerWidth < 768, instead of skipping them during the tour.
Adding a step later
- Add a
guideDeftosetupStepsormemberSteps, withshowanddone. - Add its key to
guideKeys. - Add its tour to
TOURSunder the same key. - Add the
data-tourmarkers on the pages the tour visits. - Extend the test with one line that creates the record and checks that the step is done.


