Full Screen Overlay Menu with Giant Links and Preview Panel

A full screen navigation overlay for a studio or portfolio site. Pressing Menu grows a dark circle out of the button until it covers the page, then five very large serif links rise into place one after another. Hovering or focusing a link brings it to full strength, draws a short line in front of it and wipes a matching picture up over the previous one in the preview panel, with a caption that swaps underneath. The preview plays through the links by itself until a visitor takes over. On phones the links shrink to fit and the preview becomes a wide strip below them.

Code

import { useEffect, useRef, useState } from "react";
import type { KeyboardEvent } from "react";
import { AnimatePresence, motion, useInView, useReducedMotion } from "framer-motion";

// The menu. `scene` picks one of the drawn previews below; swap them for your own images.
const links = [
  { label: "Work", note: "42 buildings", scene: "arch", caption: "Salt House, Whitby", meta: "Residential, 2024" },
  { label: "Studio", note: "19 people", scene: "window", caption: "The drawing room", meta: "Second floor, Calder Yard" },
  { label: "Journal", note: "New essay", scene: "paper", caption: "On building less", meta: "8 minute read" },
  { label: "Careers", note: "3 open roles", scene: "stairs", caption: "Project architect", meta: "Leeds or remote" },
  { label: "Contact", note: "Say hello", scene: "door", caption: "Knock any weekday", meta: "9 to 5, kettle is on" },
] as const;

const DEMO_MS = 1500; // time on each link while the preview plays on its own
const EASE = [0.76, 0, 0.24, 1] as const; // slow start, fast middle, soft landing
const WIPE = 0.75; // seconds the preview picture takes to wipe in

/** Five small drawn scenes built from plain shapes, so the demo needs no image files. */
function Scene({ kind }: { kind: (typeof links)[number]["scene"] }) {
  if (kind === "arch")
    return (
      <div className="absolute inset-0 bg-gradient-to-b from-[#f4c9a0] to-[#e9a273]">
        <span className="absolute right-[14%] top-[12%] h-16 w-16 rounded-full bg-[#fff1dc]" />
        <span className="absolute bottom-0 left-[12%] h-[68%] w-[52%] rounded-t-full bg-[#b5432a]" />
        <span className="absolute bottom-0 left-[26%] h-[44%] w-[24%] rounded-t-full bg-[#5c1c12]" />
        <span className="absolute bottom-0 right-0 h-[38%] w-[30%] bg-[#d6693f]" />
        <span className="absolute bottom-0 left-0 h-[7%] w-full bg-[#3a120c]" />
      </div>
    );
  if (kind === "window")
    return (
      <div className="absolute inset-0 bg-[#56603a]">
        <span className="absolute left-[16%] top-[14%] h-[52%] w-[68%] bg-gradient-to-b from-[#f6ead2] to-[#e7c98f]" />
        <span className="absolute left-[49.5%] top-[14%] h-[52%] w-[1%] bg-[#56603a]" />
        <span className="absolute left-[16%] top-[39%] h-[1%] w-[68%] bg-[#56603a]" />
        <span className="absolute left-[48%] top-0 h-[20%] w-px bg-[#1f2414]" />
        <span className="absolute left-[40%] top-[20%] h-10 w-[18%] rounded-b-full bg-[#e7572f]" />
        <span className="absolute bottom-[14%] left-[8%] h-[3%] w-[84%] bg-[#1f2414]" />
        <span className="absolute bottom-0 left-[14%] h-[14%] w-[2%] bg-[#1f2414]" />
        <span className="absolute bottom-0 right-[14%] h-[14%] w-[2%] bg-[#1f2414]" />
      </div>
    );
  if (kind === "paper")
    return (
      <div className="absolute inset-0 bg-[#3d2440]">
        <span className="absolute left-[20%] top-[20%] h-[62%] w-[58%] -rotate-12 bg-[#cdb9a0]" />
        <span className="absolute left-[22%] top-[18%] h-[62%] w-[58%] -rotate-3 bg-[#e6d6bf]" />
        <span className="absolute left-[21%] top-[19%] h-[62%] w-[58%] rotate-6 bg-[#f7efdf]">
          <span className="absolute left-[12%] top-[12%] h-3 w-[46%] bg-[#e7572f]" />
          {[30, 40, 50, 60, 70].map((t) => (
            <span key={t} className="absolute left-[12%] h-[2px] w-[72%] bg-[#3d2440]/40" style={{ top: `${t}%` }} />
          ))}
        </span>
      </div>
    );
  if (kind === "stairs")
    return (
      <div className="absolute inset-0 bg-gradient-to-b from-[#1f2f4f] to-[#3c5479]">
        <span className="absolute left-[16%] top-[13%] h-12 w-12 rounded-full bg-[#f6ead2]" />
        <span className="absolute left-[21%] top-[11%] h-12 w-12 rounded-full bg-[#223355]" />
        {[0, 1, 2, 3, 4, 5].map((i) => (
          <span key={i} className="absolute bottom-0 bg-[#f0b98a]" style={{ left: `${i * 16.7}%`, width: "17%", height: `${12 + i * 12}%`, opacity: 0.55 + i * 0.09 }} />
        ))}
        <span className="absolute bottom-[72%] right-[5%] h-[14%] w-[7%] rounded-t-full bg-[#e7572f]" />
      </div>
    );
  return (
    <div className="absolute inset-0 bg-[#e8d5b5]">
      <span className="absolute bottom-0 left-0 h-[10%] w-full bg-[#b99b6e]" />
      <span className="absolute bottom-[10%] left-[30%] h-[70%] w-[40%] rounded-t-full bg-[#e7572f]" />
      <span className="absolute bottom-[10%] left-[35%] h-[60%] w-[30%] rounded-t-full border-2 border-[#a8341a]" />
      <span className="absolute bottom-[40%] left-[61%] h-3 w-3 rounded-full bg-[#2a120d]" />
      <span className="absolute bottom-[30%] left-[42%] h-[2.5%] w-[16%] bg-[#2a120d]" />
      <span className="absolute bottom-0 left-[70%] h-[10%] w-[26%] -skew-x-[40deg] bg-[#8f7447]" />
    </div>
  );
}

export default function FullScreenOverlayMenuWithGiantLinksAndPreviewPanel() {
  const reduce = useReducedMotion();
  const root = useRef<HTMLDivElement>(null);
  const first = useRef<HTMLAnchorElement>(null);
  const inView = useInView(root, { amount: 0.3 });
  const [open, setOpen] = useState(true);
  const [active, setActive] = useState(0);
  const [demo, setDemo] = useState(true);

  useEffect(() => {
    if (!demo || reduce || !open || !inView) return;
    const id = window.setInterval(() => setActive((a) => (a + 1) % links.length), DEMO_MS);
    return () => window.clearInterval(id);
  }, [demo, reduce, open, inView]);

  const pick = (i: number) => {
    setDemo(false);
    setActive(i);
  };
  const toggle = () => {
    setDemo(false);
    setOpen((o) => !o);
  };
  const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
    if (e.key === "Escape" && open) toggle();
  };

  const link = links[active];
  const origin = "calc(100% - 52px) 44px"; // centre of the menu button: the overlay grows out of it
  return (
    <div ref={root} onKeyDown={onKeyDown} className="relative h-[720px] overflow-hidden bg-[#f3ece0] text-[#2a120d]">
      {/* The page under the menu. */}
      <div aria-hidden={open} className="mx-auto flex h-full max-w-6xl flex-col px-6 sm:px-10">
        <div className="flex h-[88px] items-center">
          <a href="#" tabIndex={open ? -1 : 0} className="rounded font-serif text-xl tracking-tight focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#e7572f]">
            Ostrava &amp; Vale
          </a>
        </div>
        <div className="flex flex-1 flex-col justify-center pb-16">
          <p className="font-mono text-xs uppercase tracking-[0.25em] text-[#a8341a]">Architects, Leeds</p>
          <h1 className="mt-5 max-w-3xl font-serif text-5xl leading-[1.02] tracking-tight sm:text-7xl">Buildings that sit quietly in loud places.</h1>
          <p className="mt-6 max-w-md text-base text-[#5b3a30]">Homes, small schools and one very stubborn lighthouse, drawn by hand before they are drawn by anything else.</p>
        </div>
      </div>

      <AnimatePresence>
        {open && (
          <motion.div
            id="overlay-menu"
            key="overlay"
            className="absolute inset-0 z-10 bg-[#1c0d0a] text-[#f3ece0]"
            initial={reduce ? false : { clipPath: `circle(0% at ${origin})` }}
            animate={{ clipPath: `circle(150% at ${origin})` }}
            exit={reduce ? { opacity: 0 } : { clipPath: `circle(0% at ${origin})`, transition: { duration: 0.55, ease: EASE } }}
            transition={{ duration: reduce ? 0 : 0.8, ease: EASE }}
          >
            <div aria-hidden="true" className="pointer-events-none absolute -left-40 bottom-[-220px] h-[520px] w-[520px] rounded-full bg-[#e7572f]/20 blur-3xl" />
            <div className="relative mx-auto flex h-full max-w-6xl flex-col px-6 sm:px-10">
              <div className="flex h-[88px] shrink-0 items-center">
                <span className="font-serif text-xl tracking-tight">Ostrava &amp; Vale</span>
              </div>

              <div className="flex min-h-0 flex-1 flex-col gap-5 lg:flex-row lg:items-center lg:gap-16">
                <nav aria-label="Site menu" className="lg:flex-1">
                  <ul>
                    {links.map((l, i) => {
                      const on = active === i;
                      return (
                        <li key={l.label} className="overflow-hidden">
                          <motion.div initial={reduce ? false : { y: "115%", rotate: 4 }} animate={{ y: 0, rotate: 0 }} transition={reduce ? { duration: 0 } : { delay: 0.22 + i * 0.07, duration: 0.8, ease: [0.16, 1, 0.3, 1] }} style={{ transformOrigin: "left bottom" }}>
                            <a
                              ref={i === 0 ? first : undefined}
                              href="#"
                              aria-current={on ? "true" : undefined}
                              onMouseEnter={() => pick(i)}
                              onFocus={(e) => e.target.matches(":focus-visible") && pick(i)}
                              onClick={(e) => {
                                e.preventDefault();
                                pick(i);
                              }}
                              className="group flex items-baseline gap-3 rounded-lg py-1 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[#e7572f] sm:gap-5 sm:py-0"
                            >
                              <span className={`w-6 shrink-0 font-mono text-xs transition-colors duration-300 ${on ? "text-[#ff8b66]" : "text-[#f3ece0]/60"}`}>0{i + 1}</span>
                              <span className={`flex items-center font-serif text-[46px] leading-[1.12] tracking-tight transition-[opacity,transform] duration-500 ease-out motion-reduce:transition-none sm:text-[76px] sm:leading-[1.1] ${on ? "translate-x-0 opacity-100" : "opacity-60"}`}>
                                <span aria-hidden="true" className={`block h-[3px] rounded-full bg-[#e7572f] transition-all duration-500 ease-out motion-reduce:transition-none ${on ? "mr-4 w-8 sm:mr-6 sm:w-16" : "mr-0 w-0"}`} />
                                {l.label}
                              </span>
                              <span className={`hidden font-mono text-xs uppercase tracking-[0.2em] text-[#ff8b66] transition-all duration-500 motion-reduce:transition-none sm:block ${on ? "translate-x-0 opacity-100" : "-translate-x-3 opacity-0"}`}>{l.note}</span>
                            </a>
                          </motion.div>
                        </li>
                      );
                    })}
                  </ul>
                </nav>

                {/* Preview: the new picture wipes up over the old one, which stays put underneath until it is covered. */}
                <motion.div className="relative lg:w-[360px] lg:shrink-0" initial={reduce ? false : { opacity: 0, y: 40 }} animate={{ opacity: 1, y: 0 }} transition={reduce ? { duration: 0 } : { delay: 0.45, duration: 0.8, ease: [0.16, 1, 0.3, 1] }}>
                  <div className="relative h-[150px] overflow-hidden rounded-2xl lg:h-[440px] lg:rounded-[28px]">
                    <AnimatePresence initial={false}>
                      <motion.div
                        key={link.scene}
                        className="absolute inset-0"
                        style={{ zIndex: active + 1 }}
                        initial={reduce ? { opacity: 0 } : { clipPath: "inset(100% 0% 0% 0%)" }}
                        animate={reduce ? { opacity: 1 } : { clipPath: "inset(0% 0% 0% 0%)" }}
                        exit={{ opacity: 0.99, zIndex: 0, transition: { duration: WIPE } }}
                        transition={{ duration: reduce ? 0 : WIPE, ease: EASE }}
                      >
                        <motion.div className="absolute inset-0" initial={reduce ? false : { scale: 1.25, y: 30 }} animate={{ scale: 1, y: 0 }} transition={{ duration: reduce ? 0 : WIPE + 0.3, ease: [0.16, 1, 0.3, 1] }}>
                          <Scene kind={link.scene} />
                        </motion.div>
                      </motion.div>
                    </AnimatePresence>
                    <div className="absolute left-4 top-4 z-10 rounded-full bg-[#1c0d0a]/80 px-3 py-1 font-mono text-[11px] tabular-nums text-[#f3ece0]">
                      0{active + 1} / 0{links.length}
                    </div>
                  </div>
                  <div className="mt-3 h-11 overflow-hidden lg:mt-4">
                    <AnimatePresence mode="wait" initial={false}>
                      <motion.div key={link.scene} className="flex items-baseline justify-between gap-4" initial={reduce ? false : { y: 18, opacity: 0 }} animate={{ y: 0, opacity: 1 }} exit={reduce ? { opacity: 0 } : { y: -18, opacity: 0 }} transition={{ duration: reduce ? 0 : 0.25 }}>
                        <p className="font-serif text-xl">{link.caption}</p>
                        <p className="shrink-0 text-right text-xs text-[#f3ece0]/75">{link.meta}</p>
                      </motion.div>
                    </AnimatePresence>
                  </div>
                </motion.div>
              </div>

              <motion.div className="flex shrink-0 flex-wrap items-center justify-between gap-x-6 gap-y-1 border-t border-[#f3ece0]/15 py-5 text-sm text-[#f3ece0]/75" initial={reduce ? false : { opacity: 0 }} animate={{ opacity: 1 }} transition={reduce ? { duration: 0 } : { delay: 0.8, duration: 0.6 }}>
                <p>Calder Yard, 14 Tannery Row, Leeds</p>
                <ul className="flex gap-5">
                  {["Newsletter", "Press kit", "Imprint"].map((s) => (
                    <li key={s}>
                      <a href="#" className="inline-flex h-10 items-center rounded hover:text-[#f3ece0] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#e7572f]">
                        {s}
                      </a>
                    </li>
                  ))}
                </ul>
              </motion.div>
            </div>
          </motion.div>
        )}
      </AnimatePresence>

      {/* One button for both states, kept above the overlay so it never moves under the pointer. */}
      <div className="pointer-events-none absolute inset-x-0 top-0 z-20 mx-auto flex h-[88px] max-w-6xl items-center justify-end px-6 sm:px-10">
        <button type="button" aria-expanded={open} aria-controls="overlay-menu" onClick={toggle} className={`pointer-events-auto flex h-11 items-center gap-3 rounded-full pl-5 pr-4 text-sm font-medium transition-colors duration-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#e7572f] focus-visible:ring-offset-2 focus-visible:ring-offset-transparent motion-reduce:transition-none ${open ? "bg-[#f3ece0] text-[#1c0d0a]" : "bg-[#1c0d0a] text-[#f3ece0]"}`}>
          <span className="relative block h-5 w-11 overflow-hidden">
            <span className={`absolute inset-0 transition-transform duration-500 ease-out motion-reduce:transition-none ${open ? "-translate-y-full" : ""}`}>Menu</span>
            <span className={`absolute inset-0 transition-transform duration-500 ease-out motion-reduce:transition-none ${open ? "" : "translate-y-full"}`}>Close</span>
          </span>
          <span aria-hidden="true" className="relative block h-4 w-4">
            <span className={`absolute left-0 top-1/2 h-[2px] w-4 rounded-full bg-current transition-transform duration-500 ease-out motion-reduce:transition-none ${open ? "rotate-45" : "-translate-y-[4px]"}`} />
            <span className={`absolute left-0 top-1/2 h-[2px] w-4 rounded-full bg-current transition-transform duration-500 ease-out motion-reduce:transition-none ${open ? "-rotate-45" : "translate-y-[4px]"}`} />
          </span>
        </button>
      </div>
    </div>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/full-screen-overlay-menu-with-giant-links-and-preview-panel.json

When to use it

How to customize it

Accessibility

Questions

How does the overlay grow out of the button?

The overlay always covers the whole frame, but it is clipped with `clip-path: circle()`. The circle is centred on the button and animates from 0% to 150% radius, which is large enough to reach the far corner. Closing runs the same animation backwards.

How do the links rise in without overlapping each other?

Each list item has `overflow-hidden`, and the link inside starts shifted down by 115% of its own height with a slight tilt. As it slides up it is revealed by its own row only, so it looks like it comes up from behind a line.

How does the preview picture wipe over the old one?

The new picture is clipped with `clip-path: inset()` and animates from fully cut off at the top to fully shown, while its contents scale down from 125% for a little depth. The old picture stays mounted underneath until the wipe ends, then is removed.

Why does the menu move through the links by itself?

That is only the demo, so the preview is alive before anyone touches it. It stops for good on the first hover, focus or click. Set the `demo` state to start as `false` to remove it.

Should I trap focus inside the overlay?

On a real page, yes. This demo keeps the page behind out of the tab order, which covers its one link. With a full page behind it, add the `inert` attribute to your main content while the menu is open.

Related: Menu Links with Rolling Letter Swap on Hover, Mega Menu Navbar with Morphing Dropdown Panel, Floating Navbar That Shrinks to a Pill on Scroll.

More navbars or all free UI components. Start for free to have a full site built around it.

More in Navbars

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap