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, shown without its comments; the files you install keep them, with the reasoning behind every rule.
---
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";
const inerted = new WeakMap<HTMLElement, HTMLElement[]>();
const openers = new WeakMap<HTMLElement, HTMLElement>();
function stepsOf(root: HTMLElement): HTMLElement[] {
return [...root.querySelectorAll<HTMLElement>('[data-slot="tour-step"]')];
}
function trap(root: HTMLElement) {
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) {
target.dataset.tourTarget = "true";
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"]');
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);
});
root.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
event.preventDefault();
end(root);
}
});
if (root.dataset.autostart) start(root);
}
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>
---
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"div"> & {
backLabel?: string;
nextLabel?: string;
lastLabel?: string;
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>
---
import "../../../../styles/structure.css";
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"div"> & {
id: string;
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.