01 · Proyectos
Lugares que se recuerdan.
Editoriales Vollbildmenü
Ein „Menü“-Button öffnet einen kreisrunden Vorhang, der die Farben umkehrt, und große Serifenlinks steigen nacheinander auf.
Einstellungen
Code
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { ArrowUpRight } from "lucide-react";
const EDITORIAL_LINKS = [
{ label: "Projekte", title: "Orte, die bleiben." },
{ label: "Studio", title: "Zwölf Menschen, ein langer Tisch." },
{ label: "Täglich", title: "Notizen aus der Werkstatt." },
{ label: "Kontakt", title: "Lass uns über deinen Raum sprechen." },
];
const EDITORIAL_EASE = [0.22, 1, 0.36, 1] as const;
interface EditorialMenuProps {
accent?: string;
stagger?: number;
brand?: string;
}
export default function EditorialMenu({
accent = "#ff4d00",
stagger = 0.06,
brand = "Estudio Norte",
}: EditorialMenuProps) {
const [open, setOpen] = useState(false);
const [active, setActive] = useState(0);
const [hover, setHover] = useState<number | null>(null);
const reduce = useReducedMotion();
const toggleRef = useRef<HTMLButtonElement>(null);
const firstLinkRef = useRef<HTMLButtonElement>(null);
const current = EDITORIAL_LINKS[active]!;
const origin = "at calc(100% - 58px) 40px";
useEffect(() => {
if (!open) return;
firstLinkRef.current?.focus({ preventScroll: true });
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setOpen(false);
toggleRef.current?.focus();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open]);
const go = (i: number) => {
setActive(i);
setOpen(false);
setHover(null);
toggleRef.current?.focus();
};
const onTop = open ? "text-[#f4f1ea] dark:text-[#141312]" : "";
return (
<div className="@container relative flex h-full min-h-[360px] w-full flex-col overflow-hidden bg-[#f4f1ea] text-[#141312] dark:bg-[#141312] dark:text-[#f4f1ea]">
<header className="relative z-20 flex items-center justify-between px-6 pt-5">
<span
className={
"font-serif text-[19px] italic tracking-tight transition-colors duration-500 " + onTop
}
>
{brand}
</span>
<motion.button
ref={toggleRef}
type="button"
aria-expanded={open}
aria-label={open ? "Menü schließen" : "Menü öffnen"}
onClick={() => setOpen((o) => !o)}
whileTap={{ scale: 0.94 }}
className={
"flex h-10 items-center gap-2.5 rounded-full border pl-4 pr-3.5 text-[13px] font-medium outline-none transition-colors duration-500 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-current " +
(open
? "border-white/20 text-[#f4f1ea] focus-visible:ring-offset-[#141312] dark:border-black/15 dark:text-[#141312] dark:focus-visible:ring-offset-[#f4f1ea]"
: "border-black/15 hover:border-black/40 focus-visible:ring-offset-[#f4f1ea] dark:border-white/20 dark:hover:border-white/50 dark:focus-visible:ring-offset-[#141312]")
}
>
<span className="relative block h-4 w-[3.25rem] overflow-hidden text-left">
<motion.span
className="absolute inset-x-0 top-0 flex flex-col leading-4"
animate={{ y: open ? -16 : 0 }}
transition={{ type: "spring", stiffness: 420, damping: 32 }}
>
<span>Menü</span>
<span>Schließen</span>
</motion.span>
</span>
<span className="relative grid h-3 w-4 place-items-center" aria-hidden>
<motion.span
className="absolute h-[1.5px] w-4 rounded-full bg-current"
animate={open ? { y: 0, rotate: 45 } : { y: -3, rotate: 0 }}
transition={{ type: "spring", stiffness: 420, damping: 28 }}
/>
<motion.span
className="absolute h-[1.5px] w-4 rounded-full bg-current"
animate={open ? { y: 0, rotate: -45 } : { y: 3, rotate: 0 }}
transition={{ type: "spring", stiffness: 420, damping: 28 }}
/>
</span>
</motion.button>
</header>
<main className="relative flex flex-1 flex-col justify-end px-6 pb-7">
<p className="mb-4 flex items-center gap-2 text-[11px] font-medium uppercase tracking-[0.22em] text-[#8a847a]">
<span className="h-1.5 w-1.5 rounded-full" style={{ backgroundColor: accent }} />
{"0" + (active + 1)} · {current.label}
</p>
<AnimatePresence mode="wait" initial={false}>
<motion.h2
key={active}
initial={{ opacity: 0, y: 20, filter: "blur(6px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={{ opacity: 0, y: -10, filter: "blur(6px)" }}
transition={{ duration: reduce ? 0 : 0.5, ease: EDITORIAL_EASE }}
className="max-w-[12ch] font-serif text-[clamp(34px,11cqw,64px)] leading-[0.98] tracking-[-0.02em]"
>
{current.title}
</motion.h2>
</AnimatePresence>
</main>
<AnimatePresence>
{open && (
<motion.nav
key="menu"
aria-label="Hauptmenü"
initial={{ clipPath: "circle(0% " + origin + ")" }}
animate={{ clipPath: "circle(150% " + origin + ")" }}
exit={{ clipPath: "circle(0% " + origin + ")" }}
transition={reduce ? { duration: 0 } : { duration: 0.75, ease: [0.76, 0, 0.24, 1] }}
className="absolute inset-0 z-10 flex flex-col justify-end bg-[#141312] px-6 pb-6 pt-24 text-[#f4f1ea] dark:bg-[#f4f1ea] dark:text-[#141312]"
>
<ul onPointerLeave={() => setHover(null)} className="mb-10">
{EDITORIAL_LINKS.map((link, i) => {
const dim = hover !== null && hover !== i;
return (
<li key={link.label} className="overflow-hidden">
<motion.button
ref={i === 0 ? firstLinkRef : undefined}
type="button"
onClick={() => go(i)}
onPointerEnter={() => setHover(i)}
onFocus={() => setHover(i)}
aria-current={active === i ? "page" : undefined}
initial={{ y: "105%" }}
animate={{ y: 0 }}
transition={{
duration: reduce ? 0 : 0.7,
ease: EDITORIAL_EASE,
delay: reduce ? 0 : 0.28 + i * stagger,
}}
className="group flex w-full items-center gap-4 py-1.5 text-left outline-none transition-opacity duration-300"
style={{ opacity: dim ? 0.3 : 1 }}
>
<span className="w-5 font-mono text-[11px] opacity-50">{"0" + (i + 1)}</span>
<span
className="font-serif text-[clamp(36px,11cqw,60px)] leading-[1.05] tracking-[-0.02em] transition-transform duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] group-hover:translate-x-2 group-focus-visible:translate-x-2"
style={{
color: active === i ? accent : undefined,
fontStyle: active === i ? "italic" : undefined,
}}
>
{link.label}
</span>
<ArrowUpRight
className="ml-auto h-6 w-6 -translate-x-2 opacity-0 transition-all duration-300 group-hover:translate-x-0 group-hover:opacity-70 group-focus-visible:translate-x-0 group-focus-visible:opacity-70"
strokeWidth={1.5}
/>
</motion.button>
</li>
);
})}
</ul>
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.4,
delay: reduce ? 0 : 0.4 + EDITORIAL_LINKS.length * stagger,
}}
className="flex items-end justify-between border-t border-current/15 pt-4 text-[12px] opacity-60"
>
<span>hola@estudionorte.es</span>
<span>Valencia · Lisboa</span>
</motion.div>
</motion.nav>
)}
</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 Navigation
Wofür du ihn nutzen kannst
Dieses Vollbildmenü in React passt zu Websites mit wenigen Seiten und viel Charakter: ein Architekturbüro, das Portfolio einer Innenarchitektin, ein kleines Magazin. Vier oder fünf Links in riesiger Serifenschrift lesen sich wie ein Inhaltsverzeichnis, und beim Überfahren treten die anderen zurück. Die Auswahl wirkt ruhig und bewusst.
Am besten funktioniert es mit kurzen Linknamen, lange brechen um und verlieren die Wirkung. Trag zuerst deinen Markennamen und die Akzentfarbe ein, dann spiel mit der Verzögerung der Kaskade. Escape schließt das Menü und der Fokus springt zurück auf den Button.