Option group
Single or multi choice pills on a muted track or laid out loose, from an options array.
npx shadcn@latest add https://ui.goodtabs.co/r/option-group.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/option-group
Single choice on a track. Arrow keys move, Space or Enter picks.
Multiple, loose pills
2 of 4 selected
type multiple as a filter row, controlled, with the count below.
With icons
The sm size with a lucide icon before each label.
Usage
import { OptionGroup } from "@/components/ui/option-group"
<OptionGroup
aria-label="Billing period"
value={period}
onValueChange={setPeriod}
options={[{ value: "monthly", label: "Monthly" }, { value: "yearly", label: "Yearly" }]}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| options | { value: string; label: ReactNode; icon?: ReactNode; disabled?: boolean }[] | The choices, in order. | |
| type | "single" | "multiple" | "single" | One choice at a time, or any number. Sets the shape of value. |
| value, defaultValue | string | string[] | A string for single, an array for multiple. Controlled or uncontrolled. | |
| onValueChange | (value: string | string[]) => void | Fires with the new value, typed to match type. | |
| variant | "track" | "pills" | "track" | On a muted track like segmented tabs, or loose pills with a gap. |
| size | "sm" | "default" | "default" | 28px or 32px pills. |
| disabled | boolean | false | Disables every option. |
Source
option-group/option-group.tsx
"use client";
import * as React from "react";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { cn } from "@/lib/utils";
export type OptionGroupOption = {
value: string;
label: React.ReactNode;
/** Optional icon before the label, usually a lucide icon. */
icon?: React.ReactNode;
disabled?: boolean;
};
type Variant = "track" | "pills";
type Size = "sm" | "default";
type BaseProps = {
/** The choices, in order. */
options: OptionGroupOption[];
/** "track" sits the pills on a muted track; "pills" lays them out loose. */
variant?: Variant;
/** Pill height. sm is 28px on a track of 28px, default is 32px. */
size?: Size;
/** Disables every option. */
disabled?: boolean;
/** Accessible name of the group. */
"aria-label"?: string;
className?: string;
};
type SingleProps = BaseProps & {
/** One choice at a time. */
type?: "single";
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
};
type MultipleProps = BaseProps & {
/** Any number of choices. */
type: "multiple";
value?: string[];
defaultValue?: string[];
onValueChange?: (value: string[]) => void;
};
export type OptionGroupProps = SingleProps | MultipleProps;
/**
* Single or multi choice pills on shadcn's ToggleGroup.
* Selected is the primary fill; the rest is quiet text on a muted track or loose pills.
*/
export function OptionGroup({
options,
variant = "track",
size = "default",
disabled,
className,
type = "single",
value,
defaultValue,
onValueChange,
...props
}: OptionGroupProps) {
const group = { type, value, defaultValue, onValueChange } as React.ComponentProps<typeof ToggleGroup>;
const track = variant === "track";
return (
<ToggleGroup
{...group}
{...props}
data-slot="option-group"
data-variant={variant}
disabled={disabled}
className={cn(
"max-w-full overflow-x-auto rounded-full",
track ? cn("gap-px bg-muted p-0.5", size === "sm" ? "h-7" : "h-8") : "gap-1",
className,
)}
>
{options.map((option) => (
<ToggleGroupItem
key={option.value}
value={option.value}
disabled={option.disabled}
aria-label={typeof option.label === "string" ? undefined : option.value}
className={cn(
"gap-1.5 rounded-full px-3 whitespace-nowrap transition-colors duration-100 ease-out",
track ? "h-full min-w-0 text-foreground/70 hover:bg-background/60" : size === "sm" ? "h-7" : "h-8",
!track && "text-muted-foreground hover:bg-muted",
size === "sm" ? "text-xs" : "text-sm",
"hover:text-foreground aria-pressed:bg-primary aria-pressed:text-primary-foreground data-[state=on]:bg-primary data-[state=on]:text-primary-foreground data-[state=on]:hover:bg-primary data-[state=on]:hover:text-primary-foreground",
)}
>
{option.icon && (
<span aria-hidden="true" className={cn("shrink-0", size === "sm" ? "[&_svg]:size-3.5" : "[&_svg]:size-4")}>
{option.icon}
</span>
)}
{option.label}
</ToggleGroupItem>
))}
</ToggleGroup>
);
}
For AI agents
Hand your agent this page, the markdown at /components/option-group/prompt.md, or the Copy AI prompt button above.
When to use
- Two to six short choices that should all be visible: period, layout, plan, pricing filter
- Multi select filters where a select would hide the current state
- Inside a toolbar or a card header next to a title
When not to use
- Switching views with their own content panels: use segmented tabs
- Long labels or more than six options: use a select or checkboxes
- One on or off setting: use a switch
Agent notes
- Built on shadcn's ToggleGroup, so it is radix underneath: single lets the user deselect by clicking the active pill
- onValueChange is a function, so a controlled OptionGroup must be rendered from a client component
- Pass aria-label on the group; a non string label on an option falls back to its value as the aria-label
- Selected is bg-primary text-primary-foreground, unselected is quiet text; nothing else is coloured