name: next-easytour
version: 0.4.0
kind: react-component-library
language: typescript
module_type: esm
install: npm install next-easytour
peer_deps:
react: ">=18.0.0"
react-dom: ">=18.0.0"
import_path: next-easytour
server_import_path: next-easytour/server # Node only — never from a client component
stylesheet_path: next-easytour/styles.css
main_exports:
# Configured API — prefer these
- defineTutorial # one config object for a whole tour
- TutorialProvider # owns stepId, steps, completion, permission (no DOM)
- TutorialStage # renders the tour; no required props
- TourTrigger # start button, wired to the config (the tour's entry point)
# Step stores
- staticStore # steps defined in code
- httpStore # JSON over HTTP, with save endpoint
- localStore # localStorage draft
- clipboardStore # save by copying JSON
- draftOver # local draft layered over a read-only source
- normalizeSteps # lift legacy 0.2/0.3 JSON into Step[]
# Host features — how a tour drives the app
- useTutorialFeature # publish one host behaviour by name
- useTutorialFeatures # publish several at once
- useFeatureList # registered features as plain data
# Headless primitives — for hand-wiring
- Tutorial # headless provider
- Card # step card
- Arrow # Bézier/straight arrow overlay
- Spotlight # dimmer with target cutouts
- Circles # annotated ellipses on target
- Labels # text annotations with animation
- Tooltip # lightweight popover (no nav controls)
- TriggerButton # tutorial start button (annoying/default modes)
- Editor # authoring wrapper
- EditorHandles # drag handles for card/arrow
- EditorPanel # Figma-style sidebar editor
- EditorSeam # yellow seam round the trigger while authoring
main_hooks:
- useTour # control the tour from anywhere in the provider
- useTutorialFeature # register a host behaviour a step can invoke
- useTutorial # navigation API inside <Tutorial>
- useTutorialTarget # register DOM element as named target
- useTutorialSteps # load a store into { steps, loading, error, reload }
- useEditorPermission # resolve an EditorPermission into live state
- useTutorialDone # cookie-based completion tracking (0.3; prefer `completion`)
- useEditorState # editor state (active, unsavedCount, save)
- useCardRect # card bounding rect
server_exports:
- createTutorialFileRoute # { GET, POST, PUT } for a Next.js route handler
requires_client_component: true
requires_app_router: false
framework_agnostic: true
ssr_safe: true
editor_runs_in_production: false # hard rule, no override exists
editor_ui_is_lazy_chunk: true # handles/panel/seam load on demand, never in prod
css_prefix: eto-
css_var_prefix: --eto-// app/layout.tsx
import "next-easytour/styles.css";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return <html lang="en"><body>{children}</body></html>;
}// tour.ts — describe the tour once, at module scope
import { defineTutorial, targetPoint, type Step } from "next-easytour";
const steps: Step[] = [
{ id: "welcome", title: "Welcome!", body: "Let me show you around." },
{
id: "search",
title: "Search",
body: "Type here to find anything.",
selector: "#search-input",
highlight: true,
annotations: { spotlight: true, arrow: { to: targetPoint(50, 50) } },
waitFor: { type: "input", pattern: "\\S+" },
},
];
export const tour = defineTutorial({
id: "onboarding",
steps,
trigger: { text: "Take the tour", mode: "annoying" },
});// app/page.tsx
"use client";
import { TutorialProvider, TourTrigger, TutorialStage } from "next-easytour";
import { tour } from "./tour";
export default function Page() {
return (
<TutorialProvider config={tour}>
<TourTrigger />
<input id="search-input" placeholder="Search..." />
<TutorialStage />
</TutorialProvider>
);
}The host does not need useState for stepId, an onStepChange
handler, a fetch, a completion cookie, or a JSX gate around the overlays.
<TutorialProvider> owns all of it.
| Component | Role |
|---|---|
<TutorialProvider config> |
Owns state. Renders no DOM — wrap the page. |
<TourTrigger /> |
Start button. Hides itself when open or unavailable. |
<TutorialStage /> |
Draws the tour. Place anywhere inside the provider. |
Identical, minus "use client".
// src/main.tsx
import "next-easytour/styles.css";// src/App.tsx
import { defineTutorial, TutorialProvider, TourTrigger, TutorialStage } from "next-easytour";
const tour = defineTutorial({
id: "onboarding",
steps: [
{ id: "welcome", title: "Welcome!", body: "Let me show you around." },
{
id: "btn", title: "Click me", body: "Click the button to continue.",
selector: "#my-button", highlight: true,
annotations: { spotlight: true }, waitFor: { type: "click" },
},
],
});
export default function App() {
return (
<TutorialProvider config={tour}>
<TourTrigger />
<button id="my-button">My button</button>
<TutorialStage />
</TutorialProvider>
);
}| Mode | When to use | Example |
|---|---|---|
selector: "#foo" |
Target exists in host markup | { selector: "#nav-search" } |
useTutorialTarget("foo") |
Target is dynamic / conditional | const ref = useTutorialTarget("row-1") |
Hook targets take precedence when both are present.
| Field | Required | Description |
|---|---|---|
id |
yes | Unique string |
title |
no | Card heading |
body |
no | Card body text |
content |
no | JSX body (overrides body) |
selector |
no | CSS selector for target |
targets |
no | Hook-registered target IDs |
annotations.arrow |
no | { to: targetPoint(x, y), style?, label? } |
annotations.spotlight |
no | Boolean — dim everything except target |
annotations.circles |
no | Circle[] — ellipses on target |
annotations.labels |
no | TextLabel[] — text annotations |
scrollIntoView |
no | true or ScrollIntoViewOptions |
actions |
no | Ordered side-effects on enter |
waitFor |
no | Block Next until condition met |
autoAdvance |
no | Auto-advance after N ms |
highlight |
no | true or { pulse, color, padding } |
transition |
no | { enter: "fade" | "fade-slide" | "scale" | "none" } |
cardAnchor |
no | viewportAnchor(x, y) — card position |
meta |
no | Host-owned typed metadata |
actions: [
{ type: "scroll-into-view", behavior: "smooth" },
{ type: "wait", ms: 500 },
{ type: "click", selector: ".expand-btn" },
{ type: "highlight", pulse: true, duration: 2000 },
{ type: "add-class", selector: ".sidebar", className: "ring-2" },
{ type: "remove-class", selector: ".sidebar", className: "hidden" },
{ type: "dispatch", event: "tour:demo", detail: { mode: "dark" } },
{ type: "focus", selector: "#email-input" },
{ type: "feature", name: "chart.setMode", args: ["lines"] }, // ← host behaviour
]Use this instead of synthesising clicks whenever the tour needs to change application state. Clicks are brittle, and for a canvas or a virtualised list they are impossible.
The host registers a behaviour by name, in the component that owns it:
useTutorialFeature({
name: "chart.setMode", // required, unique, stable
label: "Set chart mode", // shown in the editor's menu
description: "Bars or lines.",
params: [{ name: "mode", type: "enum", options: ["bars", "lines"] }],
run: (mode) => setMode(mode), // may return a cleanup fn
read: () => mode, // enables waitFor / equals
snapshot: () => mode, // taken when the tour opens
restore: (m) => setMode(m as Mode), // put back when it closes
});Steps then invoke it and stay serialisable:
{ "actions": [{ "type": "feature", "name": "chart.setMode", "args": ["lines"] }],
"waitFor": { "type": "feature", "name": "chart.hasSelection" } }| Field | Effect if omitted |
|---|---|
run |
Not invocable — { type: "feature" } actions warn |
read |
Not waitable — waitFor: { type: "feature" } warns |
snapshot + restore |
State is not restored when the tour closes |
params |
The editor cannot draw a form; args must be hand-written |
Rules that matter:
- The feature object may be rebuilt every render. Only
nameis identity, and the newest definition is always invoked — soruncloses over current props and state with no memoising. - Registering the same name twice warns; the later one wins.
runreturning a function makes it a cleanup on step leave.- State restores automatically on close, via
snapshot/restore. Disable withfeatures: { restoreOnClose: false }. - Features need
<TutorialProvider>. A bare<Tutorial>has no registry and feature actions warn. useTour().features/useFeatureList()returnFeatureDescriptor[]— use it to assert in a test that steps only name features that exist.
FeatureParam.type is one of "string" | "number" | "boolean" | "enum" | "string[]" | "json". enum and boolean become dropdowns in the editor,
string[] a comma-separated field.
waitFor: { type: "click" } // click on target
waitFor: { type: "click", selector: ".submit" } // click on element
waitFor: { type: "input", selector: "#name", pattern: "\\S+" } // regex match
waitFor: { type: "event", name: "modal:closed" } // custom DOM event
waitFor: { type: "delay", ms: 3000 } // time delay
waitFor: { type: "visible", selector: ".result" } // element appears
waitFor: { type: "custom", predicate: () => count > 5 } // poll function
waitFor: { type: "feature", name: "chart.hasSelection" } // host feature is truthy
waitFor: { type: "feature", name: "chart.mode", equals: "lines" } // host feature equals a valuetargetPoint(50, 50) // { space: "target", x: 50, y: 50 } — % of target rect
viewportAnchor(10, 20) // { space: "viewport", x: 10, y: 20 } — % of viewport (fixed) or px (absolute)Via the stage — keeps every other overlay on:
<TutorialStage card={(args) => <MyCard {...args} />} />Or on <Card> directly, when hand-wiring:
<Card>
{({ step, index, total, isFirst, isLast, canAdvance, isWaiting, stableMinHeight, next, prev, close }) => (
<div className="my-card" style={{ minHeight: stableMinHeight }}>
<img src="/logo.svg" alt="Logo" />
<h3>{step.title}</h3>
<p>{step.body}</p>
<footer>
<button onClick={prev} disabled={isFirst}>Back</button>
<span>{index + 1} / {total}</span>
<button onClick={next} disabled={!canAdvance}>
{isLast ? "Done" : "Next"}
</button>
</footer>
</div>
)}
</Card>stableMinHeight is the max height across all steps — apply as minHeight for uniform card sizing with no jumping.
<TourTrigger> reads everything from the config — including a trigger
config saved from the editor — and hides itself when the tour is open or
unavailable.
const tour = defineTutorial({
id: "onboarding",
steps,
trigger: { text: "Take the tour", mode: "annoying", icon: true },
completion: { markOn: "last-step" }, // cookie defaults to "onboarding-done"
});
<TourTrigger /> // anywhere inside the provider
<TourTrigger text="Replay" startAt="intro" showWhileOpen />mode: "annoying" pulses until the user completes the tour, then renders
calm. Completion is automatic:
completion.markOn |
Records completion when |
|---|---|
"last-step" (default) |
the last step is reached |
"finish" |
the tour is closed after reaching the last step |
"never" |
never — call useTour().markDone() yourself |
completion: false |
disables tracking entirely |
The 0.3 way — <TriggerButton> with hand-passed props plus
useTutorialDone — still works, but the editor cannot round-trip a saved
trigger config to it.
const { start, stop, restart, isOpen, done, markDone, resetDone,
steps, loading, error, reload, available, editor } = useTour();Works anywhere inside <TutorialProvider>, including outside the stage.
| Field | Default | Description |
|---|---|---|
id |
required | Namespaces the completion cookie and warnings |
steps |
— | Steps in code. Ignored if store is set |
store |
staticStore(steps) |
Where steps load from and save to |
theme |
— | TutorialTheme — sets --eto-* CSS variables |
card.positioning |
"fixed" |
"fixed" pins to viewport, "absolute" scrolls with page |
card.anchor |
— | Default viewportAnchor for steps without their own |
transition |
— | { enter, exit, duration } |
scrollIntoView |
— | Global auto-scroll default |
trigger |
— | { text, mode, icon } defaults for <TourTrigger> |
completion |
{ key: "<id>-done", markOn: "last-step" } |
Or false |
editor.permission |
(nobody) | See below |
editor.panel / .handles |
true |
Authoring chrome |
editor.reloadAfterSave |
true |
Re-read the store after saving |
minWidth / media |
— | Responsive gate via matchMedia |
enabled |
true |
Hard off switch |
autoStart |
false |
true or { once, delay } |
classNames |
— | Per-slot classes for the built-in card and trigger |
features.restoreOnClose |
true |
Put host state back when the tour closes |
entryPoint |
"trigger" |
"custom" silences the unreachable-tour warning |
staticStore(steps) // in code, read-only
httpStore("/tour.json", { saveTo: "/api/tutorial" })// JSON file + save endpoint
localStore("draft", { fallback: steps }) // localStorage
clipboardStore() // save → clipboard
draftOver(httpStore("/tour.json"), localStore("d")) // local draft over a sourcehttpStore cache-busts on load, normalises legacy JSON, and surfaces the
server's own error message on a failed save. A custom store is four
members:
interface TutorialStore<Meta> {
readonly name: string;
load?: (signal?: AbortSignal) => Promise<{ steps: Step<Meta>[]; trigger?: TriggerConfig; version: number }>;
save?: (payload: { steps: Step<Meta>[]; trigger?: TriggerConfig; version: number }) => Promise<void>;
readonly readonly: boolean;
}Legacy JSON (0.2 flat target / arrowTo / circles / untagged
cardAnchor) is lifted by normalizeSteps, which stores are already
wired to. It is idempotent and moves unrecognised fields into step.meta
rather than dropping them — do not write a host-side adapter.
1. Tokens. 35 CSS custom properties. Set via config or CSS:
defineTutorial({ id: "t", steps, theme: { accent: "#7c3aed", fontFamily: "Inter" } });:root { --eto-accent: #262262; --eto-card-radius: 1rem; }
.dark { --eto-accent: #60a5fa; --eto-surface: #18181b; }| Group | Tokens |
|---|---|
| Colour | accent, surface, fg, muted, mutedSoft, border, borderSoft, hoverBg, arrowColor, arrowOpacity |
| Type | fontFamily, fontSize, titleSize, titleWeight, lineHeight |
| Card | cardWidth, cardRadius, cardPadding, cardBackground, cardShadow, cardBackdropFilter, borderWidth |
| Buttons | buttonRadius, buttonPadding, buttonFontSize, primaryButtonBg, primaryButtonFg |
| Overlays | spotlightColor, spotlightPadding, spotlightRadius, highlightColor, seamColor |
Each maps to --eto-* via THEME_CSS_VARS; themeToCssVars(theme) does
the conversion.
2. Presets. theme: { preset: "minimal" } — one of default,
minimal, soft, glass, contrast. Only a token bundle, so it can do
nothing your own tokens cannot; your tokens are applied on top.
3. Slot classes. For a design system that owns its styling:
classNames: { card: "rounded-2xl shadow-xl", nextButton: "bg-indigo-600" }Slots: card, header, progress, closeButton, body, title,
copy, footer, prevButton, nextButton, trigger, spotlight,
tooltip. The library class is kept and yours appended — this adds to
the default look. To replace it, use the render-prop.
Authoring is on when three things are true: this is not a production
build, the user is allowed, and they have unlocked the editor.
<TutorialStage> renders the handles and panel itself — do not gate
them in host JSX.
The editor never runs in production.
resolveAllowreturns false in a production build before considering anything else.permission,canEdit,?edit=1, andunlock()are all ignored there, andcreateTutorialFileRouterefuses writes ahead ofauthorize. Do not try to route around this — there is no flag. Author in development and deploy the JSON.The UI is not even shipped:
EditorHandles,EditorPanelandEditorSeamsit behind a dynamicimport()ineditor/chrome.tsx, guarded by a foldableprocess.env.NODE_ENVcheck ineditor/useEditorChunk.ts. Never import those three statically — one static import on the main path returns ~45 KB of authoring UI to every visitor's bundle. A test enforces this.EditoranduseEditorStateare exempt and stay in the main bundle.
const tour = defineTutorial({
id: "onboarding",
store: httpStore("/tour.json", { saveTo: "/api/tutorial" }),
editor: { permission: { allow: "dev-only" } },
});
// Reactive auth stays at the call site; the config stays a static module.
<TutorialProvider config={tour} canEdit={hasRole("admin")}>
<TourTrigger />
<TutorialStage />
</TutorialProvider>editor.permission |
Meaning |
|---|---|
(unset) / false / "never" |
Nobody edits. The default. |
true / "always" |
Allowed, unlocked immediately |
"dev-only" |
Allowed outside production; unlock still required |
{ allow, requireUnlock, unlockParam, unlockKey, persistUnlock } |
Full rule |
Rule defaults: requireUnlock: true, unlockParam: "edit" (?edit=1),
unlockKey: "mod+shift+e", persistUnlock: "eto:editor-unlocked"
(sessionStorage). Set any to false to disable that channel.
allow takes a plain boolean — the library never calls a host hook.
The 0.3 canEdit={{ useCanEdit }} shape still works but is deprecated: it
put a hook behind a prop, which the rules of hooks do not survive.
const { allowed, unlocked, active, unlock, lock, toggle } = useTour().editor;While unlocked, a yellow seam and a next-easytour badge are drawn round
the trigger button, so the author can see the mode is on and which button
belongs to the library. It is portaled, fixed, and pointer-events: none
— no DOM inline, no layout change. Restyle with --eto-seam,
--eto-seam-fg, --eto-seam-radius, --eto-seam-z. The seam tracks the
trigger, so it is on screen only while the tour is closed; once the tour
opens, the panel and handles are the mode indicator.
A tour always has an entry point. <TourTrigger> or autoStart —
without one, nothing can open the tour and the page looks entirely normal.
In development the provider warns when a tour is unreachable.
entryPoint |
Meaning |
|---|---|
"trigger" (default) |
A <TourTrigger> or autoStart is expected; warn if neither |
"custom" |
The host calls useTour().start() itself; stay quiet |
// app/api/tutorial/route.ts
import { createTutorialFileRoute } from "next-easytour/server";
export const { GET, POST } = createTutorialFileRoute({
file: "public/tour.json", // required — no default to mistype
authorize: async (req) => (await getSession(req))?.role === "admin",
read: false, // GET returns 405 unless true
});Validates and normalises the payload, refuses to overwrite a file with an
empty step list, writes atomically, and refuses paths outside the project
directory. Every write is refused in a production build, before
authorize is consulted — authorize narrows access within development,
it never grants it.
next-easytour/server is a separate, Node-only entry point. Importing
it from a client component fails to resolve fs.
start() → onOpen, onStepEnter(a), actions, scrollIntoView, waitFor
next() → onStepLeave(a), onStepEnter(b)
stop() → onStepLeave(b), onClose, completion recorded
Callbacks go on <TutorialStage>: onOpen, onClose, onStepEnter,
onStepLeave, canAdvance. The tour closes itself if the media gate
stops matching, or if the open step disappears from the store.
- Ship analytics
- Support nested tours
- Authenticate anyone —
editor.permission.allowtakes a boolean you compute - Require Next.js — works with any React 18+ setup
(
next-easytour/serveris Next-shaped, but optional)
| Don't | Do |
|---|---|
useState for stepId + onStepChange |
<TutorialProvider> owns it; use useTour() |
{isAdmin && <EditorPanel />} |
<TutorialStage> gates chrome itself |
| Hand-written fetch + step adapter | httpStore(url) — it normalises legacy JSON |
onStepEnter reading step.meta to drive app state |
useTutorialFeature + { type: "feature" } actions |
| Synthesising clicks to change app state | A feature — clicks break on canvas and virtual lists |
| A hand-rolled ref snapshotting pre-tour state | snapshot / restore on each feature |
| Rebuilding the card via render-prop just to restyle it | theme tokens, a preset, or classNames slots |
Hand-written /api/tutorial route |
createTutorialFileRoute({ file, authorize }) |
typeof window !== "undefined" && innerWidth >= 768 in render |
minWidth: 768 on the config |
<TriggerButton> with hand-passed props |
<TourTrigger /> |
Comparing step.id to the last id to mark done |
completion.markOn |
Relying on canEdit defaulting to dev |
editor.permission: "dev-only", explicitly |
| Trying to enable the editor in production | Not possible. Author in dev, deploy the JSON |
import { EditorPanel } from "../editor/EditorPanel" inside the library |
Import from editor/chrome via useEditorChunk, or the chunk split breaks |
A provider with no <TourTrigger> and no autoStart |
Nothing can open the tour — add one, or entryPoint: "custom" |