01 · Proyectos
Lugares que se recuerdan.
Menu plein écran éditorial
Un bouton « Menu » ouvre un rideau circulaire qui inverse les couleurs, et de grands liens à empattements montent l’un après l’autre.
Réglages
Code
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { ArrowUpRight } from "lucide-react";
const EDITORIAL_LINKS = [
{ label: "Projets", title: "Des lieux qui marquent." },
{ label: "Studio", title: "Douze personnes, une grande tablée." },
{ label: "Quotidien", title: "Notes de l’atelier." },
{ label: "Contact", title: "Parlons de votre espace." },
];
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 ? "Fermer le menu" : "Ouvrir le menu"}
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>Menu</span>
<span>Fermer</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 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 Navigation
Où l’utiliser
Ce menu plein écran en React convient aux sites qui ont peu de pages et beaucoup de caractère : un cabinet d’architecture, le portfolio d’une architecte d’intérieur, un petit magazine. Quatre ou cinq liens en très grand corps se lisent comme un sommaire, et au survol les autres s’estompent. Le choix paraît posé.
Il fonctionne mieux avec des intitulés courts ; les longs passent à la ligne et perdent leur effet. Indiquez d’abord le nom de votre marque et votre couleur, puis réglez le délai de la cascade. Échap le referme et le focus revient sur le bouton.