Skip to main content
astrocraft-ui/ components · 101

Tour

Free · MIT

Headless, 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

Install command
npx astrocraft-ui add overlays/tour

Plain-CSS theme — no build step

Install command
npx astrocraft-ui add overlays/tour --theme css

Tailwind theme — needs Tailwind v4

Install command
npx astrocraft-ui add overlays/tour --theme tailwind

Plain-CSS theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add overlays/tour --theme css --bridge lumos

Tailwind theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add overlays/tour --theme tailwind --bridge lumos

Live 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.

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.

ComponentSlotsVariantsRuntime stateNative 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
---
// 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
---
// 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
---
// 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>
src/components/ui/overlays/tour/index.ts
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.