Button

Menu Button

A hamburger icon Button that morphs into a close cross. The animation is driven entirely by aria-expanded, so the visual state and the screen-reader state stay in sync.

Free, MIT licenseOpen in Browse
import type * as React from "react"
import { cn } from "cn"

import { Button } from "@/components/ui/button"

/**
 * An outline icon button that opens and closes a menu, such as a mobile
 * navigation. Its three lines turn into an X when `open` is true.
 *
 * The animation follows `aria-expanded`, which is set from `open`, so what
 * shows and what screen readers hear always match. It holds no state of its
 * own: set `open` from your menu and toggle it in `onClick`. Without an
 * `aria-label` it is named "Open menu" or "Close menu". Pass `aria-controls`
 * with the menu's id to name the panel it toggles.
 */
function MenuButton({
  open,
  className,
  "aria-label": label,
  ...props
}: Omit<
  React.ComponentProps<typeof Button>,
  "children" | "aria-expanded" | "className" | "size" | "variant"
> & {
  /**
   * The stock `Button` size. Use a square one: `icon` (the default here)
   * 36px, `icon-xs` 24px, `icon-sm` 32px or `icon-lg` 40px.
   */
  size?: React.ComponentProps<typeof Button>["size"]
  /**
   * The stock `Button` variant, `outline` by default here. `ghost` drops the
   * border and shows a muted fill only on hover.
   */
  variant?: React.ComponentProps<typeof Button>["variant"]
  /** Whether the menu is open. Sets `aria-expanded` and shows the X. */
  open: boolean
  /**
   * Extra classes, as a string only: the stock `Button` does not accept a
   * `className` function.
   */
  className?: string
}) {
  return (
    <Button
      data-slot="menu-button"
      size="icon"
      variant="outline"
      aria-expanded={open}
      aria-label={label ?? (open ? "Close menu" : "Open menu")}
      className={cn("group/menu-button", className)}
      {...props}
    >
      <svg
        aria-hidden="true"
        fill="none"
        stroke="currentColor"
        strokeLinecap="round"
        strokeLinejoin="round"
        strokeWidth="2"
        viewBox="0 0 24 24"
      >
        <path
          className="origin-center -translate-y-1.75 transition-transform duration-300 ease-out group-aria-expanded/menu-button:translate-y-0 group-aria-expanded/menu-button:-rotate-45"
          d="M4 12L20 12"
        />
        <path
          className="origin-center transition-transform duration-300 ease-out group-aria-expanded/menu-button:rotate-45"
          d="M4 12H20"
        />
        <path
          className="origin-center translate-y-1.75 transition-transform duration-300 ease-out group-aria-expanded/menu-button:translate-y-0 group-aria-expanded/menu-button:-rotate-45"
          d="M4 12H20"
        />
      </svg>
    </Button>
  )
}

export { MenuButton }

Installation

pnpm dlx shadcn add @01orbital/menu-button
npx shadcn add @01orbital/menu-button
yarn shadcn add @01orbital/menu-button
bunx --bun shadcn add @01orbital/menu-button

Free under the MIT license. Add the @01orbital registry to components.json once (install guide), then run the command.

It lands at components/button/menu-button.tsx, as source you own and edit.

Usage

"use client"

import { useId, useState } from "react"

import { MenuButton } from "@/components/button/menu-button"

// Every word on the page. Change copy here, not in the markup.
const LINKS = ["Overview", "Components", "Pricing", "Changelog"] as const

// The button names the panel it opens; the panel hangs below, so the
// button never moves.
export default function MenuButtonDemo() {
  const [open, setOpen] = useState(false)
  const panelId = useId()
  return (
    <div className="relative">
      <MenuButton
        open={open}
        aria-controls={panelId}
        onClick={() => setOpen((v) => !v)}
      />
      <nav
        id={panelId}
        hidden={!open}
        className="absolute top-full left-1/2 mt-2 min-w-48 -translate-x-1/2 rounded-3xl bg-popover p-1.5 text-popover-foreground shadow-lg ring-1 ring-foreground/5 dark:ring-foreground/10"
      >
        <ul>
          {LINKS.map((link) => (
            <li key={link}>
              <a
                href="#"
                className="flex rounded-2xl px-3 py-2 text-sm outline-none hover:bg-accent focus-visible:bg-accent"
              >
                {link}
              </a>
            </li>
          ))}
        </ul>
      </nav>
    </div>
  )
}

API reference

Prop

Type

It also accepts the props of the Base UI part it wraps, and the HTML attributes of the element it renders.

Dependencies

npm: cn. The CLI installs them for you.

Registry pieces it installs with it:

  • button