Skip to content
  1. Keyline UI
  2. Menu
Keyline UIComponents
  • Get started
    • Installation
    • Theming
    • Modes
    • Configuration
    • Composition
  • Actions
    • Button
    • Button group
    • Split button
    • Toggle
    • Toggle group
    • Toolbar
    • Menu
    • Menubar
    • Context menu
  • Inputs
    • Field and form
    • Checkbox
    • Checkbox group
    • Radio group
    • Switch
    • Slider
    • Segmented
    • Select
    • Native select
    • Combobox
    • Multiselect
    • Number field
    • OTP field
    • Time field
    • Date picker and calendar
    • Date range
  • Data
    • Table
    • Chart
    • Stat and meter
    • Status and badge
    • Progress
    • Skeleton
    • Empty
    • Avatar
    • Kbd
    • Marker
  • Navigation
    • Tabs
    • Breadcrumb
    • Pagination
    • Nav
    • Navigation menu
    • Command menu
  • Overlays
    • Dialog
    • Alert dialog
    • Drawer
    • Sheet
    • Popover
    • Hover card
    • Tooltip
    • Toast
    • Notice
  • Layout
    • App shell
    • Sidebar
    • Panel
    • Resizable
    • Scroll area
    • Accordion
    • Collapsible
    • Carousel
    • Inspector
    • Item
    • Direction
  • Conversation
    • Message
    • Bubble
    • Message scroller
    • Attachment
    • Questionnaire
  • Blocks
    • Shipments

Menu

A list of commands that docks to its trigger: its top edge lies on the trigger’s keyline and it unfolds from there. Rows can open submenus or hold checkbox and radio choices.

Loading the live example…

"use client"

import { ChevronDown } from "lucide-react"
import { useState } from "react"
import { Button } from "@/components/keyline/ui/button"
import {
  Menu,
  MenuContent,
  MenuItem,
  MenuRadioGroup,
  MenuRadioItem,
  MenuSeparator,
  MenuSubmenu,
  MenuSubmenuContent,
  MenuSubmenuTrigger,
  MenuTrigger,
} from "@/components/keyline/ui/menu"

const REPS = ["Dwight Schrute", "Jim Halpert", "Phyllis Vance", "Stanley Hudson"]

export function OrderActions() {
  const [rep, setRep] = useState("Dwight Schrute")
  const [due, setDue] = useState("Thu 12 Nov")
  const [copies, setCopies] = useState(0)
  const [cancelled, setCancelled] = useState(false)
  const [last, setLast] = useState("Nothing changed yet.")

  return (
    <div className="grid gap-4">
      <div className="flex flex-wrap items-center justify-between gap-3">
        <dl className="m-0 grid grid-cols-[auto_1fr] gap-x-6 gap-y-1 text-[14px]">
          <dt className="text-kl-ink-3">Order</dt>
          <dd className="m-0">
            <span className="font-kl-mono whitespace-nowrap">DM-4102</span> · Scranton Business Park
            {cancelled ? <strong className="ms-2">Cancelled</strong> : null}
          </dd>
          <dt className="text-kl-ink-3">Sales rep</dt>
          <dd className="m-0">{rep}</dd>
          <dt className="text-kl-ink-3">Delivery</dt>
          <dd className="m-0">{due}</dd>
        </dl>
        <Menu>
          <MenuTrigger render={<Button />}>
            Actions
            <ChevronDown size={16} aria-hidden="true" />
          </MenuTrigger>
          <MenuContent align="end">
            <MenuItem
              disabled={cancelled}
              onClick={() => {
                setDue("Mon 16 Nov")
                setLast("Delivery moved to Monday. The client gets an email.")
              }}
            >
              Move delivery to Monday
            </MenuItem>
            <MenuItem
              onClick={() => {
                setCopies(copies + 1)
                setLast(`Duplicated as DM-${4141 + copies}, same paper and client.`)
              }}
            >
              Duplicate order
            </MenuItem>
            <MenuSubmenu>
              <MenuSubmenuTrigger disabled={cancelled}>Assign to</MenuSubmenuTrigger>
              <MenuSubmenuContent>
                <MenuRadioGroup
                  value={rep}
                  onValueChange={(r: string) => {
                    setRep(r)
                    setLast(`${r.split(" ")[0]} has the account now.`)
                  }}
                >
                  {REPS.map((r) => (
                    <MenuRadioItem key={r} value={r}>
                      {r}
                    </MenuRadioItem>
                  ))}
                </MenuRadioGroup>
              </MenuSubmenuContent>
            </MenuSubmenu>
            <MenuSeparator />
            <MenuItem
              variant="destructive"
              disabled={cancelled}
              onClick={() => {
                setCancelled(true)
                setLast("Order cancelled. 40 cases go back on the shelf.")
              }}
            >
              Cancel order
            </MenuItem>
          </MenuContent>
        </Menu>
      </div>
      <div className="flex min-h-8 flex-wrap items-center gap-3">
        <p role="status" className="m-0 text-[14px] text-kl-ink-2">
          {last}
        </p>
        {cancelled ? (
          <Button
            size="compact"
            variant="quiet"
            onClick={() => {
              setCancelled(false)
              setLast("Order restored. Nothing went back on the shelf.")
            }}
          >
            Undo
          </Button>
        ) : null}
      </div>
    </div>
  )
}

Installation

npx shadcn@latest add @keyline-ui/menu

Run it where your components.json is. The first time, map the @keyline-ui registry: one command, in Installation.

Usage

MenuTrigger renders whatever you pass to render; a Keyline Button is the usual choice.

import { Button } from "@/components/keyline/ui/button"
import { Menu, MenuContent, MenuItem, MenuTrigger } from "@/components/keyline/ui/menu"
<Menu>
  <MenuTrigger render={<Button />}>Actions</MenuTrigger>
  <MenuContent>
    <MenuItem onClick={duplicate}>Duplicate order</MenuItem>
    <MenuItem variant="destructive" onClick={cancel}>Cancel order</MenuItem>
  </MenuContent>
</Menu>

Examples

Checkbox and radio

MenuCheckboxItem takes a tick, MenuRadioItem a filled square, so the kind reads without colour. Choosing one keeps the menu open.

Loading the live example…

"use client"

import { useState } from "react"
import { Button } from "@/components/keyline/ui/button"
import {
  Menu,
  MenuCheckboxItem,
  MenuContent,
  MenuGroup,
  MenuGroupLabel,
  MenuRadioGroup,
  MenuRadioItem,
  MenuSeparator,
  MenuTrigger,
} from "@/components/keyline/ui/menu"

const EVENTS = { shipped: "An order ships", complaint: "A client complains", mention: "Someone mentions me" }
type Event = keyof typeof EVENTS

export function NotificationMenu() {
  const [on, setOn] = useState<Record<Event, boolean>>({ shipped: true, complaint: true, mention: false })
  const [when, setWhen] = useState("now")
  const chosen = (Object.keys(EVENTS) as Event[]).filter((e) => on[e])

  return (
    <div className="grid gap-4">
      <Menu>
        <MenuTrigger render={<Button className="justify-self-start" />}>Notify me</MenuTrigger>
        <MenuContent>
          <MenuGroup>
            <MenuGroupLabel>When</MenuGroupLabel>
            {(Object.keys(EVENTS) as Event[]).map((e) => (
              <MenuCheckboxItem key={e} checked={on[e]} onCheckedChange={(c) => setOn({ ...on, [e]: c })}>
                {EVENTS[e]}
              </MenuCheckboxItem>
            ))}
          </MenuGroup>
          <MenuSeparator />
          <MenuRadioGroup value={when} onValueChange={setWhen}>
            <MenuGroupLabel>How often</MenuGroupLabel>
            <MenuRadioItem value="now">Right away</MenuRadioItem>
            <MenuRadioItem value="daily" description="One email at 5 PM">
              Daily digest
            </MenuRadioItem>
          </MenuRadioGroup>
        </MenuContent>
      </Menu>
      <p role="status" className="m-0 min-h-10 max-w-[52ch] text-[14px] text-kl-ink-2">
        {chosen.length
          ? `${when === "now" ? "Right away" : "Once a day"}: ${chosen.map((e) => EVENTS[e].toLowerCase()).join(", ")}.`
          : "No notifications. You’ll hear about it at the next conference room meeting."}
      </p>
    </div>
  )
}

Props

PropTypeDefaultDescription
align"start" | "end"Default "start"On MenuContent: which edge of the trigger the menu lines up with.
side"bottom" | "top"Default "bottom"On MenuContent: the trigger edge it docks to.
attachWeight1 | 2 | 3Default 2On MenuContent: the weight of the edge it shares. Use 1 when the trigger is a cell.
anchorElement | RefDefault the triggerOn MenuContent: dock to another element, such as the frame around the trigger.
variant"default" | "destructive"Default "default"On MenuItem: destructive sets the row in red.
descriptionReactNodeDefault —On items: a second line under the label.
shortcutReactNodeDefault —On items: a key shown at the row’s end. Showing it does not bind it.

Parts: Menu, MenuTrigger, MenuContent, MenuItem, MenuCheckboxItem, MenuRadioGroup, MenuRadioItem, MenuSeparator, MenuGroup, MenuGroupLabel, MenuSubmenu, MenuSubmenuTrigger, MenuSubmenuContent. Built on Base UI Menu.

Sheet 13 of 73PreviousToolbarNextMenubar
On this page