# Spotlight product tour

> A guided tour that dims the screen and slides a glowing spotlight from button to button, each with a short note.

- Category: [Overlays](https://library.aimran.es/components/overlays)
- Built with: React + Motion
- Live editor: https://library.aimran.es/blocks/spotlight-tour
- Library: AIMRAN Library (https://library.aimran.es), free, no sign-up, by Aimran Oukham

## Where to use it

A product tour like this helps when you launch something new and don't want to write a help page: new shortcuts in a project management tool, the first visit to a booking dashboard or a redesigned editor. The spotlight glides between buttons, and the arrow keys move back and forth through the steps.

Three or four steps is plenty; after that people just hit Skip. In the copied code the dark veil covers the whole window and follows the buttons as you scroll. The steps and texts are samples, so point them at your own buttons.

## Code

### React (Motion)

```tsx
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState, type RefObject } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { Plus, Search, Share, Sparkles } from "lucide-react";

const spring = { type: "spring" as const, stiffness: 380, damping: 34 };

const TOUR = [
  { target: "search", title: "Search everything", text: "Projects, people, and files in one place. Shortcut: /" },
  { target: "new", title: "Create instantly", text: "A doc, a board, or an invite, in one tap." },
  { target: "share", title: "Share with a link", text: "You decide who can view and who can edit." },
];

type TourLayout = { x: number; y: number; w: number; h: number; boxW: number; boxH: number; tipH: number };

const FOCUSABLE =
  'a[href],button:not([disabled]),input:not([disabled]),select,textarea,[tabindex]:not([tabindex="-1"])';

// Modal accesible: enfoca el primer control (o el marcado con data-autofocus), atrapa Tab, cierra con Esc y devuelve el foco
function useDialog(ref: RefObject<HTMLElement | null>, open: boolean, onClose: () => void) {
  const close = useRef(onClose);
  useEffect(() => {
    close.current = onClose;
  });
  useEffect(() => {
    if (!open) return;
    const previous = document.activeElement as HTMLElement | null;
    const node = ref.current;
    const first =
      node?.querySelector<HTMLElement>("[data-autofocus]") ?? node?.querySelector<HTMLElement>(FOCUSABLE);
    first?.focus({ preventScroll: true });
    const onKey = (e: KeyboardEvent) => {
      if (e.key === "Escape") {
        e.preventDefault();
        close.current();
        return;
      }
      if (e.key !== "Tab" || !node) return;
      const items = Array.from(node.querySelectorAll<HTMLElement>(FOCUSABLE));
      if (!items.length) return;
      const firstItem = items[0];
      const lastItem = items[items.length - 1];
      if (!firstItem || !lastItem) return;
      if (!node.contains(document.activeElement)) {
        e.preventDefault();
        firstItem.focus();
      } else if (e.shiftKey && document.activeElement === firstItem) {
        e.preventDefault();
        lastItem.focus();
      } else if (!e.shiftKey && document.activeElement === lastItem) {
        e.preventDefault();
        firstItem.focus();
      }
    };
    document.addEventListener("keydown", onKey);
    return () => {
      document.removeEventListener("keydown", onKey);
      previous?.focus({ preventScroll: true });
    };
  }, [open, ref]);
}

interface SpotlightTourProps {
  accent?: string;
  /** Margen del recorte alrededor del elemento */
  padding?: number;
  radius?: number;
}

export default function SpotlightTour({
  accent = "#6e56cf",
  padding: pad = 6,
  radius = 20,
}: SpotlightTourProps) {
  const [open, setOpen] = useState(false);
  const [step, setStep] = useState(0);
  const [layout, setLayout] = useState<TourLayout | null>(null);
  const hasLayout = layout !== null;
  const tip = useRef<HTMLDivElement>(null);
  const targets = useRef<Record<string, HTMLElement | null>>({});
  const titleId = useId();
  const reduce = useReducedMotion();
  const transition = reduce ? { duration: 0 } : { type: "spring" as const, stiffness: 280, damping: 30 };
  useDialog(tip, open && !!layout, () => setOpen(false));

  // Coordenadas de ventana: el foco es fixed y sigue al elemento al hacer scroll o cambiar el tamaño
  const measure = useCallback(() => {
    const el = targets.current[TOUR[step]!.target];
    if (!el) return;
    const r = el.getBoundingClientRect();
    setLayout({
      x: r.left,
      y: r.top,
      w: r.width,
      h: r.height,
      boxW: window.innerWidth,
      boxH: window.innerHeight,
      tipH: tip.current?.offsetHeight ?? 120,
    });
  }, [step]);

  useLayoutEffect(() => {
    if (!open) return;
    measure();
    window.addEventListener("resize", measure);
    window.addEventListener("scroll", measure, true);
    return () => {
      window.removeEventListener("resize", measure);
      window.removeEventListener("scroll", measure, true);
    };
  }, [open, measure, hasLayout]);

  useEffect(() => {
    if (!open) {
      setLayout(null);
      return;
    }
    const onKey = (e: KeyboardEvent) => {
      if (e.key === "ArrowRight") setStep((s) => Math.min(s + 1, TOUR.length - 1));
      if (e.key === "ArrowLeft") setStep((s) => Math.max(s - 1, 0));
    };
    document.addEventListener("keydown", onKey);
    return () => document.removeEventListener("keydown", onKey);
  }, [open]);

  const s = TOUR[step]!;
  const last = step === TOUR.length - 1;
  let tipPos = { left: 12, top: 12, width: 264 };
  if (layout) {
    const width = Math.min(264, layout.boxW - 24);
    const below = layout.y + layout.h + pad + 12 + layout.tipH < layout.boxH - 12;
    const top = below ? layout.y + layout.h + pad + 12 : layout.y - pad - 12 - layout.tipH;
    tipPos = {
      width,
      left: Math.min(Math.max(layout.x + layout.w / 2 - width / 2, 12), layout.boxW - width - 12),
      top: Math.min(Math.max(top, 12), layout.boxH - layout.tipH - 12),
    };
  }
  const register = (key: string) => (el: HTMLElement | null) => {
    targets.current[key] = el;
  };

  return (
    <div className="flex min-h-[420px] w-full flex-col items-center justify-center gap-8 bg-[#f5f5f7] px-5 text-[#1d1d1f] dark:bg-[#0b0b0f] dark:text-[#f5f5f7]">
      <div className="text-center">
        <p className="text-[11px] font-semibold uppercase tracking-[0.22em]" style={{ color: accent }}>
          What’s new
        </p>
        <h2 className="mt-2 text-[26px] font-semibold leading-none tracking-[-0.03em]">Three new shortcuts</h2>
      </div>
      <div role="toolbar" aria-label="Tools" className="flex items-center gap-1.5 rounded-[22px] border border-black/[0.06] bg-white p-1.5 shadow-[0_1px_2px_rgba(0,0,0,0.04),0_16px_40px_-12px_rgba(0,0,0,0.15)] dark:border-white/10 dark:bg-[#17171c] dark:shadow-none">
        <button
          ref={register("search")}
          type="button"
          className="flex h-11 w-[150px] items-center gap-2 rounded-2xl bg-black/[0.04] px-3.5 text-[14px] text-[#86868b] dark:bg-white/[0.06]"
        >
          <Search className="h-4 w-4 shrink-0" strokeWidth={1.5} />
          Search
          <kbd className="ml-auto rounded-md border border-black/10 px-1.5 font-sans text-[11px] dark:border-white/15">/</kbd>
        </button>
        <button
          ref={register("new")}
          type="button"
          aria-label="Create"
          className="grid h-11 w-11 place-items-center rounded-2xl text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.25)]"
          style={{ backgroundColor: accent }}
        >
          <Plus className="h-5 w-5" strokeWidth={1.5} />
        </button>
        <button
          ref={register("share")}
          type="button"
          aria-label="Share"
          className="grid h-11 w-11 place-items-center rounded-2xl transition hover:bg-black/5 dark:hover:bg-white/10"
        >
          <Share className="h-[18px] w-[18px]" strokeWidth={1.5} />
        </button>
      </div>
      <motion.button
        type="button"
        aria-haspopup="dialog"
        onClick={() => {
          setStep(0);
          setOpen(true);
        }}
        whileHover={{ scale: 1.03 }}
        whileTap={{ scale: 0.96 }}
        transition={spring}
        className="inline-flex h-11 items-center gap-2 rounded-full bg-[#1d1d1f] pl-4 pr-5 text-[14px] font-medium text-white shadow-[0_8px_20px_-8px_rgba(0,0,0,0.5)] dark:bg-white dark:text-black"
      >
        <Sparkles className="h-4 w-4" strokeWidth={1.5} />
        Take the tour
      </motion.button>

      <AnimatePresence>
        {open && layout && (
          // fixed: el velo cubre toda la ventana y el recorte usa coordenadas de ventana
          <motion.div
            key="tour"
            className="fixed inset-0 z-50"
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            transition={{ duration: reduce ? 0 : 0.25 }}
          >
            <div aria-hidden className="absolute inset-0" />
            <motion.div
              aria-hidden
              className="pointer-events-none absolute"
              initial={false}
              animate={{ left: layout.x - pad, top: layout.y - pad, width: layout.w + pad * 2, height: layout.h + pad * 2 }}
              transition={transition}
              style={{
                borderRadius: radius,
                boxShadow: "0 0 0 9999px rgba(8,8,14,0.66), 0 0 0 1.5px " + accent + ", 0 0 32px 6px " + accent + "55",
              }}
            />
            <motion.div
              ref={tip}
              role="dialog"
              aria-modal="true"
              aria-labelledby={titleId}
              initial={{ ...tipPos, opacity: 0, scale: 0.96 }}
              animate={{ ...tipPos, opacity: 1, scale: 1 }}
              transition={transition}
              className="absolute rounded-[20px] border border-black/[0.06] bg-white p-4 text-[#1d1d1f] shadow-[0_8px_24px_rgba(0,0,0,0.16),0_30px_60px_-10px_rgba(0,0,0,0.3)] dark:border-white/10 dark:bg-[#1c1c22] dark:text-[#f5f5f7]"
            >
              <AnimatePresence mode="wait" initial={false}>
                <motion.div
                  key={step}
                  initial={{ opacity: 0, y: 6 }}
                  animate={{ opacity: 1, y: 0 }}
                  exit={{ opacity: 0, y: -6 }}
                  transition={{ duration: reduce ? 0 : 0.15 }}
                >
                  <h3 id={titleId} className="pr-2 text-[16px] font-semibold tracking-[-0.01em]">
                    <span className="sr-only">Paso {step + 1} de {TOUR.length}: </span>
                    {s.title}
                  </h3>
                  <p className="mt-1 text-[13px] leading-snug text-[#6e6e73] dark:text-[#a1a1a6]">{s.text}</p>
                </motion.div>
              </AnimatePresence>
              <div className="mt-3.5 flex items-center gap-3">
                <div aria-hidden className="flex gap-1">
                  {TOUR.map((_, i) => (
                    <motion.span
                      key={i}
                      className="h-1.5 rounded-full"
                      initial={false}
                      animate={{ width: i === step ? 14 : 6, opacity: i === step ? 1 : 0.25 }}
                      transition={transition}
                      style={{ backgroundColor: accent }}
                    />
                  ))}
                </div>
                <button
                  type="button"
                  onClick={() => setOpen(false)}
                  className="ml-auto px-1 text-[13px] text-[#86868b] transition hover:text-[#1d1d1f] dark:hover:text-white"
                >
                  Skip
                </button>
                <button
                  type="button"
                  data-autofocus
                  onClick={() => (last ? setOpen(false) : setStep(step + 1))}
                  className="h-8 rounded-full px-3.5 text-[13px] font-medium text-white transition hover:brightness-110"
                  style={{ backgroundColor: accent }}
                >
                  {last ? "Got it" : "Next"}
                </button>
              </div>
            </motion.div>
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}
```

## Prompt for AI assistants

Paste this into ChatGPT, Claude, Cursor, v0 or Lovable to adapt the block to your stack (Tailwind, Vue, Svelte…):

````text
I want to add the “Spotlight product tour” block (Overlays) from AIMRAN Library to my project.

## What it is
A guided tour that dims the screen and slides a glowing spotlight from button to button, each with a short note.

## Chosen settings
- Accent: #6e56cf
- Spotlight padding: 6px (from 2 to 16px)
- Spotlight radius: 20px (from 4 to 28px)

## What I need
1. Add it to my project with the same look and animation as the reference code: same sizes, colours, radii, shadows, timings and easing.
2. It is built with React + TypeScript, with framer-motion for the animation and lucide-react for the icons. If my project uses something else (Vue, Svelte, Astro, plain HTML…), translate it without losing any behaviour. If I use Tailwind, turn the styles into classes; if not, keep the CSS in its own file.
3. Expose the settings above as props (or CSS variables) with those values as defaults.
4. Make it work well on phones: nothing that depends only on hover, touch targets of at least 44 px and no overflow on 320 px screens.
5. Make it look right in light and dark mode.
6. Keep it accessible: semantic HTML, roles and aria-* where needed, visible focus and keyboard support.
7. Respect prefers-reduced-motion: with reduced motion, no big animations but the same end result.

## Reference code
```tsx
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState, type RefObject } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { Plus, Search, Share, Sparkles } from "lucide-react";

const spring = { type: "spring" as const, stiffness: 380, damping: 34 };

const TOUR = [
  { target: "search", title: "Search everything", text: "Projects, people, and files in one place. Shortcut: /" },
  { target: "new", title: "Create instantly", text: "A doc, a board, or an invite, in one tap." },
  { target: "share", title: "Share with a link", text: "You decide who can view and who can edit." },
];

type TourLayout = { x: number; y: number; w: number; h: number; boxW: number; boxH: number; tipH: number };

const FOCUSABLE =
  'a[href],button:not([disabled]),input:not([disabled]),select,textarea,[tabindex]:not([tabindex="-1"])';

// Modal accesible: enfoca el primer control (o el marcado con data-autofocus), atrapa Tab, cierra con Esc y devuelve el foco
function useDialog(ref: RefObject<HTMLElement | null>, open: boolean, onClose: () => void) {
  const close = useRef(onClose);
  useEffect(() => {
    close.current = onClose;
  });
  useEffect(() => {
    if (!open) return;
    const previous = document.activeElement as HTMLElement | null;
    const node = ref.current;
    const first =
      node?.querySelector<HTMLElement>("[data-autofocus]") ?? node?.querySelector<HTMLElement>(FOCUSABLE);
    first?.focus({ preventScroll: true });
    const onKey = (e: KeyboardEvent) => {
      if (e.key === "Escape") {
        e.preventDefault();
        close.current();
        return;
      }
      if (e.key !== "Tab" || !node) return;
      const items = Array.from(node.querySelectorAll<HTMLElement>(FOCUSABLE));
      if (!items.length) return;
      const firstItem = items[0];
      const lastItem = items[items.length - 1];
      if (!firstItem || !lastItem) return;
      if (!node.contains(document.activeElement)) {
        e.preventDefault();
        firstItem.focus();
      } else if (e.shiftKey && document.activeElement === firstItem) {
        e.preventDefault();
        lastItem.focus();
      } else if (!e.shiftKey && document.activeElement === lastItem) {
        e.preventDefault();
        firstItem.focus();
      }
    };
    document.addEventListener("keydown", onKey);
    return () => {
      document.removeEventListener("keydown", onKey);
      previous?.focus({ preventScroll: true });
    };
  }, [open, ref]);
}

interface SpotlightTourProps {
  accent?: string;
  /** Margen del recorte alrededor del elemento */
  padding?: number;
  radius?: number;
}

export default function SpotlightTour({
  accent = "#6e56cf",
  padding: pad = 6,
  radius = 20,
}: SpotlightTourProps) {
  const [open, setOpen] = useState(false);
  const [step, setStep] = useState(0);
  const [layout, setLayout] = useState<TourLayout | null>(null);
  const hasLayout = layout !== null;
  const tip = useRef<HTMLDivElement>(null);
  const targets = useRef<Record<string, HTMLElement | null>>({});
  const titleId = useId();
  const reduce = useReducedMotion();
  const transition = reduce ? { duration: 0 } : { type: "spring" as const, stiffness: 280, damping: 30 };
  useDialog(tip, open && !!layout, () => setOpen(false));

  // Coordenadas de ventana: el foco es fixed y sigue al elemento al hacer scroll o cambiar el tamaño
  const measure = useCallback(() => {
    const el = targets.current[TOUR[step]!.target];
    if (!el) return;
    const r = el.getBoundingClientRect();
    setLayout({
      x: r.left,
      y: r.top,
      w: r.width,
      h: r.height,
      boxW: window.innerWidth,
      boxH: window.innerHeight,
      tipH: tip.current?.offsetHeight ?? 120,
    });
  }, [step]);

  useLayoutEffect(() => {
    if (!open) return;
    measure();
    window.addEventListener("resize", measure);
    window.addEventListener("scroll", measure, true);
    return () => {
      window.removeEventListener("resize", measure);
      window.removeEventListener("scroll", measure, true);
    };
  }, [open, measure, hasLayout]);

  useEffect(() => {
    if (!open) {
      setLayout(null);
      return;
    }
    const onKey = (e: KeyboardEvent) => {
      if (e.key === "ArrowRight") setStep((s) => Math.min(s + 1, TOUR.length - 1));
      if (e.key === "ArrowLeft") setStep((s) => Math.max(s - 1, 0));
    };
    document.addEventListener("keydown", onKey);
    return () => document.removeEventListener("keydown", onKey);
  }, [open]);

  const s = TOUR[step]!;
  const last = step === TOUR.length - 1;
  let tipPos = { left: 12, top: 12, width: 264 };
  if (layout) {
    const width = Math.min(264, layout.boxW - 24);
    const below = layout.y + layout.h + pad + 12 + layout.tipH < layout.boxH - 12;
    const top = below ? layout.y + layout.h + pad + 12 : layout.y - pad - 12 - layout.tipH;
    tipPos = {
      width,
      left: Math.min(Math.max(layout.x + layout.w / 2 - width / 2, 12), layout.boxW - width - 12),
      top: Math.min(Math.max(top, 12), layout.boxH - layout.tipH - 12),
    };
  }
  const register = (key: string) => (el: HTMLElement | null) => {
    targets.current[key] = el;
  };

  return (
    <div className="flex min-h-[420px] w-full flex-col items-center justify-center gap-8 bg-[#f5f5f7] px-5 text-[#1d1d1f] dark:bg-[#0b0b0f] dark:text-[#f5f5f7]">
      <div className="text-center">
        <p className="text-[11px] font-semibold uppercase tracking-[0.22em]" style={{ color: accent }}>
          What’s new
        </p>
        <h2 className="mt-2 text-[26px] font-semibold leading-none tracking-[-0.03em]">Three new shortcuts</h2>
      </div>
      <div role="toolbar" aria-label="Tools" className="flex items-center gap-1.5 rounded-[22px] border border-black/[0.06] bg-white p-1.5 shadow-[0_1px_2px_rgba(0,0,0,0.04),0_16px_40px_-12px_rgba(0,0,0,0.15)] dark:border-white/10 dark:bg-[#17171c] dark:shadow-none">
        <button
          ref={register("search")}
          type="button"
          className="flex h-11 w-[150px] items-center gap-2 rounded-2xl bg-black/[0.04] px-3.5 text-[14px] text-[#86868b] dark:bg-white/[0.06]"
        >
          <Search className="h-4 w-4 shrink-0" strokeWidth={1.5} />
          Search
          <kbd className="ml-auto rounded-md border border-black/10 px-1.5 font-sans text-[11px] dark:border-white/15">/</kbd>
        </button>
        <button
          ref={register("new")}
          type="button"
          aria-label="Create"
          className="grid h-11 w-11 place-items-center rounded-2xl text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.25)]"
          style={{ backgroundColor: accent }}
        >
          <Plus className="h-5 w-5" strokeWidth={1.5} />
        </button>
        <button
          ref={register("share")}
          type="button"
          aria-label="Share"
          className="grid h-11 w-11 place-items-center rounded-2xl transition hover:bg-black/5 dark:hover:bg-white/10"
        >
          <Share className="h-[18px] w-[18px]" strokeWidth={1.5} />
        </button>
      </div>
      <motion.button
        type="button"
        aria-haspopup="dialog"
        onClick={() => {
          setStep(0);
          setOpen(true);
        }}
        whileHover={{ scale: 1.03 }}
        whileTap={{ scale: 0.96 }}
        transition={spring}
        className="inline-flex h-11 items-center gap-2 rounded-full bg-[#1d1d1f] pl-4 pr-5 text-[14px] font-medium text-white shadow-[0_8px_20px_-8px_rgba(0,0,0,0.5)] dark:bg-white dark:text-black"
      >
        <Sparkles className="h-4 w-4" strokeWidth={1.5} />
        Take the tour
      </motion.button>

      <AnimatePresence>
        {open && layout && (
          // fixed: el velo cubre toda la ventana y el recorte usa coordenadas de ventana
          <motion.div
            key="tour"
            className="fixed inset-0 z-50"
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            transition={{ duration: reduce ? 0 : 0.25 }}
          >
            <div aria-hidden className="absolute inset-0" />
            <motion.div
              aria-hidden
              className="pointer-events-none absolute"
              initial={false}
              animate={{ left: layout.x - pad, top: layout.y - pad, width: layout.w + pad * 2, height: layout.h + pad * 2 }}
              transition={transition}
              style={{
                borderRadius: radius,
                boxShadow: "0 0 0 9999px rgba(8,8,14,0.66), 0 0 0 1.5px " + accent + ", 0 0 32px 6px " + accent + "55",
              }}
            />
            <motion.div
              ref={tip}
              role="dialog"
              aria-modal="true"
              aria-labelledby={titleId}
              initial={{ ...tipPos, opacity: 0, scale: 0.96 }}
              animate={{ ...tipPos, opacity: 1, scale: 1 }}
              transition={transition}
              className="absolute rounded-[20px] border border-black/[0.06] bg-white p-4 text-[#1d1d1f] shadow-[0_8px_24px_rgba(0,0,0,0.16),0_30px_60px_-10px_rgba(0,0,0,0.3)] dark:border-white/10 dark:bg-[#1c1c22] dark:text-[#f5f5f7]"
            >
              <AnimatePresence mode="wait" initial={false}>
                <motion.div
                  key={step}
                  initial={{ opacity: 0, y: 6 }}
                  animate={{ opacity: 1, y: 0 }}
                  exit={{ opacity: 0, y: -6 }}
                  transition={{ duration: reduce ? 0 : 0.15 }}
                >
                  <h3 id={titleId} className="pr-2 text-[16px] font-semibold tracking-[-0.01em]">
                    <span className="sr-only">Paso {step + 1} de {TOUR.length}: </span>
                    {s.title}
                  </h3>
                  <p className="mt-1 text-[13px] leading-snug text-[#6e6e73] dark:text-[#a1a1a6]">{s.text}</p>
                </motion.div>
              </AnimatePresence>
              <div className="mt-3.5 flex items-center gap-3">
                <div aria-hidden className="flex gap-1">
                  {TOUR.map((_, i) => (
                    <motion.span
                      key={i}
                      className="h-1.5 rounded-full"
                      initial={false}
                      animate={{ width: i === step ? 14 : 6, opacity: i === step ? 1 : 0.25 }}
                      transition={transition}
                      style={{ backgroundColor: accent }}
                    />
                  ))}
                </div>
                <button
                  type="button"
                  onClick={() => setOpen(false)}
                  className="ml-auto px-1 text-[13px] text-[#86868b] transition hover:text-[#1d1d1f] dark:hover:text-white"
                >
                  Skip
                </button>
                <button
                  type="button"
                  data-autofocus
                  onClick={() => (last ? setOpen(false) : setStep(step + 1))}
                  className="h-8 rounded-full px-3.5 text-[13px] font-medium text-white transition hover:brightness-110"
                  style={{ backgroundColor: accent }}
                >
                  {last ? "Got it" : "Next"}
                </button>
              </div>
            </motion.div>
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}
```

Give me the complete final code, ready to paste, and tell me in one line where to put it in my project.
````
