Skip to content
goodtabs UI
BrowseBrowse components

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

PropTypeDefaultDescription
groups{ label: string; options: { value: string; label: string; disabled?: boolean }[] }[]The groups, each with a heading and its options.
value, defaultValue, onValueChangeradix Select propsControlled or uncontrolled, as in shadcn select.
placeholderstring"Select"Text shown before a value is chosen.
widthstringWidth 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