npx shadcn@latest add https://sdk-components.thesqd.com/r/toggle-chips.jsonimport { ToggleChips } from "@/components/ui/toggle-chips";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)}
/>
);
}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)| Prop | Type | Default | Description |
|---|---|---|---|
items | ToggleChipsItem[] | — | 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. |
value | string | — | Controlled selection in single-select mode. Pair with `onValueChange`. |
defaultValue | string | — | Uncontrolled initial selection in single-select mode. |
onValueChange | (value: string | null) => void | — | Fires with the newly selected key — `null` when `deselectable` clears the row. |
multiple | boolean | false | Chips toggle independently instead of behaving as a radio row. |
values | string[] | — | Controlled selection in multi-select mode. |
defaultValues | string[] | [] | Uncontrolled initial selection in multi-select mode. |
onValuesChange | (values: string[]) => void | — | Fires with the full selected set on every toggle and on clear. |
deselectable | boolean | false | Single-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. |
dividerAfter | string | — | Draws a hairline divider after the chip with this key — e.g. to split an “All” chip off from the categories. |
clearable | boolean | false | Multi-select only — appends a ghost Clear button once anything is selected. |
clearLabel | string | "Clear" | Label on that Clear button. |
wrap | boolean | true (false for tabs) | Wrap onto multiple lines. `false` keeps one row that scrolls horizontally, masked with `scroll-fade-x`. |
disabled | boolean | false | Disables 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. |
addLabel | string | "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. |
maxItems | number | — | 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. |
statusId | string | — | 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. |
className | string | — | Extend or override the row layout classes. |
aria-label | string | — | Names the group for assistive tech. |
| Prop | Type | Default | Description |
|---|---|---|---|
key | string | — | Stable id reported through the change handlers. |
label | ReactNode | — | Chip text. |
icon | ComponentType<{ className?: string }> | — | Leading icon component — Phosphor, Lucide, or anything taking a className. |
count | number | — | Trailing count, rendered as an outline Badge with a little air to its left. |
disabled | boolean | false | Disables this chip only. |
pinned | boolean | false | `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. |
removable | boolean | true | `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. |
statusLabel | string | "Pending" / "Done" | What a screen reader hears for the status (it's announced after the label; nothing is drawn). |