Estudio Norte

01 · Proyectos

Lugares que se recuerdan.

React + MotionNavigation

Editorial fullscreen menu

A “Menu” button opens a circular curtain that flips the colours, and big serif links rise one after another.

Settings

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: "Projects", title: "Places worth remembering." },
  { label: "Studio", title: "Twelve people, one long table." },
  { label: "Daily", title: "Notes from the workshop." },
  { label: "Contact", title: "Let’s talk about your space." },
];

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 ? "Close menu" : "Open 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>Close</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="Main"
            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 for AI

Paste it into ChatGPT, Claude or Cursor and it will adapt the block to your project with these settings, even if you don’t use React.

More in Navigation

Where to use it

This fullscreen menu in React suits sites with few pages and a lot of personality: an architecture studio, an interior designer’s portfolio, a small magazine. Four or five links set huge in serif read like a table of contents, and hovering one dims the rest, so the choice feels calm and deliberate.

It works best with short link names; long ones wrap and lose the effect. Start by setting your brand name and accent, then play with the cascade delay. Escape closes it and focus goes back to the button, so keyboard users are covered.