macbetter

Menus and pickers

Pop-Up Button

Choose one option, or several, from a menu of choices.

Show scroll bars
import { Select, SelectContent, SelectItem, SelectLabel, SelectTrigger } from "macbetter/select"

const options = [
  { value: "automatic", label: "Automatically based on input" },
  { value: "when-scrolling", label: "When scrolling" },
  { value: "always", label: "Always" },
]

export default function Example() {
  return (
    <div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 10 }}>
      <Select items={options} defaultValue="when-scrolling">
        <SelectLabel>Show scroll bars</SelectLabel>
        <SelectTrigger style={{ width: 260 }} />
        <SelectContent>
          {options.map((option) => (
            <SelectItem key={option.value} value={option.value}>
              {option.label}
            </SelectItem>
          ))}
        </SelectContent>
      </Select>
    </div>
  )
}

When to use it

A pop-up button shows the current choice and opens a menu of the others. Use it for a set of mutually exclusive options that would take too much room as radio buttons, like a setting with four values.

  • Use it for choices, not commands. A list of actions belongs in a pull-down menu.
  • For two or three options that people switch often, a segmented control is faster.
  • Label it, and keep option names short enough to fit the button.

Usage

import { Select, SelectContent, SelectItem, SelectLabel, SelectTrigger } from "macbetter/select"

SelectTrigger renders the value and its chevron on its own. SelectItem renders the checkmark beside the selected item. On the Mac the menu opens over the button with the selected item on top of it, its text lined up with the button's. Pass alignItemWithTrigger={false} to SelectContent to open it below instead; iOS and iPadOS, and any touch, always do.

Pass the options to Select as items too, as the examples do. Then the button can show the selected label before the menu has ever opened, including in the server-rendered page.

Groups

SelectGroup, SelectGroupLabel and SelectSeparator organize a long list.

Font
import {
  Select,
  SelectContent,
  SelectGroup,
  SelectGroupLabel,
  SelectItem,
  SelectLabel,
  SelectSeparator,
  SelectTrigger,
  type ListData,
} from "macbetter/select"
import { Fragment } from "react"

const families = [
  {
    label: "Sans Serif",
    fonts: [
      { value: "sf-pro", label: "SF Pro" },
      { value: "helvetica", label: "Helvetica Neue" },
      { value: "avenir", label: "Avenir Next" },
    ],
  },
  {
    label: "Serif",
    fonts: [
      { value: "new-york", label: "New York" },
      { value: "georgia", label: "Georgia" },
    ],
  },
  {
    label: "Monospaced",
    fonts: [
      { value: "sf-mono", label: "SF Mono" },
      { value: "menlo", label: "Menlo" },
    ],
  },
]

// Labels on the root let the button show "New York" before the menu has ever opened.
const items: ListData = families.map((family) => ({ type: "group", label: family.label, items: family.fonts }))

export default function Example() {
  return (
    <div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 10 }}>
      <Select items={items} defaultValue="new-york">
        <SelectLabel>Font</SelectLabel>
        <SelectTrigger style={{ width: 180 }} />
        <SelectContent alignItemWithTrigger={false}>
          {families.map((family, index) => (
            <Fragment key={family.label}>
              {index > 0 ? <SelectSeparator /> : null}
              <SelectGroup>
                <SelectGroupLabel>{family.label}</SelectGroupLabel>
                {family.fonts.map((font) => (
                  <SelectItem key={font.value} value={font.value}>
                    {font.label}
                  </SelectItem>
                ))}
              </SelectGroup>
            </Fragment>
          ))}
        </SelectContent>
      </Select>
    </div>
  )
}

Multiple

Set multiple to let people pick several. The menu stays open while they toggle items, and SelectValue's maxLabels summarizes a long selection.

Notify me about
import { Select, SelectContent, SelectItem, SelectLabel, SelectTrigger, SelectValue } from "macbetter/select"

const events = [
  { value: "mentions", label: "Mentions" },
  { value: "replies", label: "Replies" },
  { value: "reactions", label: "Reactions" },
  { value: "new-members", label: "New members" },
]

export default function Example() {
  return (
    <div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 10 }}>
      <Select items={events} multiple defaultValue={["mentions", "replies"]} placeholder="Nothing">
        <SelectLabel>Notify me about</SelectLabel>
        <SelectTrigger style={{ width: 220 }}>
          <SelectValue maxLabels={2} />
        </SelectTrigger>
        <SelectContent>
          {events.map((event) => (
            <SelectItem key={event.value} value={event.value}>
              {event.label}
            </SelectItem>
          ))}
        </SelectContent>
      </Select>
    </div>
  )
}

Plain

variant="plain" drops the bezel: text and a small chevron, for toolbars, sort controls and table headers. It's what auto uses on iOS and iPadOS.

Sort by
import { Select, SelectContent, SelectItem, SelectLabel, SelectTrigger } from "macbetter/select"

const sorts = [
  { value: "date", label: "Date" },
  { value: "name", label: "Name" },
  { value: "size", label: "Size" },
  { value: "kind", label: "Kind" },
]

export default function Example() {
  return (
    <div style={{ display: "flex", alignItems: "center", gap: 8, whiteSpace: "nowrap" }}>
      <Select items={sorts} defaultValue="date">
        <SelectLabel>Sort by</SelectLabel>
        <SelectTrigger variant="plain" />
        <SelectContent>
          {sorts.map((sort) => (
            <SelectItem key={sort.value} value={sort.value}>
              {sort.label}
            </SelectItem>
          ))}
        </SelectContent>
      </Select>
    </div>
  )
}

On a phone

Set presentation="auto" on Select to show the list as a bottom sheet on small touch screens, with a popover everywhere else. presentation="sheet" always uses a sheet. Swipe the sheet down to dismiss it without changing the value.

Repeat
import { Select, SelectContent, SelectItem, SelectLabel, SelectTrigger } from "macbetter/select"

const repeats = [
  { value: "never", label: "Never" },
  { value: "daily", label: "Every Day" },
  { value: "weekdays", label: "Every Weekday" },
  { value: "weekly", label: "Every Week" },
  { value: "monthly", label: "Every Month" },
  { value: "yearly", label: "Every Year" },
]

export default function Example() {
  return (
    <div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 10 }}>
      <Select items={repeats} defaultValue="weekly" presentation="auto">
        <SelectLabel>Repeat</SelectLabel>
        <SelectTrigger />
        <SelectContent>
          {repeats.map((repeat) => (
            <SelectItem key={repeat.value} value={repeat.value}>
              {repeat.label}
            </SelectItem>
          ))}
        </SelectContent>
      </Select>
    </div>
  )
}

Listbox

When the choices should stay on screen, use Listbox. It's the same choice in a list that's always visible: a bordered table with an accent selection on the Mac, and an inset list with a checkmark on iOS. The selection turns gray when the list doesn't have focus, as on the Mac.

Default web browser
Safari
Firefox
Chrome
Arc
import { Listbox, ListboxItem } from "macbetter/listbox"

export default function Example() {
  return (
    <div style={{ display: "grid", gap: 6, width: 240 }}>
      <span id="browser-label">Default web browser</span>
      <Listbox aria-labelledby="browser-label" defaultValue="safari">
        <ListboxItem value="safari">Safari</ListboxItem>
        <ListboxItem value="firefox">Firefox</ListboxItem>
        <ListboxItem value="chrome">Chrome</ListboxItem>
        <ListboxItem value="arc">Arc</ListboxItem>
      </Listbox>
    </div>
  )
}
import { Listbox, ListboxItem } from "macbetter/listbox"

Platform behavior

  • macOS: a bezeled button with the value and an accent square at its trailing edge holding a double chevron. Its menu is a Mac menu that always keeps a check column, so items don't shift as the value changes.
  • iOS and iPadOS: the menu picker. The value is plain accent-colored text with an up-and-down chevron, and the menu is the roomy iOS menu with a checkmark beside the choice. variant="bezeled" gives it a gray capsule instead.
  • presentation="auto" on Select shows the list as a bottom sheet on small touch screens.
  • Right to left, the check column and the Mac's text alignment mirror.

Accessibility

The trigger is a combobox named by SelectLabel. Enter, Space and the arrow keys open the list, typing selects by name even while it's closed, Enter chooses, and Escape closes without changing the value. With name, the value submits with a form.

API

SelectTrigger

PropTypeDefaultDescription
variant"auto" | "bezeled" | "plain""auto"Bezeled on the Mac and plain on touch, or one of them everywhere.
size"sm" | "md" | "lg""md"The control size.
childrenReactNode<SelectValue />What the button shows. The cap is always added.

Other parts

PropTypeDefaultDescription
Selectitems, value, defaultValue, multiple, presentation, name—The root. items lets the button show labels before the list opens. presentation is popover, sheet or auto.
SelectContentalignItemWithTrigger: boolean | "auto""auto"Opens the menu over the button with the selected item on it. auto does that on the Mac only.
SelectItemvalue, label, disabled—An option. Renders its own checkmark.
SelectItemIconmirror—An icon before the label.
SelectGroup, SelectGroupLabel, SelectSeparator——Organize the list.
SelectInput, SelectEmpty, SelectStatus——A search field inside the list, and its messages.
Listbox, ListboxItemvalue, multiple—The always-visible list. ListboxItem renders its own checkmark.

Styling hooks

PropTypeDefaultDescription
data-selecteditem—The chosen item, and its checkmark.
data-placeholdertrigger and value—Nothing is chosen yet.
data-opentrigger—The list is open.
--mb-select-cap-insetlength4pxSpace around the accent cap.
--mb-select-content-shiftlength—How far the Mac menu moves to line its text up with the button's.