Skip to content
goodtabs UI
BrowseBrowse components

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

PropTypeDefaultDescription
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, defaultValuestring | string[]A string for single, an array for multiple. Controlled or uncontrolled.
onValueChange(value: string | string[]) => voidFires 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.
disabledbooleanfalseDisables 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