# Fullscreen Menu

> A full-screen navigation overlay with a morphing hamburger, staggered links, scroll lock and focus trap.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/fullscreen-menu
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/fullscreen-menu.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

No extra dependencies.

## Usage

```tsx title="components/fullscreen-menu-demo.tsx"
"use client"

import { ArrowUpRight } from "lucide-react"

import { FullscreenMenu } from "@/components/fullscreen-menu"

const links = [
  { label: "Work", href: "#work" },
  { label: "Studio", href: "#studio" },
  { label: "Journal", href: "#journal" },
  { label: "Contact", href: "#contact" },
]

const secondaryLinks = [
  { label: "Careers", href: "#careers" },
  { label: "Press kit", href: "#press" },
  { label: "Newsletter", href: "#newsletter" },
  { label: "Privacy", href: "#privacy" },
]

export default function FullscreenMenuDemo() {
  return (
    <div className="mx-auto flex min-h-[440px] w-full max-w-3xl flex-col rounded-2xl border bg-card">
      <header className="flex items-center justify-between gap-4 border-b px-5 py-3">
        <span className="flex items-center gap-2 text-sm font-semibold tracking-tight">
          <span className="size-5 rounded-md bg-primary" aria-hidden /> Northwind Studio
        </span>
        <FullscreenMenu links={links} secondaryLinks={secondaryLinks} label="Northwind navigation" footer="hello@northwind.studio" />
      </header>
      <div className="flex flex-1 flex-col justify-center gap-5 px-5 py-10 sm:px-10">
        <p className="text-xs font-medium tracking-widest text-muted-foreground uppercase">Independent design studio</p>
        <h3 className="max-w-lg text-3xl leading-tight font-semibold tracking-tight sm:text-5xl">We shape brands people remember.</h3>
        <p className="max-w-md text-sm text-muted-foreground">
          Open the menu to see a full-screen navigation with staggered links. Page scrolling is locked while it is open; press Esc to close.
        </p>
        <a
          href="#work"
          className="inline-flex w-fit items-center gap-1.5 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90 focus-visible:ring-4 focus-visible:ring-ring/40 focus-visible:outline-none"
        >
          See our work <ArrowUpRight className="size-4" aria-hidden />
        </a>
      </div>
    </div>
  )
}
```

## Source

```tsx title="components/fullscreen-menu.tsx"
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"

export interface FullscreenMenuLink {
  label: string
  href: string
}

export interface FullscreenMenuProps extends Omit<React.ComponentProps<"div">, "children"> {
  links: FullscreenMenuLink[]
  secondaryLinks?: FullscreenMenuLink[]
  /** Accessible name of the overlay */
  label?: string
  /** Small text shown at the bottom of the overlay */
  footer?: React.ReactNode
  onOpenChange?: (open: boolean) => void
}

const focusable = 'a[href], button:not(:disabled), [tabindex]:not([tabindex="-1"])'

// The hamburger stays above the overlay and morphs into an X, so it doubles as the close button.
// The overlay is fixed to the viewport, so place the component in a header without transforms.
export function FullscreenMenu({
  links,
  secondaryLinks = [],
  label = "Site navigation",
  footer,
  onOpenChange,
  className,
  ...props
}: FullscreenMenuProps) {
  const [open, setOpenState] = React.useState(false)
  const root = React.useRef<HTMLDivElement>(null)
  const button = React.useRef<HTMLButtonElement>(null)
  const overlayId = React.useId()

  const setOpen = React.useCallback(
    (next: boolean) => {
      setOpenState(next)
      onOpenChange?.(next)
    },
    [onOpenChange]
  )

  const close = React.useCallback(() => {
    setOpen(false)
    button.current?.focus()
  }, [setOpen])

  React.useEffect(() => {
    if (!open) return
    root.current?.querySelector<HTMLElement>("nav a[href]")?.focus()

    const previous = document.body.style.overflow
    document.body.style.overflow = "hidden"

    const onKeyDown = (event: KeyboardEvent) => {
      if (event.key === "Escape") close()
    }
    const onFocusIn = (event: FocusEvent) => {
      if (!root.current?.contains(event.target as Node)) root.current?.querySelector<HTMLElement>("nav a[href]")?.focus()
    }
    document.addEventListener("keydown", onKeyDown)
    document.addEventListener("focusin", onFocusIn)
    return () => {
      document.body.style.overflow = previous
      document.removeEventListener("keydown", onKeyDown)
      document.removeEventListener("focusin", onFocusIn)
    }
  }, [open, close])

  function onKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
    if (event.key !== "Tab" || !open) return
    const items = Array.from(event.currentTarget.querySelectorAll<HTMLElement>(focusable))
    if (items.length === 0) return
    const first = items[0]
    const last = items[items.length - 1]
    if (event.shiftKey && document.activeElement === first) {
      event.preventDefault()
      last.focus()
    } else if (!event.shiftKey && document.activeElement === last) {
      event.preventDefault()
      first.focus()
    }
  }

  const bar = "absolute top-1/2 left-1/2 -mt-px h-0.5 w-5 -translate-x-1/2 rounded-full bg-current transition-[translate,rotate,opacity] duration-300 ease-out motion-reduce:transition-none"

  return (
    <div ref={root} onKeyDown={onKeyDown} className={cn("relative z-50", className)} {...props}>
      <button
        ref={button}
        type="button"
        aria-label={open ? "Close menu" : "Open menu"}
        aria-expanded={open}
        aria-controls={overlayId}
        onClick={() => (open ? close() : setOpen(true))}
        className="relative z-10 flex size-11 items-center justify-center rounded-full text-foreground transition-colors hover:bg-accent focus-visible:ring-4 focus-visible:ring-ring/40 focus-visible:outline-none"
      >
        <span className="relative block size-5" aria-hidden>
          <span className={cn(bar, open ? "translate-y-0 rotate-45" : "-translate-y-1.5")} />
          <span className={cn(bar, open ? "opacity-0" : "opacity-100")} />
          <span className={cn(bar, open ? "translate-y-0 -rotate-45" : "translate-y-1.5")} />
        </span>
      </button>

      <div
        id={overlayId}
        role="dialog"
        aria-modal="true"
        aria-label={label}
        aria-hidden={!open}
        className={cn(
          "fixed inset-0 z-0 flex flex-col overflow-y-auto bg-background text-foreground",
          "transition-[clip-path,visibility] duration-500 ease-[cubic-bezier(.77,0,.18,1)] motion-reduce:transition-none",
          open ? "visible [clip-path:inset(0_0_0_0)]" : "invisible [clip-path:inset(0_0_100%_0)]"
        )}
      >
        <div className="mx-auto flex w-full max-w-5xl flex-1 flex-col justify-center gap-10 px-6 pt-24 pb-10 sm:px-10">
          <nav aria-label="Primary">
            <ul className="grid gap-1 sm:gap-2">
              {links.map((link, index) => (
                <li key={link.label} className="overflow-hidden">
                  <a
                    href={link.href}
                    tabIndex={open ? 0 : -1}
                    onClick={() => close()}
                    style={{ "--delay": `${open ? 150 + index * 70 : 0}ms` } as React.CSSProperties}
                    className={cn(
                      "group flex items-baseline gap-4 rounded-lg py-1 text-4xl font-semibold tracking-tight transition-[translate,opacity,color] duration-500 ease-out [transition-delay:var(--delay)] hover:text-primary focus-visible:ring-4 focus-visible:ring-ring/40 focus-visible:outline-none motion-reduce:transition-none motion-reduce:[transition-delay:0ms] sm:text-6xl",
                      open ? "translate-y-0 opacity-100" : "translate-y-full opacity-0"
                    )}
                  >
                    <span className="w-8 shrink-0 font-mono text-xs font-normal text-muted-foreground tabular-nums sm:text-sm">
                      {String(index + 1).padStart(2, "0")}
                    </span>
                    <span className="min-w-0 truncate transition-transform duration-300 group-hover:translate-x-2 motion-reduce:transition-none">{link.label}</span>
                  </a>
                </li>
              ))}
            </ul>
          </nav>

          {(secondaryLinks.length > 0 || footer) && (
            <div
              style={{ "--delay": `${open ? 150 + links.length * 70 : 0}ms` } as React.CSSProperties}
              className={cn(
                "flex flex-col gap-4 border-t pt-6 transition-[translate,opacity] duration-500 ease-out [transition-delay:var(--delay)] motion-reduce:transition-none motion-reduce:[transition-delay:0ms] sm:flex-row sm:items-center sm:justify-between",
                open ? "translate-y-0 opacity-100" : "translate-y-4 opacity-0"
              )}
            >
              {secondaryLinks.length > 0 && (
                <nav aria-label="Secondary">
                  <ul className="flex flex-wrap gap-x-6 gap-y-2">
                    {secondaryLinks.map((link) => (
                      <li key={link.label}>
                        <a
                          href={link.href}
                          tabIndex={open ? 0 : -1}
                          onClick={() => close()}
                          className="rounded-md text-sm text-muted-foreground transition-colors hover:text-foreground focus-visible:ring-4 focus-visible:ring-ring/40 focus-visible:outline-none"
                        >
                          {link.label}
                        </a>
                      </li>
                    ))}
                  </ul>
                </nav>
              )}
              {footer && <p className="text-sm text-muted-foreground">{footer}</p>}
            </div>
          )}
        </div>
      </div>
    </div>
  )
}
```
