Toggle chips

A row of selectable chips for filtering a list by one category or several.

Installation
$Terminal
npx shadcn@latest add https://sdk-components.thesqd.com/r/toggle-chips.json
Usage
Import the master file and pass `items`. Selection is uncontrolled until you pass `value` (or `values` in multi-select mode).
TSImport
import { ToggleChips } from "@/components/ui/toggle-chips";
TSExample
import { ToggleChips } from "@/components/ui/toggle-chips";

export function Example() {
  return (
    <ToggleChips
      items={[
        { key: "all", label: "All projects" },
        { key: "design", label: "Design" },
        { key: "video", label: "Video" },
      ]}
      defaultValue="all"
      onValueChange={(key) => console.log(key)}
    />
  );
}
Composition
Anatomy of the ToggleChips primitive.
ToggleChips                       (single component — pass everything as props)
├── variant="chips | tabs"         (filter row · browser-tab strip)
├── items={[{ key, label, icon?, count?, disabled?, pinned?, status?, statusLabel? }]}
├── value / defaultValue / onValueChange        (single-select)
├── multiple + values / defaultValues / onValuesChange
├── deselectable={boolean}         (single-select: click the chip to clear)
├── size="xs | sm | default | lg"  (chips 1.75–2.5rem · tabs 1.5–2.25rem)
├── shape="rounded | pill"         (rounded tightens on sm/xs)
├── surface="card | outline | ghost"
├── activeVariant="default | secondary | soft | coral | destructive-solid"
├── dividerAfter="<key>"           (hairline after one chip)
├── clearable + clearLabel         (multi-select only)
├── wrap={boolean}                 (wrap, or one scrolling row)
├── disabled={boolean}             (whole group)
├── onClose={(key) => …}           (tabs: close button + middle-click)
├── onAdd={() => …} + addLabel     (tabs: the trailing “+”)
├── renderClose={({ key, close, button }) => …}   (wrap the ×)
├── maxItems + onMaxItemsReached   (cap, and what to say at it)
├── menuItems={(key) => […]}       (right-click DropdownMenu, per chip)
├── onReorder={(keys) => …}        (drag to reorder)
└── statusId="<row>"               (toggleChipStatus.set / clear / track from anywhere)
Default
Single-select chips on the card surface. `deselectable` lets a second click on the active chip clear the row.
Icons and counts
Each item takes an optional icon component (Phosphor duotone here) and a count, rendered as an outline badge.
Multi-select
`multiple` turns the row into independent toggles. `clearable` appends a Clear button, `dividerAfter` splits the “All” chip off from the categories.
Surfaces
`card` is the hairline-on-`bg-card` surface the shell dock uses, `outline` the standard button outline, `ghost` paints only when selected.
Shapes
`rounded` matches card corners; `pill` is fully round.
Sizes
Four heights — 1.75rem, 2rem, 2.25rem, and 2.5rem.
Browser tabs
`variant="tabs"` is the desktop shell's tab strip: the active tab is a raised card surface in full-weight text rather than a brand fill, close buttons appear on hover and on the active tab, middle-click closes, pinned tabs swap the cross for a pin, and the row scrolls instead of wrapping. Hover is the outline chip's, and `size` works the same way here. This demo also wires the rest of the tab vocabulary: drag to reorder, right-click for a menu, and an 8-tab cap that raises a notification instead of opening a ninth.
One scrolling row
`wrap={false}` keeps every chip on one line and scrolls, with the `scroll-fade-x` mask hinting at what is off-screen.
Disabled
Per-item via `disabled` on the item, or the whole row via `disabled` on the group.
Status, add and delete
A chip with a status takes the status badge's colours — `pending` amber with a spinner, `success` green with a check mark — and one without stays the plain card surface (Overview, Notes). Status is declared on its item, or set from anywhere through `toggleChipStatus` once the row has a `statusId`. The glyph sits where the × does, and hovering a chip swaps it for the × (transitions-dev icon swap). `track()` wraps a promise: pending while it runs, success when it resolves. `onAdd` and `onClose` put a “+” and a “×” on the row; both only ask, so here each opens a `Dialog` and the list changes on confirm.
API Reference
Props on ToggleChips and on each item.

ToggleChips

PropTypeDefaultDescription
itemsToggleChipsItem[]—The chips, in render order. See the table below.
variant"chips" | "tabs""chips"`chips` is the filter row. `tabs` is the desktop shell's browser-tab strip — a card surface instead of the brand fill, close buttons, pinned tabs, and one scrolling row.
valuestring—Controlled selection in single-select mode. Pair with `onValueChange`.
defaultValuestring—Uncontrolled initial selection in single-select mode.
onValueChange(value: string | null) => void—Fires with the newly selected key — `null` when `deselectable` clears the row.
multiplebooleanfalseChips toggle independently instead of behaving as a radio row.
valuesstring[]—Controlled selection in multi-select mode.
defaultValuesstring[][]Uncontrolled initial selection in multi-select mode.
onValuesChange(values: string[]) => void—Fires with the full selected set on every toggle and on clear.
deselectablebooleanfalseSingle-select only — clicking the selected chip clears it and reports `null`.
size"xs" | "sm" | "default" | "lg""default"Chip height: 1.75rem, 2rem, 2.25rem (the dock's card height), or 2.5rem. `xs` also drops the icon to 12px and the count badge to `xxs`. `variant="tabs"` reads the same scale — 1.5rem, 1.75rem, 2rem, 2.25rem.
shape"rounded" | "pill""rounded"`rounded` is the card/dock radius, tightened proportionally on the smaller sizes (`sm` and `xs`) so the corner never reads as a bubble; `pill` is the fully round button shape.
surface"card" | "outline" | "ghost""card"Resting surface for unselected chips — the card hairline on `bg-card`, the standard button outline, or no surface at all.
activeVariant"default" | "secondary" | "soft" | "coral" | "destructive-solid""default"Button variant painted on the selected chip.
dividerAfterstring—Draws a hairline divider after the chip with this key — e.g. to split an “All” chip off from the categories.
clearablebooleanfalseMulti-select only — appends a ghost Clear button once anything is selected.
clearLabelstring"Clear"Label on that Clear button.
wrapbooleantrue (false for tabs)Wrap onto multiple lines. `false` keeps one row that scrolls horizontally, masked with `scroll-fade-x`.
disabledbooleanfalseDisables every chip and the Clear button.
onClose(key: string) => void—Renders a close button on each unpinned chip or tab (shown on hover and on the selected one) and fires with its key on click or middle-click. It only ASKS — remove the item from `items` yourself, straight away or after a confirmation `Dialog`.
onAdd() => void—Renders a trailing “+” — the bare icon a browser tab strip draws, in tab strips and chip rows alike. It only asks: open a `Dialog`, add to `items`, or both.
addLabelstring"New tab"Accessible name for that “+” button.
renderAdd({ add, button }) => ReactNode—Wraps the “+” — return whatever stands in its place with the ready-made `button` inside (a Popover trigger, say). `add()` calls `onAdd`, or `onMaxItemsReached` at the cap. The twin of `renderClose`.
renderClose(args: { key, close, button }) => ReactNode—Wraps the close button — you get the chip's key, a `close()` that calls `onClose`, and the button itself, so a confirmation Popover can sit in front of it without re-implementing the button or its hover reveal.
maxItemsnumber—Cap on how many chips the row will open. At the cap the “+” stops calling `onAdd`.
onMaxItemsReached(max: number) => void—Fires instead of `onAdd` once `maxItems` is reached — raise a toast here. The “+” stays clickable on purpose: the point is to say why.
menuItems(key: string) => DropdownMenuItemSpec[] | null—Right-click menu for a chip — return its entries, or nothing for a chip with no menu. Rendered with the SDK DropdownMenu, anchored under the chip rather than at the pointer.
onReorder(keys: string[]) => void—Makes the row drag-reorderable (dnd-kit, horizontal) and fires with the new key order on drop. The chip itself is the handle, so a plain click still selects.
statusIdstring—Hooks the row up to `toggleChipStatus`, so anything in the app can badge a chip: `set(statusId, key, "pending" | "success", { label?, duration? })`, `clear(statusId, key?)` (no key clears the row), and `track(statusId, key, promise | () => promise, { pending?, success?, successFor? })` — pending while it runs, success once it resolves, cleared and re-thrown on rejection. `useToggleChipStatus(statusId, key)` reads it back. A store status wins over the item's own.
classNamestring—Extend or override the row layout classes.
aria-labelstring—Names the group for assistive tech.

ToggleChipsItem

PropTypeDefaultDescription
keystring—Stable id reported through the change handlers.
labelReactNode—Chip text.
iconComponentType<{ className?: string }>—Leading icon component — Phosphor, Lucide, or anything taking a className.
countnumber—Trailing count, rendered as an outline Badge with a little air to its left.
disabledbooleanfalseDisables this chip only.
pinnedbooleanfalse`variant="tabs"` — a pin glyph replaces the close button and `onClose` never fires for it.
status"pending" | "success"—Tints the whole chip in the status badge's own colours — `pending` the amber warning tint with a `SpinnerIcon`, `success` the green success tint with a check mark. The glyph sits where the × does; with `onClose`, hovering the chip swaps it for the × (transitions-dev icon swap). The selected chip always wears the active style; its glyph still swaps to the × on hover. Pending → success crossfades the glyph while the tint transitions.
removablebooleantrue`false` keeps the chip in the row when the row has `onClose`: no × on hover, and middle-click does nothing — it can only be clicked. A status glyph then trails the chip instead of sharing the ×'s slot. Pinned tabs are never removable.
statusLabelstring"Pending" / "Done"What a screen reader hears for the status (it's announced after the label; nothing is drawn).