Novedades
Tres atajos nuevos
Produkttour mit Spotlight
Eine geführte Tour, die den Bildschirm abdunkelt und einen leuchtenden Ausschnitt von Button zu Button wandern lässt, mit kurzer Erklärung.
Einstellungen
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: "Alles durchsuchen", text: "Projekte, Personen und Dateien an einem Ort. Kurzbefehl: /" },
{ target: "new", title: "Sofort erstellen", text: "Ein Dokument, ein Board oder eine Einladung – mit einem Tippen." },
{ target: "share", title: "Per Link teilen", text: "Du entscheidest, wer ansehen und wer bearbeiten darf." },
];
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 }}>
Neuigkeiten
</p>
<h2 className="mt-2 text-[26px] font-semibold leading-none tracking-[-0.03em]">Drei neue Kurzbefehle</h2>
</div>
<div role="toolbar" aria-label="Werkzeuge" 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} />
Suchen
<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="Erstellen"
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="Teilen"
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} />
Tour starten
</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"
>
Überspringen
</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 ? "Verstanden" : "Weiter"}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
Prompt für KI
Füg ihn in ChatGPT, Claude oder Cursor ein und der Baustein wird mit diesen Einstellungen an dein Projekt angepasst, auch ohne React.
Mehr aus Overlays
Wofür du ihn nutzen kannst
So eine Produkttour hilft, wenn du etwas Neues einführst und keine Hilfeseite schreiben willst: neue Shortcuts in einem Projekttool, der erste Besuch im Dashboard einer Buchungs-App oder ein überarbeiteter Editor. Der Lichtkegel gleitet von Button zu Button, und mit den Pfeiltasten geht es vor und zurück.
Drei oder vier Schritte reichen, danach klicken die meisten nur noch auf „Überspringen“. Im kopierten Code deckt der dunkle Schleier das ganze Fenster ab und folgt den Buttons beim Scrollen. Die Schritte sind Beispiele, richte sie auf deine eigenen Elemente aus.