Estudio Norte

01 · Proyectos

Lugares que se recuerdan.

React + MotionNavigation

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

0.06s

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.