Grouped select
The house select: placeholder, labelled option groups, disabled items and a width.
npx shadcn@latest add https://ui.goodtabs.co/r/grouped-select.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/grouped-select
Timezones in two labelled groups with London chosen.
With a disabled option
A placeholder, a fixed width and one region that cannot be chosen yet.
In a form row
Changes apply at the next billing date.
Full width under a Label, wired through id and htmlFor.
Usage
import { GroupedSelect } from "@/components/ui/grouped-select"
<GroupedSelect
placeholder="Timezone"
width="100%"
groups={[{ label: "Europe", options: [{ value: "europe/london", label: "London" }] }]}
onValueChange={setTimezone}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| groups | { label: string; options: { value: string; label: string; disabled?: boolean }[] }[] | The groups, each with a heading and its options. | |
| value, defaultValue, onValueChange | radix Select props | Controlled or uncontrolled, as in shadcn select. | |
| placeholder | string | "Select" | Text shown before a value is chosen. |
| width | string | Width of the trigger, any CSS length such as "200px" or "100%". Fits its content by default. | |
| size | "sm" | "default" | "default" | Trigger height: 28px or 32px. |
Source
grouped-select/grouped-select.tsx
"use client";
import * as React from "react";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
export type SelectOption = {
/** The key reported in onValueChange. */
value: string;
/** Item text, also shown in the trigger. */
label: string;
/** Greys the item out and keeps it from being chosen. */
disabled?: boolean;
};
export type SelectOptionGroup = {
/** Heading above the group. */
label: string;
options: SelectOption[];
};
type RootProps = React.ComponentProps<typeof Select>;
type GroupedSelectProps = Omit<React.ComponentProps<typeof SelectTrigger>, "value" | "defaultValue" | "children"> &
Pick<RootProps, "value" | "defaultValue" | "onValueChange" | "name" | "disabled" | "required"> & {
/** The groups, each with a heading and its options. */
groups: SelectOptionGroup[];
/** Text shown before a value is chosen. */
placeholder?: string;
/** Width of the trigger, any CSS length. Fits its content by default. */
width?: string;
/** Trigger height: sm is 28px, default is 32px. */
size?: "sm" | "default";
};
/**
* The house select: a placeholder, labelled option groups, disabled items and a width.
* Radix Select underneath, so value, defaultValue and onValueChange work as in shadcn.
*/
export function GroupedSelect({
groups,
placeholder = "Select",
width,
size = "default",
value,
defaultValue,
onValueChange,
name,
disabled,
required,
className,
style,
...props
}: GroupedSelectProps) {
return (
<Select
value={value}
defaultValue={defaultValue}
onValueChange={onValueChange}
name={name}
disabled={disabled}
required={required}
>
<SelectTrigger
size={size}
className={cn("min-w-32", width && "w-(--grouped-select-width)", className)}
style={{ "--grouped-select-width": width, ...style } as React.CSSProperties}
{...props}
>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent position="popper" className="rounded-md">
{groups.map((group, i) => (
<React.Fragment key={group.label}>
{i > 0 && <SelectSeparator />}
<SelectGroup>
<SelectLabel>{group.label}</SelectLabel>
{group.options.map((option) => (
<SelectItem
key={option.value}
value={option.value}
disabled={option.disabled}
className="h-8 rounded-sm focus:bg-muted focus:text-foreground"
>
{option.label}
</SelectItem>
))}
</SelectGroup>
</React.Fragment>
))}
</SelectContent>
</Select>
);
}
For AI agents
Hand your agent this page, the markdown at /components/grouped-select/prompt.md, or the Copy AI prompt button above.
When to use
- For one choice from a list long enough to need headings
- In forms and settings panels, under a Label
- As the default select everywhere, so every select on the site looks the same
When not to use
- For lists that need search or free text, use a combobox
- For two to five choices that should all be visible, use segmented tabs or radio cards
- For picking several values, use a filter menu
Agent notes
- Radix Select underneath: value, defaultValue, onValueChange, name and disabled work as in shadcn select
- width is a CSS length; without it the trigger fits its content, so set width="100%" in form rows
- A single group still needs a label; pass one group for a flat list
- Name the control with a Label and htmlFor plus an id, or an aria-label