# Fullscreen Menu

A navigation menu that covers the whole screen, for site links on small viewports.

Fullscreen Menu is an open-source component from suiss/ui for React. The @esuiss/ui CLI copies its source into your project, so you own the code.

Source: https://ui.suiss.app/docs/components/fullscreen-menu

**React**

**Example**

```tsx
import * as React from "react"
import {
  FullscreenMenu,
  FullscreenMenuContent,
  FullscreenMenuGroup,
  FullscreenMenuLabel,
  FullscreenMenuLink,
  FullscreenMenuTitle,
  FullscreenMenuTrigger,
} from "@/components/ui/fullscreen-menu"

const primary = ["Store", "Products", "Services", "Company", "Support"]

const sections = [
  { label: "Explore", links: ["Overview", "Compare", "Accessories"] },
  { label: "Shop", links: ["Shop online", "Find a store", "Order status"] },
]

function MenuExample({
  defaultOpen,
  closeLabel,
  withSections = true,
}: {
  defaultOpen?: boolean
  closeLabel?: string
  withSections?: boolean
}) {
  const [active, setActive] = React.useState("Store")

  return (
    <div className="flex h-14 items-center justify-between px-4">
      <span className="font-semibold">Acme</span>
      <FullscreenMenu defaultOpen={defaultOpen}>
        <FullscreenMenuTrigger aria-label="Menu" />
        <FullscreenMenuContent closeLabel={closeLabel}>
          <FullscreenMenuTitle>Menu</FullscreenMenuTitle>
          <FullscreenMenuGroup>
            {primary.map((link) => (
              <FullscreenMenuLink
                key={link}
                href={`#${link.toLowerCase()}`}
                isActive={active === link}
                onClick={() => setActive(link)}
              >
                {link}
              </FullscreenMenuLink>
            ))}
          </FullscreenMenuGroup>
          {withSections &&
            sections.map((section) => (
              <FullscreenMenuGroup key={section.label}>
                <FullscreenMenuLabel>{section.label}</FullscreenMenuLabel>
                {section.links.map((link) => (
                  <FullscreenMenuLink
                    key={link}
                    href={`#${link.toLowerCase().replaceAll(" ", "-")}`}
                  >
                    {link}
                  </FullscreenMenuLink>
                ))}
              </FullscreenMenuGroup>
            ))}
        </FullscreenMenuContent>
      </FullscreenMenu>
    </div>
  )
}

export default function FullscreenMenuDemo() {
  return <MenuExample />
}
```

Fullscreen menu is built on the dialog primitive of the chosen base. The `FullscreenMenu` root manages the open state, and `FullscreenMenuTrigger` renders a menu button whose two-line icon animates into a close icon. `FullscreenMenuContent` covers the viewport and reveals from the top, with a sticky header that holds the close button. Inside it, `FullscreenMenuTitle` names the menu, `FullscreenMenuGroup` holds a list of `FullscreenMenuLink` items, and `FullscreenMenuLabel` adds a caption to a group. Links and labels fade in with a staggered delay as the menu opens.

Use it for the primary navigation of a website on phones, like the global navigation on apple.com. For a short list of actions, use a dropdown menu; for a panel that keeps part of the page visible, use a drawer or sheet.

## Installation

### CLI

```bash title="Terminal"
npx shadcn@latest add @esuiss/fullscreen-menu
```

Works in any project with a `components.json`. See [Installation](https://ui.suiss.app/docs/installation#add-components) for the one-line registry setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add fullscreen-menu
```

### Manual

1. Install the dependencies and add the [`cn` helper](https://ui.suiss.app/docs/installation#the-cn-helper):

```bash title="Terminal"
npm install @base-ui/react
```

2. Add the pieces it builds on: [Button](https://ui.suiss.app/docs/components/button).

3. Copy the source into `components/ui/fullscreen-menu.tsx`:

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

import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"

type FullscreenMenuProps = {
  open?: boolean
  defaultOpen?: boolean
  onOpenChange?: (open: boolean) => void
}

type FullscreenMenuContextProps = {
  setOpen: (open: boolean) => void
}

const FullscreenMenuContext =
  React.createContext<FullscreenMenuContextProps | null>(null)

function useFullscreenMenu() {
  const context = React.useContext(FullscreenMenuContext)

  if (!context) {
    throw new Error(
      "useFullscreenMenu must be used within a <FullscreenMenu />"
    )
  }

  return context
}

function FullscreenMenu({
  open: openProp,
  defaultOpen = false,
  onOpenChange,
  ...props
}: Omit<DialogPrimitive.Root.Props, "open" | "defaultOpen" | "onOpenChange"> &
  FullscreenMenuProps) {
  const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen)
  const open = openProp ?? uncontrolledOpen

  const setOpen = React.useCallback(
    (next: boolean) => {
      if (openProp === undefined) setUncontrolledOpen(next)
      onOpenChange?.(next)
    },
    [openProp, onOpenChange]
  )

  const contextValue = React.useMemo(() => ({ setOpen }), [setOpen])

  return (
    <FullscreenMenuContext.Provider value={contextValue}>
      <DialogPrimitive.Root
        data-slot="fullscreen-menu"
        open={open}
        onOpenChange={(next) => setOpen(next)}
        {...props}
      />
    </FullscreenMenuContext.Provider>
  )
}

function FullscreenMenuIcon({ className }: { className?: string }) {
  return (
    <span
      aria-hidden
      data-slot="fullscreen-menu-icon"
      className={cn(
        "relative size-4.5 *:absolute *:inset-x-0 *:top-1/2 *:-mt-px *:h-0.5 *:rounded-full *:bg-current *:transition-[translate,rotate] *:duration-500 *:ease-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:*:transition-none",
        className
      )}
    >
      <span />
      <span />
    </span>
  )
}

function FullscreenMenuTrigger({
  className,
  ...props
}: DialogPrimitive.Trigger.Props) {
  return (
    <DialogPrimitive.Trigger
      data-slot="fullscreen-menu-trigger"
      render={<Button variant="ghost" size="icon" />}
      className={cn("group/fullscreen-menu-trigger", className)}
      {...props}
    >
      <FullscreenMenuIcon className="*:first:-translate-y-0.75 *:last:translate-y-0.75 group-data-popup-open/fullscreen-menu-trigger:*:first:translate-y-0 group-data-popup-open/fullscreen-menu-trigger:*:first:rotate-45 group-data-popup-open/fullscreen-menu-trigger:*:last:translate-y-0 group-data-popup-open/fullscreen-menu-trigger:*:last:-rotate-45" />
    </DialogPrimitive.Trigger>
  )
}

function FullscreenMenuContent({
  className,
  children,
  showCloseButton = true,
  closeLabel,
  ...props
}: DialogPrimitive.Popup.Props & {
  showCloseButton?: boolean
  closeLabel?: string
}) {
  return (
    <DialogPrimitive.Portal>
      <DialogPrimitive.Popup
        data-slot="fullscreen-menu-content"
        className={cn(
          "group/fullscreen-menu no-scrollbar fixed inset-0 z-50 flex flex-col overflow-x-hidden overflow-y-auto overscroll-contain bg-background pt-[env(safe-area-inset-top)] pb-[max(--spacing(8),env(safe-area-inset-bottom))] text-foreground outline-none [clip-path:inset(0)] transition-[clip-path] duration-500 ease-[cubic-bezier(0.32,0.72,0,1)] data-ending-style:duration-350 data-ending-style:[clip-path:inset(0_0_100%_0)] data-starting-style:[clip-path:inset(0_0_100%_0)] motion-reduce:transition-none",
          className
        )}
        {...props}
      >
        {showCloseButton && <FullscreenMenuHeader closeLabel={closeLabel} />}
        <div
          data-slot="fullscreen-menu-body"
          className="flex flex-col gap-8 px-8 pt-2 *:nth-2:[--fullscreen-menu-section:1] *:nth-3:[--fullscreen-menu-section:2] *:nth-4:[--fullscreen-menu-section:3] *:nth-5:[--fullscreen-menu-section:4] *:nth-6:[--fullscreen-menu-section:5]"
        >
          {children}
        </div>
      </DialogPrimitive.Popup>
    </DialogPrimitive.Portal>
  )
}

function FullscreenMenuHeader({ closeLabel }: { closeLabel?: string }) {
  return (
    <div
      data-slot="fullscreen-menu-header"
      className="sticky top-0 z-10 flex h-14 shrink-0 items-center justify-end bg-background px-4"
    >
      <DialogPrimitive.Close
        data-slot="fullscreen-menu-close"
        data-label={closeLabel ? "" : undefined}
        render={
          closeLabel ? (
            <Button variant="link" className="text-base" />
          ) : (
            <Button variant="ghost" size="icon" />
          )
        }
      >
        {closeLabel ?? (
          <>
            <FullscreenMenuIcon className="*:first:rotate-45 *:last:-rotate-45 group-data-ending-style/fullscreen-menu:*:first:-translate-y-0.75 group-data-ending-style/fullscreen-menu:*:first:rotate-0 group-data-ending-style/fullscreen-menu:*:last:translate-y-0.75 group-data-ending-style/fullscreen-menu:*:last:rotate-0 group-data-starting-style/fullscreen-menu:*:first:-translate-y-0.75 group-data-starting-style/fullscreen-menu:*:first:rotate-0 group-data-starting-style/fullscreen-menu:*:last:translate-y-0.75 group-data-starting-style/fullscreen-menu:*:last:rotate-0 group-data-ending-style/fullscreen-menu:*:duration-350" />
            <span className="sr-only">Close</span>
          </>
        )}
      </DialogPrimitive.Close>
    </div>
  )
}

function FullscreenMenuTitle({
  className,
  ...props
}: DialogPrimitive.Title.Props) {
  return (
    <DialogPrimitive.Title
      data-slot="fullscreen-menu-title"
      className={cn("sr-only", className)}
      {...props}
    />
  )
}

function FullscreenMenuGroup({
  className,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="fullscreen-menu-group"
      className={cn(
        "group/fullscreen-menu-group flex flex-col gap-1 *:nth-1:[--fullscreen-menu-index:1] *:nth-2:[--fullscreen-menu-index:2] *:nth-3:[--fullscreen-menu-index:3] *:nth-4:[--fullscreen-menu-index:4] *:nth-5:[--fullscreen-menu-index:5] *:nth-6:[--fullscreen-menu-index:6] *:nth-7:[--fullscreen-menu-index:7] *:nth-8:[--fullscreen-menu-index:8] *:nth-9:[--fullscreen-menu-index:9] *:nth-10:[--fullscreen-menu-index:10]",
        className
      )}
      {...props}
    />
  )
}

function FullscreenMenuLabel({
  className,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="fullscreen-menu-label"
      className={cn(
        "pb-1 text-sm text-muted-foreground transition-[opacity,translate] delay-(--fullscreen-menu-delay) duration-500 ease-[cubic-bezier(0.32,0.72,0,1)] [--fullscreen-menu-delay:calc(var(--fullscreen-menu-index,1)*30ms+var(--fullscreen-menu-section,0)*60ms+100ms)] group-data-ending-style/fullscreen-menu:-translate-y-2 group-data-ending-style/fullscreen-menu:opacity-0 group-data-ending-style/fullscreen-menu:delay-0 group-data-ending-style/fullscreen-menu:duration-200 group-data-starting-style/fullscreen-menu:-translate-y-2 group-data-starting-style/fullscreen-menu:opacity-0 motion-reduce:transition-none",
        className
      )}
      {...props}
    />
  )
}

function FullscreenMenuLink({
  render,
  isActive = false,
  className,
  ...props
}: useRender.ComponentProps<"a"> &
  React.ComponentProps<"a"> & {
    isActive?: boolean
  }) {
  const { setOpen } = useFullscreenMenu()

  return useRender({
    defaultTagName: "a",
    props: mergeProps<"a">(
      {
        "aria-current": isActive ? "page" : undefined,
        onClick: () => setOpen(false),
        className: cn(
          "flex min-h-11 items-center rounded-lg text-2xl font-semibold text-foreground outline-none transition-[opacity,translate,color] delay-(--fullscreen-menu-delay) duration-500 ease-[cubic-bezier(0.32,0.72,0,1)] select-none [--fullscreen-menu-delay:calc(var(--fullscreen-menu-index,1)*30ms+var(--fullscreen-menu-section,0)*60ms+100ms)] hover:text-muted-foreground focus-visible:ring-3 focus-visible:ring-ring/50 data-active:text-primary group-data-ending-style/fullscreen-menu:-translate-y-2 group-data-ending-style/fullscreen-menu:opacity-0 group-data-ending-style/fullscreen-menu:delay-0 group-data-ending-style/fullscreen-menu:duration-200 group-data-starting-style/fullscreen-menu:-translate-y-2 group-data-starting-style/fullscreen-menu:opacity-0 group-has-data-[slot=fullscreen-menu-label]/fullscreen-menu-group:min-h-10 group-has-data-[slot=fullscreen-menu-label]/fullscreen-menu-group:text-lg group-has-data-[slot=fullscreen-menu-label]/fullscreen-menu-group:font-medium motion-reduce:transition-none",
          className
        ),
      },
      props
    ),
    render,
    state: {
      slot: "fullscreen-menu-link",
      active: isActive,
    },
  })
}

function FullscreenMenuClose(props: DialogPrimitive.Close.Props) {
  return <DialogPrimitive.Close data-slot="fullscreen-menu-close" {...props} />
}

export {
  FullscreenMenu,
  FullscreenMenuClose,
  FullscreenMenuContent,
  FullscreenMenuGroup,
  FullscreenMenuLabel,
  FullscreenMenuLink,
  FullscreenMenuTitle,
  FullscreenMenuTrigger,
}
```

4. Update the import paths to match your project.

## Usage

```tsx
import { FullscreenMenu, FullscreenMenuTrigger, FullscreenMenuContent, FullscreenMenuTitle, FullscreenMenuGroup, FullscreenMenuLabel, FullscreenMenuLink, FullscreenMenuClose } from "@/components/ui/fullscreen-menu"
```

```tsx
<MenuExample />
```

The menu is uncontrolled by default; set `defaultOpen` to start open, or control it with `open` and `onOpenChange`. Choosing a `FullscreenMenuLink` closes the menu. Set `isActive` on the link for the current page.

Groups that contain a `FullscreenMenuLabel` render their links smaller than groups without one, so the first group reads as primary navigation. `FullscreenMenuLink` can render a router link instead of an anchor.

Use `FullscreenMenuLink` with the `render` prop to render a router link instead of an anchor.

## Examples

### Links only

A menu with a single group of primary links and no labelled sections.

**Example**

```tsx
import * as React from "react"
import {
  FullscreenMenu,
  FullscreenMenuContent,
  FullscreenMenuGroup,
  FullscreenMenuLabel,
  FullscreenMenuLink,
  FullscreenMenuTitle,
  FullscreenMenuTrigger,
} from "@/components/ui/fullscreen-menu"

const primary = ["Store", "Products", "Services", "Company", "Support"]

const sections = [
  { label: "Explore", links: ["Overview", "Compare", "Accessories"] },
  { label: "Shop", links: ["Shop online", "Find a store", "Order status"] },
]

function MenuExample({
  defaultOpen,
  closeLabel,
  withSections = true,
}: {
  defaultOpen?: boolean
  closeLabel?: string
  withSections?: boolean
}) {
  const [active, setActive] = React.useState("Store")

  return (
    <div className="flex h-14 items-center justify-between px-4">
      <span className="font-semibold">Acme</span>
      <FullscreenMenu defaultOpen={defaultOpen}>
        <FullscreenMenuTrigger aria-label="Menu" />
        <FullscreenMenuContent closeLabel={closeLabel}>
          <FullscreenMenuTitle>Menu</FullscreenMenuTitle>
          <FullscreenMenuGroup>
            {primary.map((link) => (
              <FullscreenMenuLink
                key={link}
                href={`#${link.toLowerCase()}`}
                isActive={active === link}
                onClick={() => setActive(link)}
              >
                {link}
              </FullscreenMenuLink>
            ))}
          </FullscreenMenuGroup>
          {withSections &&
            sections.map((section) => (
              <FullscreenMenuGroup key={section.label}>
                <FullscreenMenuLabel>{section.label}</FullscreenMenuLabel>
                {section.links.map((link) => (
                  <FullscreenMenuLink
                    key={link}
                    href={`#${link.toLowerCase().replaceAll(" ", "-")}`}
                  >
                    {link}
                  </FullscreenMenuLink>
                ))}
              </FullscreenMenuGroup>
            ))}
        </FullscreenMenuContent>
      </FullscreenMenu>
    </div>
  )
}

export default function FullscreenMenuLinksOnly() {
  return <MenuExample withSections={false} />
}
```

### With close label

A menu whose close button shows the text `Done` instead of an icon.

**Example**

```tsx
import * as React from "react"
import {
  FullscreenMenu,
  FullscreenMenuContent,
  FullscreenMenuGroup,
  FullscreenMenuLabel,
  FullscreenMenuLink,
  FullscreenMenuTitle,
  FullscreenMenuTrigger,
} from "@/components/ui/fullscreen-menu"

const primary = ["Store", "Products", "Services", "Company", "Support"]

const sections = [
  { label: "Explore", links: ["Overview", "Compare", "Accessories"] },
  { label: "Shop", links: ["Shop online", "Find a store", "Order status"] },
]

function MenuExample({
  defaultOpen,
  closeLabel,
  withSections = true,
}: {
  defaultOpen?: boolean
  closeLabel?: string
  withSections?: boolean
}) {
  const [active, setActive] = React.useState("Store")

  return (
    <div className="flex h-14 items-center justify-between px-4">
      <span className="font-semibold">Acme</span>
      <FullscreenMenu defaultOpen={defaultOpen}>
        <FullscreenMenuTrigger aria-label="Menu" />
        <FullscreenMenuContent closeLabel={closeLabel}>
          <FullscreenMenuTitle>Menu</FullscreenMenuTitle>
          <FullscreenMenuGroup>
            {primary.map((link) => (
              <FullscreenMenuLink
                key={link}
                href={`#${link.toLowerCase()}`}
                isActive={active === link}
                onClick={() => setActive(link)}
              >
                {link}
              </FullscreenMenuLink>
            ))}
          </FullscreenMenuGroup>
          {withSections &&
            sections.map((section) => (
              <FullscreenMenuGroup key={section.label}>
                <FullscreenMenuLabel>{section.label}</FullscreenMenuLabel>
                {section.links.map((link) => (
                  <FullscreenMenuLink
                    key={link}
                    href={`#${link.toLowerCase().replaceAll(" ", "-")}`}
                  >
                    {link}
                  </FullscreenMenuLink>
                ))}
              </FullscreenMenuGroup>
            ))}
        </FullscreenMenuContent>
      </FullscreenMenu>
    </div>
  )
}

export default function FullscreenMenuWithCloseLabel() {
  return <MenuExample closeLabel="Done" />
}
```

## API Reference

### FullscreenMenu

The root that manages the menu's open state.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `open` | `boolean` | - | Whether the menu is open, for controlled use. |
| `defaultOpen` | `boolean` | `false` | Whether the menu starts open when uncontrolled. |
| `onOpenChange` | `(open: boolean) => void` | - | Called when the menu opens or closes. |

Also accepts the props of the primitive part it wraps, plus `FullscreenMenuProps`.

### FullscreenMenuTrigger

The menu button, with an icon that animates between menu and close states.

Also accepts the props of the primitive part it wraps.

### FullscreenMenuContent

The full-screen panel, with a sticky header that holds the close button.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `showCloseButton` | `boolean` | `true` | Shows the header with a close button at the top of the menu. |
| `closeLabel` | `string` | - | Replaces the close button's icon with this text. |

Also accepts the props of the primitive part it wraps.

### FullscreenMenuTitle

The menu's title, visually hidden and used as its accessible name.

Also accepts the props of the primitive part it wraps.

### FullscreenMenuGroup

A list of links, animated in sequence.

Also accepts the props of `React.ComponentProps<"div">`.

### FullscreenMenuLabel

A caption for a group of links.

Also accepts the props of `React.ComponentProps<"div">`.

### FullscreenMenuLink

A navigation link that closes the menu when chosen.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `isActive` | `boolean` | `false` | Marks the link as the current page with `aria-current="page"` and `text-primary`. |

Also accepts the props of `useRender.ComponentProps<"a">`, `React.ComponentProps<"a">`.

### FullscreenMenuClose

A button that closes the menu from any element.

Also accepts the props of the primitive part it wraps.

## Accessibility

- The panel has the `dialog` role and takes its name from `FullscreenMenuTitle`, which is visually hidden. Always include one.
- The trigger has no text of its own, so give it an accessible name with `aria-label`. It sets `aria-expanded` while the menu is open.
- Opening the menu moves focus inside it. Escape closes it and returns focus to the trigger.
- The icon-only close button has a visually hidden `Close` label.
- The active link sets `aria-current="page"`.

## Notes

- Styling hooks: `data-slot` values `fullscreen-menu-trigger`, `fullscreen-menu-content`, `fullscreen-menu-header`, `fullscreen-menu-body`, `fullscreen-menu-close`, `fullscreen-menu-title`, `fullscreen-menu-group`, `fullscreen-menu-label` and `fullscreen-menu-link`.
- The active link gets `data-active`.
- The stagger delay is computed from a link's position in its group, for up to 10 links, and the group's position in the menu, for up to 6 sections.
- The content respects safe-area insets, and animations are disabled when reduced motion is preferred.
- Uses the `background`, `foreground`, `muted-foreground` and `primary` tokens.

