Skip to content
garn

BadgeSelect

A single-value pick-to-change control styled as a Badge — a tinted lozenge whose tone follows the selected option, opening a single-select list to change the value.

Stablev0.4.2added in v0.1.0@garn/ui/badge-select
On this page

Default

A status picker — the trigger tone follows the selected value.

Tone × appearance

Every tone rendered against every appearance. Hover a cell for the data attributes it emits; click the copy icon for that combination's JSX.

Shapes

sharp · rounded · pill.

Sizes

sm · md.

Leading

Custom leading icons (priority) mirrored into the trigger.

Count

Per-option `count` tallies (tone-matched Counters), mirrored into the trigger.

Assignee

Avatars as leading content — an assignee picker.

Grouped

Sectioned options (BadgeSelectGroup / Label / Separator).

Loading

A pending change — a Spinner in the trigger, disabled until done.

Options Prop

Data-driven options via the `options` prop.

Placeholder Disabled

A placeholder (unset), a disabled control, and a disabled option.

Data Rich

A status cell per row in a data-rich table — the core use case.

Installation

garn is copy-in — the garn CLI writes this component's source into your project, so you own and can edit it. It resolves any base files and installs the npm dependencies below for you.

terminal
npx garn-ui add badge-select
First time? Set up garn in your project
Registry
badge-select
Deps
class-variance-authority@radix-ui/react-selectlucide-react
Registry deps
utilsuse-controllable-stateselectcounterspinner

Import

import { BadgeSelectItem, BadgeSelectGroup, BadgeSelectLabel, BadgeSelectSeparator } from "@garn/ui/badge-select";

Anatomy

The parts this component is built from — compose them to assemble it.

BadgeSelectItemRequired[data-slot="badge-select-item"]

A single option (a Radix Select.Item). Props: value (required), tone (the trigger mirrors it when selected; also colours the row dot), leading (icon/avatar/badge — mirrored to the trigger), trailing (row-only meta), count (a tone-matched Counter, row-only), disabled. A leading tone dot is shown by default when tone is set.

BadgeSelectGroup[data-slot="badge-select-group"]

Wraps related options into a labelled section (a Radix Select.Group).

BadgeSelectLabel[data-slot="badge-select-label"]

A section heading inside a BadgeSelectGroup.

BadgeSelectSeparator[data-slot="badge-select-separator"]

A divider between option groups.

Props

The component's public props and their types.

appearance
Description
No description.
Type
"framed" | "outline" | "soft"
Default
soft
shape
Description
No description.
Type
"pill" | "rounded" | "sharp"
Default
rounded
size
Description
No description.
Type
"md" | "sm"
Default
md
tone
Description
No description.
Type
"brand" | "danger" | "discovery" | "info" | "neutral" | "success" | "warning"
Default
neutral
"aria-label"
Description
No description.
Type
string
"aria-labelledby"
Description
No description.
Type
string
children
Description
BadgeSelectItem children.
Type
React.ReactNode
className
Description
Applied to the trigger (the visible root).
Type
string
contentClassName
Description
Applied to the popup content.
Type
string
id
Description
No description.
Type
string
loading
Description
Pending state: a spinner replaces the trigger leading and the control is disabled.
Type
boolean
Default
false
options
Description
Data-driven options — an alternative to compound children (one or the other).
Type
BadgeSelectOption[]
placeholder
Description
Shown when nothing is selected.
Type
React.ReactNode
Default
"Select…"
ref
Description
No description.
Type
React.Ref<HTMLButtonElement>

Plus 14 inherited native <button> attributes.

BadgeSelectItem

children
Description
No description.
Type
React.ReactNode
count
Description
A trailing count, rendered as a tone-matched Counter. Row only.
Type
number
leading
Description
Leading content — icon / avatar / badge / dot. Mirrors into the trigger when selected.
Type
React.ReactNode
tone
Description
Tone the trigger mirrors when this option is selected; also colours the row dot.
Type
BadgeSelectTone
trailing
Description
Trailing content (Counter / Badge / Kbd …). Shown in the row only, not the trigger.
Type
React.ReactNode
valueRequired
Description
Stable value for this option (required).
Type
string

Plus 282 inherited native HTML attributes.

Styling

Target these data-slots and states, and remap these tokens, to restyle without forking the component.

[data-slot="badge-select-trigger"][data-slot="badge-select-trigger-leading"][data-slot="badge-select-trigger-label"][data-slot="badge-select-trigger-count"]

States

SelectorState
[data-state=open]open
[data-placeholder]placeholder
[data-loading]loading
[data-disabled]disabled
PropertyTokenTier
fill--garn-badge-brand-bgbrandablesemantic

Private instance vars (never externalize): --t-soft-bg

When to use

A single value you pick to change, displayed as a badge: a status / priority / category / assignee cell in a table, board, or inline-edit field. The trigger reads as a tinted lozenge whose tone follows the chosen value.

Reach for something else when

  • A static, non-interactive label (use Badge)
  • a numeric count (use Counter)
  • removable or multi-select chips (use Tag / TagGroup)
  • a few mutually-exclusive choices shown at once (use RadioGroup)
  • a long searchable list (use Select or a Command popover)
  • a menu of actions/transitions (use a Button/Badge trigger + DropdownMenu).

Overview

BadgeSelect is a single value you pick that reads as a badge — a status / priority / category / assignee cell you edit in place on a table, board, or detail view. The trigger is a tinted lozenge whose tone follows the chosen value; opening it reveals BadgeSelectItems (each with a tone dot), optionally organized with Group/Label/Separator. It's the editable cousin of Badge: use it when the badge's value is set by the user, Select when the control is a normal field, and Badge when the value is read-only.

Guidelines

Use it where a badge needs to become editable in place. It shines as an inline-edit cell — a status or priority you click to change without leaving the row. If the field belongs in a form with a label, a normal Select reads better.

Let the chosen tone mirror its meaning. Give each option a tone that matches its semantics (done = success, blocked = danger) so the resting lozenge communicates state at a glance, and keep that mapping consistent across the app.

Keep the option set short and named. It's a pick-one from a small, known set; for a long or searchable list use a Combobox. Ensure the trigger has an accessible name and that selection is fully keyboard-operable.

Best practices

Do
  • Give the trigger an accessible name (aria-label / aria-labelledby, or a <label>).
  • Set a tone on each option so the trigger colour follows the selected value.
  • Use the badge family tone vocabulary so a status reads the same across Badge / Counter / Tag / BadgeSelect.
  • Use count for a per-option tally (a tone-matched Counter), shown in the row and mirrored to the trigger.
  • Use name for form submission, and loading for a pending async change.
Don't
  • Don't use it for multi-select or removable chips — that's Tag / TagGroup.
  • Don't rely on the placeholder as the accessible name.
  • Don't reach for raw colors; tone resolves through the --garn-badge-* tokens (edit dark mode in the token layer).
  • Don't use it for a long list that needs search — compose Select / Command instead.

Content guidelines

  • Keep option labels short — they become the trigger lozenge.
  • Prefer a leading dot/icon (or tone) to distinguish options over color alone.

Troubleshooting

The trigger colour doesn't follow the selected value.

Cause. The options have no tone.

Fix. Set a tone on each BadgeSelectItem — the trigger always mirrors the selected option's tone.

A dev console warns the trigger has no accessible name.

Cause. No aria-label, aria-labelledby, or id (for a <label htmlFor>) on BadgeSelect; the placeholder is not a name.

Fix. Pass aria-label (or wire a <label htmlFor> to the trigger id).

Trying to make it multi-select or removable.

Cause. BadgeSelect is single-value only (listbox semantics).

Fix. Use Tag / TagGroup for removable or multi-select chips.

A count shows in the row but you expected it everywhere — or vice versa.

Cause. count (and trailing) render in the option row; only count is also mirrored into the trigger. Generic trailing is row-only.

Fix. Use count for a value mirrored into the trigger; use trailing for row-only secondary meta.

An avatar overflows the trigger.

Cause. An Avatar larger than the 20–28px trigger was used in the leading slot.

Fix. Use the Avatar xs size (24px) in the leading slot.

Editing dark-mode trigger colours in the component has no effect.

Cause. Tones resolve through the --garn-badge-* component tokens; dark values live in the token layer.

Fix. Adjust dark mode in the garn theme CSS (.dark block) — never with raw ramps in badge-select.tsx.

Accessibility

Role
combobox
Focus
2px ring in --garn-ring on the trigger; focus and the listbox roving are managed by Radix Select.

Accessibility requirements

warn

Give the trigger an accessible name (aria-label / aria-labelledby, or a <label htmlFor> matching the trigger id). The placeholder doesn't count.

when the BadgeSelect trigger has no `aria-label`, `aria-labelledby`, or `id` (for an external <label htmlFor>)

A1.4.1Use of ColorAA1.4.3Contrast (Minimum)A2.1.1KeyboardAA2.4.7Focus VisibleA4.1.2Name, Role, Value
  • The trigger is a combobox (Radix Select.Trigger) that conveys the current value in its collapsed state — listbox, not menu, semantics — so it suits a control whose job is to display a value.
  • Opening exposes a role=listbox of role=option items; the selected option carries aria-selected and a trailing check. Single-select only.
  • Keyboard (inherited from Select): Enter / Space / ArrowUp / ArrowDown and typeahead open and move; Enter / Space pick; Esc closes; Home / End jump.
  • Give the trigger an accessible name via `aria-label`, `aria-labelledby`, or a <label htmlFor> matching the trigger `id` — the placeholder is not a name. The selected value is the trigger's text content.
  • Setting `name` submits the value via a hidden native <select> (form integration, inherited from Select).