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.
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 }
pnpm dlx shadcn add @01orbital/menu-buttonnpx shadcn add @01orbital/menu-buttonyarn shadcn add @01orbital/menu-buttonbunx --bun shadcn add @01orbital/menu-buttonFree 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.
"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>
)
}Prop
Type
It also accepts the props of the Base UI part it wraps, and the HTML attributes of the element it renders.
npm: cn. The CLI installs them for you.
Registry pieces it installs with it:
buttonFrosted Button
A translucent Button for use over art: a blurred, faint background-tinted fill with a border and inner glow that follow currentColor. Needs a backdrop behind it for the blur to read.
Sphere Button
A round planetary button lit by a radial highlight and crescent inset shadows, in teal, blue and coral. A void variant opens a dark centre on hover; labels set in Marcellus.