Components
Select
One choice among several: a button holding the current choice, which opens the options in a list.
MarkdownA select holds one choice among several, shown as a button with the current choice that opens the options in a list. It takes little room, and shows the alternatives only when they are wanted.
A typeface
Open either list: it starts on the current choice. Move with the arrows or the pointer without changing anything; Return, Space or a click chooses.
The quick brown fox jumps over the lazy dog.
Code
"use client";
import { Select, SelectGroup, SelectItem } from "@publictheta/ui/components/select/select.tsx";
import { useId, useState } from "react";
const FAMILIES: Record<string, string> = {
system: "system-ui, sans-serif",
rounded: "ui-rounded, system-ui, sans-serif",
serif: "ui-serif, Georgia, serif",
mono: "ui-monospace, Menlo, monospace",
};
export default function Typeface() {
const ids = { family: useId(), weight: useId() };
const [family, setFamily] = useState<string | null>("serif");
const [weight, setWeight] = useState<string | null>("400");
return (
<div className="flex w-full max-w-sm flex-col gap-4">
<div className="grid grid-cols-[auto_1fr] items-center gap-x-4 gap-y-2 text-sm">
<span id={ids.family} className="text-fg-muted">
Typeface
</span>
<Select aria-labelledby={ids.family} value={family} onValueChange={setFamily}>
<SelectGroup label="Sans serif">
<SelectItem value="system">System</SelectItem>
<SelectItem value="rounded">Rounded</SelectItem>
</SelectGroup>
<SelectGroup label="Other">
<SelectItem value="serif">Serif</SelectItem>
<SelectItem value="mono">Monospaced</SelectItem>
</SelectGroup>
</Select>
<span id={ids.weight} className="text-fg-muted">
Weight
</span>
<Select aria-labelledby={ids.weight} value={weight} onValueChange={setWeight}>
<SelectItem value="300">Light</SelectItem>
<SelectItem value="400">Regular</SelectItem>
<SelectItem value="600">Semibold</SelectItem>
<SelectItem value="800" disabled>
Heavy
</SelectItem>
</Select>
</div>
<p
className="rounded-lg bg-surface p-4 text-xl text-fg-strong ring-1 ring-border-muted"
style={{ fontFamily: FAMILIES[family ?? "system"], fontWeight: Number(weight ?? 400) }}
>
The quick brown fox jumps over the lazy dog.
</p>
</div>
);
}When to use
Use a select for a choice of one among more options than fit comfortably in view, where the current choice matters more than the alternatives. When people benefit from seeing all the options, or choose several, use a listbox. For commands rather than a value, use a menu.
Best practices
Show the current choice on the button, in the option's own words. With nothing chosen, say what is being chosen.
Open on the current choice. The list opens with the chosen option under focus, so the alternatives nearest it are a key press away.
Let people look without choosing. Moving through the options changes nothing; Return, Space or a click chooses. Escape, Tab and a press outside close the list and leave the choice as it was.
Mark the choice; fill the highlight. In the open list the chosen option carries a mark, and the filled highlight follows the pointer or the keys, so the two are never mistaken for each other.
Choose without opening. On the closed button, typing the start of an option's name chooses it, as a platform's own popup button does.
Show a choice that cannot be changed as one. A select that is read-only shows its choice, says that it is read-only, and does not open.
Take part in a form. A select with a name submits its chosen value, and a label that names the button names the list too.
Order options meaningfully, and group them under headings when they fall into kinds.
Disable rather than hide an option that is unavailable at the moment.
Interaction
| Intent | Pointer | Keyboard | Touch |
|---|---|---|---|
| Open | Press the button | Return, Space, Down Arrow or Up Arrow | Tap the button |
| Move between options | Move the pointer | Up and Down Arrow, Home, End | — |
| Move to an option by name | — | Type its first letters | — |
| Choose | Click, or release after dragging from the button | Return or Space | Tap an option |
| Choose while closed | — | Type an option's first letters | — |
| Close without choosing | Click outside, or the button | Escape; Tab also moves on | Tap outside |
Platform considerations
Pointer and keyboard. The list opens beneath its button, at least as wide.
Touch. On a phone the list rises from the bottom edge as a sheet; options are at least 44 CSS pixels tall.
Assistive technology. The button states its name, its current choice and that it opens a list; the options are a listbox of the same name, with the chosen option selected.
Behaviour
Semantics
The button names what is chosen from, shows the current choice, and says it opens a list
const screen = await render(<Status defaultValue="review" />);
await expect.element(button(screen)).toHaveTextContent("In review");
await expect.element(button(screen)).toHaveAttribute("aria-haspopup", "listbox");
await expect.element(button(screen)).toHaveAttribute("aria-expanded", "false");With nothing chosen, it shows its placeholder
const screen = await render(<Status />);
await expect.element(button(screen)).toHaveTextContent("Choose…");The options are a listbox with the same name, the chosen one selected
const screen = await render(<Status defaultValue="review" />);
await userEvent.click(button(screen));
await expect.element(screen.getByRole("listbox", { name: "Status" })).toBeVisible();
await expect.element(option(screen, "In review")).toHaveAttribute("aria-selected", "true");The button states which list it controls
const screen = await render(<Status defaultValue="review" />);
await userEvent.click(button(screen));
const list = screen.getByRole("listbox", { name: "Status" }).element();
await expect.element(button(screen)).toHaveAttribute("aria-controls", list.id);A label that names the button names the list too
const screen = await render(
<>
<label htmlFor="size">Size</label>
<Select id="size" defaultValue="s">
<SelectItem value="s">Small</SelectItem>
<SelectItem value="l">Large</SelectItem>
</Select>
</>,
);
await userEvent.click(screen.getByRole("combobox", { name: "Size" }));
await expect.element(screen.getByRole("listbox", { name: "Size" })).toBeVisible();Pressing the label of an open list closes it, rather than closing and reopening
const screen = await render(
<>
<label htmlFor="size">Size</label>
<Select id="size" defaultValue="s">
<SelectItem value="s">Small</SelectItem>
<SelectItem value="l">Large</SelectItem>
</Select>
</>,
);
const select = screen.getByRole("combobox", { name: "Size" });
await userEvent.click(select);
await expect.element(select).toHaveAttribute("aria-expanded", "true");
// On a phone the label lies under the list's scrim, inert: the press is aimed at it regardless.
await userEvent.click(screen.getByText("Size", { exact: true }), { force: hasTouch });
await expect.element(select).toHaveAttribute("aria-expanded", "false");Has no accessibility violations, closed or open
const screen = await render(
<Select aria-label="Font" defaultValue="inter">
<SelectGroup label="Sans serif">
<SelectItem value="inter">Inter</SelectItem>
</SelectGroup>
<SelectGroup label="Serif">
<SelectItem value="charter">Charter</SelectItem>
</SelectGroup>
</Select>,
);
await expectAccessible(screen.container);
await userEvent.click(screen.getByRole("combobox", { name: "Font" }));
await expectAccessible(screen.container);Opening
A click opens the list with focus on the chosen option
const screen = await render(<Status defaultValue="live" />);
await userEvent.click(button(screen));
await expect.element(option(screen, "Live")).toHaveFocus();Opened again, it is on the chosen option, not where the pointer last wasChromium, Firefox, WebKit
Not yet written.
With nothing chosen, focus is on the first option
const screen = await render(<Status />);
await userEvent.click(button(screen));
await expect.element(option(screen, "Draft")).toHaveFocus();Enter, Space, Down Arrow and Up Arrow open it from the keyboard
const screen = await render(<Status defaultValue="review" />);
for (const key of ["{Enter}", " ", "{ArrowDown}", "{ArrowUp}"]) {
await focusButton(screen);
await userEvent.keyboard(key);
await expect.element(option(screen, "In review")).toHaveFocus();
await userEvent.keyboard("{Escape}");
await expect.poll(() => isOpen(screen)).toBe(false);
}The list is at least as wide as its buttonChromium, Firefox, WebKit
Not yet written.
Choosing
Moving through the options chooses nothing
const onValueChange = vi.fn();
const screen = await render(<Status defaultValue="draft" onValueChange={onValueChange} />);
await userEvent.click(button(screen));
await userEvent.keyboard("{ArrowDown}{ArrowDown}");
await expect.element(option(screen, "Live")).toHaveFocus();
expect(onValueChange).not.toHaveBeenCalled();Enter chooses the focused option, closes the list and returns focus to the button
const screen = await render(<Status defaultValue="draft" />);
await userEvent.click(button(screen));
await userEvent.keyboard("{ArrowDown}{Enter}");
await expect.element(button(screen)).toHaveTextContent("In review");
await expect.poll(() => isOpen(screen)).toBe(false);
await expect.element(button(screen)).toHaveFocus();Space chooses too
const screen = await render(<Status defaultValue="draft" />);
await userEvent.click(button(screen));
await userEvent.keyboard("{End} ");
await expect.element(button(screen)).toHaveTextContent("Live");
await expect.poll(() => isOpen(screen)).toBe(false);A click chooses an option and closes the list
const screen = await render(<Status defaultValue="draft" />);
await userEvent.click(button(screen));
await userEvent.click(option(screen, "Live"));
await expect.element(button(screen)).toHaveTextContent("Live");
await expect.poll(() => isOpen(screen)).toBe(false);Choosing the option already chosen changes nothing and closes the list
const onValueChange = vi.fn();
const screen = await render(<Status defaultValue="draft" onValueChange={onValueChange} />);
await userEvent.click(button(screen));
await userEvent.click(option(screen, "Draft"));
await expect.poll(() => isOpen(screen)).toBe(false);
await userEvent.click(button(screen));
await userEvent.keyboard(" ");
await expect.poll(() => isOpen(screen)).toBe(false);
expect(onValueChange).not.toHaveBeenCalled();An unavailable option is passed over and cannot be chosen
const screen = await render(<Status defaultValue="live" />);
await userEvent.click(button(screen));
await userEvent.keyboard("{ArrowDown}");
await expect.element(option(screen, "Live")).toHaveFocus();
await userEvent.click(option(screen, "Archived"), { force: true });
expect(await isOpen(screen)).toBe(true);
await expect.element(button(screen)).toHaveTextContent("Live");Typing moves to the option that starts with what was typed
const screen = await render(<Status defaultValue="draft" />);
await userEvent.click(button(screen));
await userEvent.keyboard("li");
await expect.element(option(screen, "Live")).toHaveFocus();The pointer moves the highlight with it
const screen = await render(<Status defaultValue="draft" />);
await userEvent.click(button(screen));
await userEvent.hover(option(screen, "Live"));
await expect.element(option(screen, "Live")).toHaveFocus();A space is part of a word being typed, and chooses nothing
const screen = await render(<Status defaultValue="draft" />);
await userEvent.click(button(screen));
await userEvent.keyboard("in r");
await expect.element(option(screen, "In review")).toHaveFocus();
expect(await isOpen(screen)).toBe(true);
await expect.element(button(screen)).toHaveTextContent("Draft");A key held down opens the list and chooses nothing
const screen = await render(<Status />);
await focusButton(screen);
await userEvent.keyboard("{Enter}");
await expect.element(option(screen, "Draft")).toHaveFocus();
// The key repeats first on the button, if the list has yet to take focus.
const held = new KeyboardEvent("keydown", {
key: "Enter",
repeat: true,
bubbles: true,
cancelable: true,
});
button(screen).element().dispatchEvent(held);
expect(held.defaultPrevented).toBe(true);
document.activeElement!.dispatchEvent(
new KeyboardEvent("keydown", { key: "Enter", repeat: true, bubbles: true, cancelable: true }),
);
await new Promise((resolve) => setTimeout(resolve, 50));
expect(await isOpen(screen)).toBe(true);
await expect.element(button(screen)).toHaveTextContent("Choose…");Pressing the button, dragging to an option and releasing chooses itChromium, Firefox, WebKit
Not yet written.
Pressing the button and releasing it leaves the list openChromium, Firefox, WebKit
Not yet written.
Typing on the closed button changes the choice without opening the list
const screen = await render(<Status defaultValue="draft" />);
await focusButton(screen);
await userEvent.keyboard("l");
await expect.element(button(screen)).toHaveTextContent("Live");
expect(await isOpen(screen)).toBe(false);
await userEvent.keyboard("{Escape}in r");
await expect.element(button(screen)).toHaveTextContent("In review");A click that no press came before, as assistive technology makes, chooses the option
const screen = await render(<Status defaultValue="draft" />);
await userEvent.click(button(screen));
(option(screen, "Live").element() as HTMLElement).click();
await expect.element(button(screen)).toHaveTextContent("Live");
await expect.poll(() => isOpen(screen)).toBe(false);Dismissal
Escape closes the list without choosing, and returns focus to the button
const screen = await render(<Status defaultValue="draft" />);
await userEvent.click(button(screen));
await userEvent.keyboard("{ArrowDown}{Escape}");
await expect.poll(() => isOpen(screen)).toBe(false);
await expect.element(button(screen)).toHaveTextContent("Draft");
await expect.element(button(screen)).toHaveFocus();Tab closes the list without choosing, and moves on
const screen = await render(<Status defaultValue="draft" />);
await userEvent.click(button(screen));
await userEvent.keyboard("{ArrowDown}{Tab}");
await expect.poll(() => isOpen(screen)).toBe(false);
await expect.element(button(screen)).toHaveTextContent("Draft");
await expect.element(screen.getByRole("textbox", { name: "After" })).toHaveFocus();A press outside closes the list without choosing
const screen = await render(<Status defaultValue="draft" />);
await userEvent.click(button(screen));
// The open list covers what is below the button; press what is above it.
// On a phone it lies under the scrim, inert: the press is aimed at it regardless.
await userEvent.click(screen.getByText("Elsewhere"), { force: hasTouch });
await expect.poll(() => isOpen(screen)).toBe(false);
await expect.element(button(screen)).toHaveTextContent("Draft");Touch
A tap opens the list as a sheet, and a tap on an option chooses itChromium · touch, WebKit · touch
Not yet written.
API
A select that is readOnly says so, shows its choice, and does not open or change
const onValueChange = vi.fn();
const screen = await render(<Status defaultValue="draft" readOnly onValueChange={onValueChange} />);
await expect.element(button(screen)).toHaveAttribute("aria-readonly", "true");
await userEvent.click(button(screen));
await userEvent.keyboard("{Enter}{ArrowDown}l");
expect(await isOpen(screen)).toBe(false);
await expect.element(button(screen)).toHaveTextContent("Draft");
expect(onValueChange).not.toHaveBeenCalled();With a name, the chosen value is submitted with its form
const screen = await render(
<form aria-label="Post">
<Select aria-label="Status" name="status" defaultValue="draft">
<SelectItem value="draft">Draft</SelectItem>
<SelectItem value="live">Live</SelectItem>
</Select>
</form>,
);
const form = screen.getByRole("form").element() as HTMLFormElement;
expect(new FormData(form).get("status")).toBe("draft");
await userEvent.click(screen.getByRole("combobox"));
await userEvent.click(screen.getByRole("option", { name: "Live" }));
await expect.poll(() => new FormData(form).get("status")).toBe("live");onValueChange hears the chosen value
const onValueChange = vi.fn();
const screen = await render(<Status onValueChange={onValueChange} />);
await userEvent.click(button(screen));
await userEvent.click(option(screen, "In review"));
expect(onValueChange.mock.calls).toEqual([["review"]]);A controlled value changes only when its owner changes it
function Controlled() {
const [value, setValue] = useState<string | null>("draft");
return <Status value={value} onValueChange={(next) => setValue(next === "live" ? value : next)} />;
}
const screen = await render(<Controlled />);
await userEvent.click(button(screen));
await userEvent.click(option(screen, "Live"));
await expect.element(button(screen)).toHaveTextContent("Draft");
await userEvent.click(button(screen));
await userEvent.click(option(screen, "In review"));
await expect.element(button(screen)).toHaveTextContent("In review");React
Install
npx shadcn@latest add https://ui.publictheta.com/r/select.jsonSource
src/components/select/select.tsx
"use client";
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { ComponentProps, ReactElement, ReactNode } from "react";
import { focusRingEdgeClassName, invalidClassName } from "../../lib/focus-class.ts";
import { mergeRefs } from "../../lib/merge-refs.ts";
import { overlaySurfaceClassName } from "../../lib/overlay-class.ts";
import { cn } from "../../lib/utils.ts";
import { useOverlay } from "../../primitives/overlay.ts";
import { matchTypeahead, useTypeahead } from "../../primitives/typeahead.ts";
import { useControllableState } from "../../primitives/use-controllable-state.ts";
import { Listbox, ListboxGroup, ListboxItem } from "../listbox/listbox.tsx";
export type SelectProps = Omit<
ComponentProps<"button">,
"value" | "defaultValue" | "onChange" | "children"
> & {
value?: string | null;
defaultValue?: string | null;
onValueChange?: (value: string) => void;
/** Hears the list opening and closing. */
onOpenChange?: (open: boolean) => void;
/** Shown when nothing is chosen. */
placeholder?: string;
/** The name the choice is submitted under, with a form. */
name?: string;
/** Whether the choice is shown and cannot be changed: the list does not open. */
readOnly?: boolean;
/** The options: `SelectItem`s, in `SelectGroup`s if they fall into kinds. */
children?: ReactNode;
};
/**
* A choice of one among several, shown as a button holding the current choice,
* which opens the options in a list. A listbox in an overlay: the list is the
* library's `Listbox`, and its options are `SelectItem`s.
*/
export function Select({
value: valueProp,
defaultValue = null,
onValueChange,
onOpenChange,
placeholder = "",
className,
ref,
style,
children,
name,
form,
disabled,
readOnly = false,
onClick,
onKeyDown,
onPointerDown,
"aria-label": label,
"aria-labelledby": labelledBy,
...props
}: SelectProps): ReactElement {
const [value, setValue] = useControllableState<string | null>(
valueProp,
defaultValue,
onValueChange as ((value: string | null) => void) | undefined,
);
// Focus goes to the list's own tab stop, which is the chosen option, or the first.
// A press outside the list dismisses it and does nothing more, as with a menu.
const overlay = useOverlay({ placement: "block-end", onOpenChange, modal: true });
const listId = useId();
const trigger = useRef<HTMLButtonElement>(null);
const pointerType = useRef("");
// What the chosen option says, read from the option itself once it is rendered.
const [text, setText] = useState<string | null>(null);
const [content, setContent] = useState<HTMLElement | null>(null);
useLayoutEffect(() => {
const option =
value === null ? null : content?.querySelector<HTMLElement>(`[data-key="${CSS.escape(value)}"]`);
// oxlint-disable-next-line react/set-state-in-effect -- synchronising with the committed DOM
setText(option?.dataset.textValue ?? option?.textContent ?? null);
// `children` is not read: it is the signal that an option's text may have changed.
// oxlint-disable-next-line react/exhaustive-effect-dependencies
}, [value, content, children]);
function choose(next: string | null): void {
if (next !== null) setValue(next);
overlay.setOpen(false);
}
const { open, setOpen } = overlay;
// A list cannot stay open over a control that can no longer be used.
useEffect(() => {
if (disabled && open) setOpen(false);
}, [disabled, open, setOpen]);
// A `<label>` names the button; the list takes the same name.
const [labelText, setLabelText] = useState<string>();
useLayoutEffect(() => {
// oxlint-disable-next-line react/set-state-in-effect -- read from the DOM once the list is to be shown
if (open) setLabelText(trigger.current?.labels[0]?.textContent ?? undefined);
}, [open]);
// Closed, typing chooses: the option that starts with what was typed.
const typeahead = useTypeahead(
useCallback(
(query: string) => {
const options = Array.from(
content?.querySelectorAll<HTMLElement>('[role="option"]:not([aria-disabled])') ?? [],
(option) => ({
key: option.dataset.key ?? "",
text: option.dataset.textValue ?? option.textContent ?? "",
}),
);
const match = matchTypeahead(options, query, value);
if (match !== null) setValue(match);
},
[content, value, setValue],
),
);
const triggerRef = useMemo(
() => mergeRefs(overlay.triggerProps.ref, trigger, ref),
[overlay.triggerProps.ref, ref],
);
const contentRef = useMemo(
() => mergeRefs(overlay.contentProps.ref, setContent),
[overlay.contentProps.ref],
);
return (
<>
<button
type="button"
role="combobox"
aria-haspopup="listbox"
aria-label={label}
aria-labelledby={labelledBy}
{...overlay.triggerProps}
// Stated again where a linter can see them: a combobox requires both.
aria-expanded={overlay.open}
aria-controls={listId}
aria-readonly={readOnly || undefined}
ref={triggerRef}
disabled={disabled}
form={form}
style={{ ...overlay.triggerProps.style, ...style }}
data-placeholder={text === null ? "" : undefined}
// A mouse opens on press, so the pointer can be dragged to an option and released.
onPointerDown={(event) => {
onPointerDown?.(event);
if (event.defaultPrevented || readOnly) return;
pointerType.current = event.pointerType;
// On a Mac, Ctrl-click is for a context menu.
if (event.pointerType === "mouse" && event.button === 0 && !event.ctrlKey) {
overlay.setOpen(!overlay.open, "first");
}
}}
// Keep the press from taking focus back from the list that has just opened.
onMouseDown={(event) => {
if (event.button === 0) event.preventDefault();
}}
onClick={(event) => {
onClick?.(event);
if (event.defaultPrevented || readOnly) return;
// The press opened it already — unless no press came before this click.
const pressed = pointerType.current === "mouse" && event.detail !== 0;
pointerType.current = "";
if (!pressed) overlay.setOpen(!overlay.open, "first");
}}
onKeyDown={(event) => {
onKeyDown?.(event);
if (event.defaultPrevented || readOnly) return;
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
overlay.setOpen(true, "first");
} else if (event.repeat && (event.key === "Enter" || event.key === " ")) {
// A key held down opens the list once; it does not go on to choose from it.
event.preventDefault();
} else if (!overlay.open && typeahead(event)) {
event.preventDefault();
}
}}
className={cn(
"inline-flex h-8 w-full min-w-0 items-center justify-between gap-2 rounded-md border border-border bg-surface px-2 text-sm text-fg-strong select-none",
"[-webkit-tap-highlight-color:transparent]",
"enabled:hover:bg-surface-muted enabled:active:bg-surface-strong aria-expanded:bg-surface-strong",
"disabled:border-border-muted disabled:bg-surface-muted disabled:text-fg-disabled",
focusRingEdgeClassName,
invalidClassName,
"data-placeholder:text-fg-muted pointer-coarse:h-11 pointer-coarse:text-base",
className,
)}
{...props}
>
<span className="truncate" title={text ?? undefined}>
{text ?? placeholder}
</span>
<svg
aria-hidden
viewBox="0 0 16 16"
className="size-3.5 shrink-0 text-fg-muted"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
>
<path d="m4.5 6 3.5 3.5L11.5 6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<div
role="presentation"
{...overlay.contentProps}
ref={contentRef}
tabIndex={-1}
onKeyDown={(event) => {
// Tab moves on from where the list was, choosing nothing. Space has by
// now chosen the option it was pressed on, even if that changed nothing.
if (event.key === "Tab" || event.key === " ") overlay.setOpen(false);
}}
onClick={(event) => {
// A click on the option already chosen changes nothing, and still closes.
if ((event.target as Element).closest('[role="option"]:not([aria-disabled])'))
overlay.setOpen(false);
}}
className={cn(
overlaySurfaceClassName,
"mt-1 min-w-[anchor-size(width)] p-1 pointer-coarse:max-sm:pb-[max(0.25rem,env(safe-area-inset-bottom))]",
)}
>
<Listbox
id={listId}
aria-label={label ?? (labelledBy === undefined ? labelText : undefined)}
aria-labelledby={labelledBy}
value={value}
// Moving through the options chooses nothing; a click, Space or Enter does.
selectionBehavior="toggle"
disallowEmptySelection
focusFollowsPointer
onValueChange={choose}
onAction={choose}
className="max-h-72 rounded-none border-0 bg-transparent p-0"
>
{children}
</Listbox>
</div>
{name !== undefined && (
<input type="hidden" name={name} form={form} value={value ?? ""} disabled={disabled} />
)}
</>
);
}
export { ListboxItem as SelectItem, ListboxGroup as SelectGroup };
export type {
ListboxItemProps as SelectItemProps,
ListboxGroupProps as SelectGroupProps,
} from "../listbox/listbox.tsx";