Customising
Brand colour and style — the quick way
import { TourProvider, createTourTheme } from "hintbeam";
<TourProvider theme={createTourTheme({ brand: "#E5484D", mode: "light", style: "subtle" })} … />brand: your colour. The light, the highlight and the main button are made from it. The light blends into a neighbouring hue; passbrand2to choose the second colour, or the same colour for a flat look. Button text turns dark on light brand colours.mode:"light"or"dark"cards.style: how much is going on.
style |
Looks like | For |
|---|---|---|
"aurora" |
Three strands of light, the guide, a soft glow and spotlight | Launch moments, first-run tours |
"balanced" (default) |
The guide and a single wave, softly lit | Most apps |
"subtle" |
A thin straight line and a quiet card; no guide; nothing keeps moving | Busy, serious or data-heavy apps |
"minimal" |
An outline around the target and a card beside it, like a tooltip; no light or dimming | When the tour should barely be noticed |
Change anything else with overrides:
createTourTheme({ brand, style: "subtle", overrides: { radius: 8, glow: 0.3 } }). The
playground has every control, and writes this code for you.
Theme
import { TourProvider, TOUR_THEMES } from "hintbeam";
<TourProvider theme={TOUR_THEMES.dark} … />
<TourProvider theme={{ accent: "#E5484D", radius: 12, fontFamily: "Inter" }} … />| Key | Default | |
|---|---|---|
accent, accent2 |
violet, sky blue | The light blends from accent to accent2; the main button too. Set only accent for a flat look |
onAccent |
#FFFFFF |
Text on the main button |
card, border, text, mutedText |
frosted white, hairline, near-black, grey | The card. A translucent card gives frosted glass on the web |
radius |
16 |
Card corners |
inset |
{ top: 56, bottom: 32, horizontal: 16 } |
Distance from screen edges — set top below a sticky header |
maxCardWidth |
360 |
On tablets and desktops |
fontFamily |
your page's font | |
light |
true |
false: highlight only, no light |
guide |
true |
The guide on the card's edge (below). false hides it |
backdrop |
0.2 (0.35 dark) |
How much the rest of the screen dims around the target, 0–1. 0 turns the spotlight off. Nothing is ever blocked |
glow |
0.4 |
One dial for every glow — the light, the highlight, the card and the guide. 0 is flat |
motion |
"full" |
"full": words arrive one by one, a spark keeps travelling, the target ripples. "calm": the light draws once and everything else stays still. "none": no motion (also what anyone with reduced motion gets) |
path |
— | The light style when TourProvider has no path prop |
zIndex |
2147483000 |
Web only |
Where the card goes
Next to its target: below it when there is room, then above, right, left. When none fits — and
always on phones narrower than 640 — the card docks to the screen edge away from the target. For a
target scrolled out of view it docks toward it, and the light runs to the edge the target is past,
with a glow and a chevron there. While anything scrolls, the card, the light and the highlight
follow the target every frame. The placement is one pure function, placeStep, shared by web and
native.
The guide
Every card has a small living light on the edge facing its target — the guide. The light of each
step leaves from it, and it shows its mood: it leans toward what it points at, breathes faster
while a step waits for the user, and searches (dimmer, ring spinning) when the target is on
another page or not found. Turn it off with theme={{ guide: false }}.
Use it elsewhere — a help button, an empty state — with GuideOrb:
import { GuideOrb } from "hintbeam";
<button onClick={() => start("first-meal")}>
<GuideOrb size={24} mood="point" accent="#9D86FF" accent2="#4CD6FF" /> Show me around
</button>Words and translations
Every word users read comes from labels:
<TourProvider
labels={{
next: "Siguiente",
back: "Atrás",
skip: "Saltar",
done: "Listo",
showMe: "Muéstrame",
takeMeThere: "Llévame",
notFound: "No encuentro esto aquí ahora mismo.",
yourTurn: "Tu turno",
stepOf: (n, total) => `Paso ${n} de ${total}`,
}}
/>announce(title, text, n, total) controls what screen readers hear when a step appears.
Tour text itself is your data — keep one tour per language, or build tours from your i18n strings.
Path styles
How the light travels from the guide to the target:
path |
Looks like |
|---|---|
"wave" (default) |
A soft S-curve that leaves with a swing and arrives from the side |
"strands" |
Three lines of light: the wave, with two fainter strands fanned out beside it that gather at the target — the most magical |
"straight" |
The shortest line — calm, clearest on busy screens |
"elbow" |
Down, then across, with a rounded corner — reads like a diagram |
"arc" |
One generous arc — playful, good over long distances |
<TourProvider path="elbow" … />Your own path style
A style is a pure function from a start point and a target box to a route. routeThrough turns
Bézier segments into a route with evenly spaced points, so a new style is a few lines:
import { defineTourPath } from "hintbeam";
import { nearEdge, routeThrough, type Point } from "hintbeam/core";
export const zigzag = defineTourPath((from, to, options) => {
const end = nearEdge(from, to);
const mid = { x: (from.x + end.x) / 2 + 40, y: (from.y + end.y) / 2 };
const line = (a: Point, b: Point): [Point, Point, Point] => [
{ x: a.x + (b.x - a.x) / 3, y: a.y + (b.y - a.y) / 3 },
{ x: a.x + (2 * (b.x - a.x)) / 3, y: a.y + (2 * (b.y - a.y)) / 3 },
b,
];
return routeThrough(from, [line(from, mid), line(mid, end)], options?.samples);
});
<TourProvider plugins={[{ name: "zigzag", paths: { zigzag } }]} path="zigzag" … />Every renderer draws every style, on every platform — styles never touch the screen.
Your own step card
Render anything with renderStep:
<TourProvider
renderStep={(step) => (
<MyPopover anchor={step.highlight?.box}>
<h3>{step.step?.title}</h3>
<p>{step.step?.text}</p>
{step.primary === "takeMeThere" && <button onClick={step.takeMeThere}>Go</button>}
{step.primary === "showMe" && <button onClick={step.showMe}>Show me</button>}
{(step.primary === "next" || step.primary === "done") && <button onClick={step.next}>Next</button>}
<button onClick={step.skip}>Skip</button>
</MyPopover>
)}
/>Or pass renderStep={null} and call useTourStep() from any component — a fully headless tour.
step.where tells you exactly where the target is (visible, offscreen, otherScreen, notFound).
Waiting for your app
A step can wait for something to happen in your app:
{ target: "exportButton", text: "Export this report.", advanceOn: { event: "report.exported" } }const { emit } = useTour();
async function exportReport() {
await api.export();
emit("report.exported");
}Declare the event names in defineTargets(…, { events: ["report.exported"] }) to have typos caught.