Novedades

Tres atajos nuevos

React + MotionOverlays

Visite guidée avec projecteur

Une visite guidée qui assombrit l'écran et déplace un halo lumineux de bouton en bouton, avec une courte explication à côté.

Réglages

6px
20px

Code

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: "Recherchez partout", text: "Projets, personnes et fichiers au même endroit. Raccourci : /" },
  { target: "new", title: "Créez en un instant", text: "Un document, un tableau ou une invitation, en un geste." },
  { target: "share", title: "Partagez avec un lien", text: "Vous décidez qui peut voir et qui peut modifier." },
];

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 }}>
          Nouveautés
        </p>
        <h2 className="mt-2 text-[26px] font-semibold leading-none tracking-[-0.03em]">Trois nouveaux raccourcis</h2>
      </div>
      <div role="toolbar" aria-label="Outils" 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} />
          Rechercher
          <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="Créer"
          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="Partager"
          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} />
        Voir la visite
      </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"
                >
                  Passer
                </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 ? "Compris" : "Suivant"}
                </button>
              </div>
            </motion.div>
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}

Prompt pour l’IA

Collez-le dans ChatGPT, Claude ou Cursor : le bloc sera adapté à votre projet avec ces réglages, même sans React.

Plus dans Overlays

Où l’utiliser

Une visite guidée de ce genre rend service quand vous lancez une nouveauté sans vouloir rédiger une page d'aide : les nouveaux raccourcis d'un outil de gestion de projet, la première visite sur le tableau de bord d'une appli de réservation ou un éditeur remanié. Le halo glisse d'un bouton à l'autre, et les flèches du clavier font avancer ou reculer.

Trois ou quatre étapes suffisent ; au-delà, on clique sur « Passer » sans lire. Dans le code à copier, le voile sombre couvre toute la fenêtre et suit les boutons au défilement. Les étapes sont des exemples : reliez-les à vos propres éléments.