01 · Proyectos
Lugares que se recuerdan.
Menú editorial a pantalla completa
Un botón «Menú» abre un telón circular que invierte los colores, y los enlaces en serif grande suben uno tras otro.
Ajustes
Código
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { ArrowUpRight } from "lucide-react";
const EDITORIAL_LINKS = [
{ label: "Proyectos", title: "Lugares que se recuerdan." },
{ label: "Estudio", title: "Doce personas, una mesa larga." },
{ label: "Diario", title: "Notas desde el taller." },
{ label: "Contacto", title: "Hablemos de tu espacio." },
];
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 ? "Cerrar menú" : "Abrir menú"}
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>Cerrar</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="Principal"
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 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 Navegación
Dónde usarlo
Este menú a pantalla completa en React le va a webs con pocas páginas y mucha personalidad: un estudio de arquitectura, el portfolio de una interiorista, una revista pequeña. Cuatro o cinco enlaces en serif enorme se leen como un índice, y al pasar por uno los demás se apagan, así que elegir se siente tranquilo.
Funciona mejor con nombres cortos; los largos saltan de línea y pierden gracia. Empieza poniendo tu marca y tu color, y luego ajusta el retardo de la cascada. Escape lo cierra y el foco vuelve al botón, así que con teclado también se usa bien.