Tour
Free · MITHeadless, accessible, and styled entirely through the attribute surface below — bring your own CSS system, or start from the reference theme.
Install
Without a flag you get no styling at all — plain HTML carrying the attribute surface below. --theme copies the same theme in two dialects;--bridge lumos re-points either one onto Lumos for Astro's tokens (it needs a theme to re-point, and Lumos itself, already in your project).
Unstyled — behavior only, you write the CSS
npx astrocraft-ui add overlays/tourPlain-CSS theme — no build step
npx astrocraft-ui add overlays/tour --theme cssTailwind theme — needs Tailwind v4
npx astrocraft-ui add overlays/tour --theme tailwindPlain-CSS theme, re-pointed onto Lumos tokens
npx astrocraft-ui add overlays/tour --theme css --bridge lumosTailwind theme, re-pointed onto Lumos tokens
npx astrocraft-ui add overlays/tour --theme tailwind --bridge lumosLive demo
Tour
Each step anchors to something already on this page and scrolls it into view, ringed withdata-tour-target. Try to Tab out of a step: you cannot, because the rest of the document is inert — which also hides it from assistive tech, unlike a hand-rolled Tab loop. Escape ends it and focus returns to the button.
Application menus live here. Arrow keys walk between them once one is open.
Toasts announce themselves through a live region that was already in the page.
And the floating button opens a menu without a line of JavaScript of its own.
Attribute surface
With no class-merging machinery, these attributes are the whole extension point. State is a data-attribute or a native attribute; visibility is the hiddenattribute. Generated from source by pnpm slots.
| Component | Slots | Variants | Runtime state | Native state |
|---|---|---|---|---|
| `Tour.astro` | `tour` | `data-autostart`: `true` when set | `data-tour-target` | — |
| `TourControls.astro` | `tour-back` `tour-controls` `tour-next` `tour-skip` | — | — | — |
| `TourStep.astro` | `tour-step` | `data-side`: `bottom` · `top` · `left` · `right` `data-align`: `start` · `center` · `end` | — | — |
Source
What the command copies — 4 files, alias-free so the copy lands standing alone. This is the whole component; there is nothing else.
---
// src/components/ui/overlays/tour/Tour.astro — headless primitive (see ../../README.md).
// A guided walkthrough: a sequence of TourSteps, each anchored to something already on the page.
// Start it from any element carrying `data-tour-start="<this tour's id>"`, or with `autostart`.
//
// Three things make this more than a stack of popovers, and each is a thing tours get wrong:
//
// • THE ANCHOR IS SOMEWHERE ELSE. A step points at an element by CSS selector, which may be far
// down the page, so the page is scrolled to it and the step is placed against its rect.
//
// THE SCROLL IS INSTANT, AND THAT IS A CORRECTNESS DECISION, not a missing feature. A step is
// positioned from the target's viewport rect, so the rect has to be final before it is read —
// and a smooth scroll's is not, for as long as it animates. Measured in Chrome 152: with a step
// already open in the top layer, a programmatic SMOOTH `scrollIntoView` does not move the page
// at all, while an instant one works; the step was then placed against a target a screenful
// away from where it ended up. Scrolling first, instantly, and placing straight after is the
// only order with no window in which the two disagree. `_anchor` still re-places on scroll, so
// the step follows the user afterwards.
// • FOCUS IS TRAPPED, with `inert` on the rest of the document rather than a hand-rolled Tab loop.
// `popover="manual"` is used precisely BECAUSE it does not trap: a modal <dialog> would, but it
// also brings a scroll lock, and a tour whose whole job is to scroll things into view cannot
// have one. `inert` gives the trap without the lock, and hides the background from assistive
// technology at the same time, which a Tab loop does not.
// • ESCAPE ENDS IT. A manual popover is not light-dismissible and ignores Escape — so unlike every
// other overlay here, that has to be written, or the tour is a thing you cannot get out of.
//
// The current step is whichever one is open: state stays in the DOM, so nothing can fall out of sync.
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"div"> & { id: string; autostart?: boolean };
const { autostart = false, class: className, ...rest } = Astro.props;
---
<div class={className} data-slot="tour" data-autostart={autostart ? "true" : undefined} {...rest}>
<slot />
</div>
<script>
import { anchorTo, place } from "../../_anchor";
import { onReadyOnce } from "../../_once";
/** Elements this tour made inert, so releasing restores exactly what it changed and nothing else. */
const inerted = new WeakMap<HTMLElement, HTMLElement[]>();
/** Who started the tour, so focus can go back there when it ends. */
const openers = new WeakMap<HTMLElement, HTMLElement>();
function stepsOf(root: HTMLElement): HTMLElement[] {
return [...root.querySelectorAll<HTMLElement>('[data-slot="tour-step"]')];
}
function trap(root: HTMLElement) {
// Everything at the top of the document that is not an ancestor of the tour. The steps
// themselves are DOM children of `root`, so they are never caught by this.
const affected = [...document.body.children].filter(
(el): el is HTMLElement => el instanceof HTMLElement && !el.contains(root) && !el.inert,
);
for (const el of affected) el.inert = true;
inerted.set(root, affected);
}
function release(root: HTMLElement) {
for (const el of inerted.get(root) ?? []) el.inert = false;
inerted.delete(root);
}
function targetOf(step: HTMLElement): HTMLElement | null {
const selector = step.dataset.target;
return selector ? document.querySelector<HTMLElement>(selector) : null;
}
function show(root: HTMLElement, i: number) {
const steps = stepsOf(root);
const step = steps[i];
if (!step) return end(root);
for (const other of steps) {
if (other !== step && other.matches(":popover-open")) other.hidePopover();
delete targetOf(other)?.dataset.tourTarget;
}
const target = targetOf(step);
if (target) {
// A hook for the theme to ring the thing being talked about — a tour that highlights nothing
// leaves the user reading a description of an element they have to find for themselves.
target.dataset.tourTarget = "true";
// `"instant"`, deliberately, and not just a reduced-motion branch — see the note in the header.
// It also OVERRIDES a consumer's `html { scroll-behavior: smooth }`, which `"auto"` would
// inherit and which would put the bug straight back.
target.scrollIntoView({ block: "center", behavior: "instant" });
anchorTo(step, () => target.getBoundingClientRect());
}
if (!step.matches(":popover-open")) step.showPopover();
place(step);
step.focus({ preventScroll: true });
const back = step.querySelector<HTMLButtonElement>('[data-slot="tour-back"]');
if (back) back.disabled = i === 0;
const next = step.querySelector<HTMLElement>('[data-slot="tour-next"]');
// Both labels ride on the button, so this reads the right one every time rather than overwriting
// the original and having nothing to restore when the user steps back.
if (next) {
const last = i === steps.length - 1;
next.textContent = (last ? next.dataset.lastLabel : next.dataset.label) ?? next.textContent;
}
}
function step(root: HTMLElement, dir: 1 | -1) {
const steps = stepsOf(root);
show(root, steps.findIndex((s) => s.matches(":popover-open")) + dir);
}
function start(root: HTMLElement, opener?: HTMLElement) {
if (opener) openers.set(root, opener);
trap(root);
show(root, 0);
}
function end(root: HTMLElement) {
for (const s of stepsOf(root)) {
if (s.matches(":popover-open")) s.hidePopover();
delete targetOf(s)?.dataset.tourTarget;
}
release(root);
openers.get(root)?.focus();
openers.delete(root);
}
function wire(root: HTMLElement) {
root.addEventListener("click", (event) => {
if (!(event.target instanceof Element)) return;
const control = event.target.closest<HTMLElement>("[data-tour]");
if (!control) return;
if (control.dataset.tour === "next") step(root, 1);
else if (control.dataset.tour === "back") step(root, -1);
else end(root);
});
// `popover="manual"` ignores Escape by design; a tour must not.
root.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
event.preventDefault();
end(root);
}
});
if (root.dataset.autostart) start(root);
}
// Module scope, so this binds once however many tours are on the page.
document.addEventListener("click", (event) => {
if (!(event.target instanceof Element)) return;
const opener = event.target.closest<HTMLElement>("[data-tour-start]");
const root = opener?.dataset.tourStart
? document.getElementById(opener.dataset.tourStart)
: null;
if (root instanceof HTMLElement) start(root, opener ?? undefined);
});
onReadyOnce('[data-slot="tour"]', wire);
</script>
---
// src/components/ui/overlays/tour/TourControls.astro — Tour compound part (see ../../README.md).
// The back / next / skip row, rendered once per TourStep. It ships the buttons rather than an empty
// container because the buttons are the fiddly part: Back has to be disabled on the first step, Next
// has to say "Done" on the last one, and Skip has to be reachable from every step or the tour is a
// trap. The Tour handles all three off the `data-tour` hooks below.
//
// Next carries BOTH labels as data attributes and the Tour reads whichever applies, so stepping back
// from the last step restores "Next" — a script that overwrote the text would have nothing to
// restore it from.
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"div"> & {
backLabel?: string;
nextLabel?: string;
/** What Next says on the final step. */
lastLabel?: string;
/** Pass `null` to drop the skip button — every other step still exits with Escape. */
skipLabel?: string | null;
};
const {
backLabel = "Back",
nextLabel = "Next",
lastLabel = "Done",
skipLabel = "Skip tour",
class: className,
...rest
} = Astro.props;
---
<div class={className} data-slot="tour-controls" {...rest}>
{
skipLabel !== null && (
<button
type="button"
data-tour="skip"
data-slot="tour-skip"
data-variant="ghost"
data-size="sm"
>
{skipLabel}
</button>
)
}
<button
type="button"
data-tour="back"
data-slot="tour-back"
data-variant="outline"
data-size="sm"
>
{backLabel}
</button>
<button
type="button"
data-tour="next"
data-slot="tour-next"
data-variant="primary"
data-size="sm"
data-label={nextLabel}
data-last-label={lastLabel}
>
{nextLabel}
</button>
</div>
---
// src/components/ui/overlays/tour/TourStep.astro — Tour compound part (see ../../README.md).
// One stop on the walkthrough. `target` is a CSS selector for the element this step is about; the
// Tour scrolls it into view, marks it `data-tour-target` for a theme to highlight, and anchors this
// panel to it — so a step can point at anything already on the page without that element knowing
// the tour exists.
//
// `popover="manual"` rather than `auto`: an auto popover light-dismisses on any outside click, and a
// tour that vanishes when you click the thing it is pointing at is useless. The Tour supplies the
// Escape handling that `manual` gives up, and traps focus with `inert` — see Tour.astro.
//
// `tabindex="-1"` is what lets the Tour move focus here without adding a tab stop. `role="dialog"`
// with a `label` names it; a step with no name is announced as "dialog" and nothing more.
import "../../../../styles/structure.css";
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"div"> & {
id: string;
/** CSS selector for the element this step describes. Omit for a step anchored to nothing. */
target?: string;
label: string;
side?: "bottom" | "top" | "left" | "right";
align?: "start" | "center" | "end";
offset?: number;
};
const {
target,
label,
side = "bottom",
align = "center",
offset,
class: className,
...rest
} = Astro.props;
---
<div
popover="manual"
role="dialog"
tabindex="-1"
aria-label={label}
data-slot="tour-step"
data-anchor
data-target={target}
data-side={side}
data-align={align}
data-offset={offset}
class={className}
{...rest}
>
<slot />
</div>
import Tour from "./Tour.astro";
import TourControls from "./TourControls.astro";
import TourStep from "./TourStep.astro";
export { Tour, TourControls, TourStep };
export default Tour;
What you get
The component source, copied into your project by npx astrocraft-ui add overlays/tour — no package dependency, no CSS to fight. Whatever the paid blocks compose, this is it.