Estudio Norte

01 · Proyectos

Lugares que se recuerdan.

React + MotionNavegación

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

0.06s

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.