A small toggleable tag, backed by daisyUI's badge and its style modifiers. It always renders a real <button> (or, when removable, a <span> wrapping one), and it communicates SELECTION, never state or naming.
The canon behind it. method/design-language/03-status-language.md §A1 (03-status-language.md:13, three small-tag species, never interchangeable) and method/design-language/07-toolbars-and-filters.md §A4 (07-toolbars-and-filters.md:24, a trigger that can only summarize as a count owes the reader removable chips).
Click feedback. Every chip type and variant presses on pointer-down (active:scale-[0.96]) and pops for 280ms when it toggles (animate-chipPop, keyed to the click, cleared on animation end); FieldChip's clickable shape shares the press. Owner ruling doc 59 V3: the feedback is a kit default, never something a page adds. Both are stilled under prefers-reduced-motion.
When to reach for it#
Anything the user can select, deselect, or remove: a filter value shown as a removable pill beside its control, a plan or role tag in a card toolbar, a click-to-navigate pill. Canon doc 3 §A1 draws the line precisely: "three small-tag species, never interchangeable. StatusPill/StatusDot communicate STATE. Chip communicates SELECTION... FieldChip communicates NAMING."
Do not reach for it to show a record's status (queued, running, failed). That is StatusPill or StatusDot. Do not reach for it to name a schema field or a file. That is FieldChip. Using Chip for either is called out in the canon as a defect, not a style preference. Do not reach for it to build a two-option segmented toggle either: the shipped precedent for that shape (walmart-mvp/frontend/src/features/catalog/ItemsView.tsx:62-90, ToggleChip, the "By part type / All items" switch) is a hand-rolled joined button pair, not Chip with selected. As of this pass Chip's own selected prop has no confirmed shipped call site in either app; when a two-way toggle is the actual need, the codebase's own answer has been a bespoke button pair, not this component.
Contract#
Identity:
children: the label.presets:ChipPreset | ChipPreset[](added 2026-08-14). Named partial-prop bundles resolved before prop defaults; explicit props always win, mirroring Button'sPresetProps/mergePresetsmechanism. One preset today:"applied", the ruled applied-filter shape (size="lg"plustext-sm,chip.tsxPresetProps), which FacetBar's applied chips now ride. A preset'sclassNamemerges BEFORE the caller's, so caller classes win conflicts. Presets are shorthands, never gates (AGENTS.md ground rule 8): everything a preset applies stays reachable through plain props.color:ChipColor, a locally-declared 8-way union (primary | secondary | accent | neutral | info | success | warning | error). This is field-for-field identical to the sharedSemanticColorunion that now lives atpackages/ui/src/internal/variants.ts, butChipstill declares its own copy rather than importing the shared one. The type comment (chip.types.ts:6-9) still frames this as pending a merge into a not-yet-existing shared module; that module exists now, the merge itself just has not happened. TreatChipColorandSemanticColoras interchangeable in practice, they are not interchangeable in the type system yet.variant:solid | outline | ghost | soft.solidis the unmodified default look (no extra class); the other three map to daisyUI'sbadge-outline/badge-ghost/badge-soft. The default issolid, except that a selectable chip at rest (onChangeorselectedset,selectedfalse, noonRemove) defaults tosoft, so an unselected option reads as quiet in both themes instead of near-black on the light one (owner ruling 2026-09-03). Passvariantexplicitly to override either default.size:xs | sm | md | lg.
Selection:
selected/selectedColor:selectedColoroverridescolorentirely whileselectedis true.onChange(isSelected, event): value-first, the state the click would produce, then the native event.clickable: derives totrueautomatically wheneveronChangeorselectedis set; you rarely need to pass it explicitly.
Icon:
Icon:IconImportProps'sIcon, or literaltruefor the built-in check-when-selected / plus-when-not affordance (resolvedIcon,chip.tsx:69). No shipped call site setsIcon={true}as of this pass; every real icon usage passes a concrete icon key instead.iconSize/iconClassNameare consumed.iconProps(the third member ofIconImportProps) is declared on the type but never destructured in the component (chip.tsx:42-61), so anything passed throughiconPropsis silently dropped. This is the same pre-existingRenderIconpassthrough gapStatusPillhas.
Removable mode:
onRemove(event): when set, the chip's DOM shape changes to a<span>shell with a nested close<button>, because the chip body itself is no longer the clickable target and a<button>cannot nest another<button>.onRemoveandonChange/selectedare mutually exclusive in effect, not just in intent: whenonRemoveis set, the component returns the removable-span branch unconditionally (chip.tsx:85-102) and the plain clickable-button branch, the only one that readsonChange,selected, orclickable, never executes. Passing both is not a type error, butonChangewill silently never fire.removeLabel: what a screen reader calls the close control, defaults toRemove <label>whenchildrenis a plain string, orRemove filterotherwise.
Other:
skeleton,loading(drives the icon's own loading state),noBorder(drops the border, no confirmed shipped call site),tooltip/tooltipProps(wraps unconditionally, no-ops when empty, same contract asTooltipitself and asButton'stooltip).
Truncation#
The label lives in its own span inside the chip. When the chip's box is narrower than its text, the label ends in an ellipsis inside the chip (the chip never overflows or hard-clips), and a tooltip with the full text appears on hover. A chip whose label fits shows no tooltip. A caller's own tooltip always wins over the automatic one. Only a string or number label can feed the automatic tooltip; for a node label pass tooltip.
Laws it enforces#
- Fit, then truncate, never clip: truncation happens inside the chip with the full value on hover.
- Show only what has something to say: the tooltip appears only when text is actually hidden.
Sanctioned combinations#
| Combination | Produces | Where used | Why |
|---|---|---|---|
size="sm" variant="soft" onRemove={...} in a chip block after a filter row's controls | A removable filter-value pill, one per selected value | constructed at walmart-mvp/frontend/src/components/FacetToolbar.tsx:153-155 (FacetChips), placed at :216 | Canon doc 7 §A4: a multi-select trigger that can only summarize as a count hides its selection, so each value gets an individually removable chip. Placement was ruled 2026-08-12: chips gather AFTER all of the row's controls, never inline beside the one that owns them, because an inline chip pushes every later control sideways |
size="sm" variant="soft" and size="sm" variant="soft" color="error", plain, no onChange/onRemove | Read-only status tags in a card toolbar | speedway/app/routes/admin/team.tsx:376-382 (plan chip, conditional "Suspended" chip), speedway/app/routes/account/accounts.tsx:444-446 (role chip) | Canon doc 5 §A2: "a read-only toolbar holding a single Chip is legitimate." A plain Chip with no interaction props is a valid, common shape, not a component you must always wire up |
color="warning" variant="soft" size="sm" Icon={concreteIcon} onChange={() => navigate(...)}, with a manually appended trailing icon inside children | A chip used as a fire-once navigation trigger, not a toggle | speedway/app/components/WorkflowStageChain.tsx:619-628 | onChange's boolean argument is deliberately ignored here (the handler always navigates); Chip has no iconRight, so the trailing arrow is placed directly in children after the label. Confirms onChange is usable as a plain click hook, not strictly a state toggle |
Banned combinations#
onChange(orselected) together withonRemove. See the contract note above:onRemovewins unconditionally andonChange/selectedare dead code on that render. If a chip needs to be both selectable and removable, that is two chips or a different component, not oneChipwith both props.Chipstanding in forStatusPill/StatusDotorFieldChip. Canon doc 3 §A1 states this as a defect, not a preference: selection, state, and naming are three different signals and the reader needs to tell them apart at a glance.Chipwithselectedas a two-option segmented toggle. The shipped precedent for that exact shape is a hand-rolled button pair (ToggleChip,ItemsView.tsx:62-90), not this component. If you're tempted to reach forselectedto build a Yes/No or grouped/flat switch, check whether a join-pair button (canon doc 7 §B, "Admin quick toggle") fits better first.- Passing
iconProps. It is silently dropped; if per-render icon customization beyondiconSize/iconClassNameis needed, it is not currently plumbed through.
Before you adopt this#
Five questions to answer before reaching for Chip.
- Does the shell, a parent layout, or a global provider already render this? Not applicable, Chip is inline content; no shell already renders it.
- Does this app already ship a local implementation of the same thing? A hand-rolled two-option toggle (
ToggleChip) is the shipped precedent, notselected. - Does this app's kit pin reach the version this component or prop landed in?
presetslanded 2026-08-14; confirm the app's kit pin reaches it. - Does the component derive its own accessible name and keyboard path, or must the call site supply them? Not applicable,
Chipderives its own name fromchildrenorremoveLabel. - Which canon §A rules bind this surface, and which does the composition break? §A1 bars
Chipstanding in forStatusPill/StatusDotorFieldChip.
Travels with#
Tooltip, unconditionally, same no-op-when-empty contract asButton.FacetBar(table.md, "FacetBar: the standing-facets toolbar"), which is the kit's answer to a filter row and already renders applied values as removable chips. Reach for it before assembling one: a standing menu per filter, its counts, and the chips are all inside it.Chipon its own is for a selection the caller places itself.DropdownorSelect, when the surface genuinely is not a table toolbar andFacetBardoes not fit:Chiprenders the selected values and the picker owns choosing them.RenderIcon, internally, for both the leading icon and the close-control'sCloseicon in removable mode.
Snippet#
import { Chip } from "@versable-git/ui";<Chip key={`${f.key}:${v}`} size="sm" variant="soft" onRemove={() => f.onToggle(v)}> {f.options.find((o) => o.value === v)?.label ?? v}</Chip>walmart-mvp/frontend/src/components/FacetToolbar.tsx:153-155