Novedades

Tres atajos nuevos

React + MotionOverlays

Tour guiado con foco

Un recorrido guiado que oscurece la pantalla y mueve un foco brillante de botón en botón, con una nota breve al lado.

Ajustes

6px
20px

Código

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: "Busca en todo", text: "Proyectos, personas y archivos desde un solo sitio. Atajo: /" },
  { target: "new", title: "Crea al instante", text: "Un documento, un tablero o una invitación, en un toque." },
  { target: "share", title: "Comparte con un enlace", text: "Tú decides quién puede ver y quién puede editar." },
];

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 }}>
          Novedades
        </p>
        <h2 className="mt-2 text-[26px] font-semibold leading-none tracking-[-0.03em]">Tres atajos nuevos</h2>
      </div>
      <div role="toolbar" aria-label="Herramientas" 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} />
          Buscar
          <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="Crear"
          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="Compartir"
          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} />
        Ver el recorrido
      </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"
                >
                  Saltar
                </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 ? "Entendido" : "Siguiente"}
                </button>
              </div>
            </motion.div>
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}

Prompt para IA

Pégalo en ChatGPT, Claude o Cursor y adaptará el bloque a tu proyecto con estos ajustes, aunque no uses React.

Más en Overlays

Dónde usarlo

Un tour de bienvenida así viene bien cuando lanzas algo nuevo y no quieres escribir una página de ayuda: los atajos nuevos de una herramienta de proyectos, la primera visita al panel de una app de reservas o un editor rediseñado. El foco se desliza entre botones y con las flechas del teclado avanzas o retrocedes.

Con tres o cuatro pasos sobra; a partir de ahí la gente pulsa «Saltar» sin leer. En el código copiable el velo oscuro cubre toda la ventana y sigue a los botones al hacer scroll. Los pasos son de ejemplo, así que apúntalos a tus propios botones.