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, shown without its comments; the files you install keep them, with the reasoning behind every rule.

src/components/ui/overlays/tour/Tour.astro
---
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>
src/components/ui/overlays/tour/TourControls.astro
---
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>
src/components/ui/overlays/tour/TourStep.astro
---
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>
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.