Novedades
Tres atajos nuevos
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
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.