Chip
A compact token — a status, a tag, a filter, a person.
Overview
A compact token — a status, a tag, a filter, a person.
First example
<Chip>Nouveau</Chip><Chip variant="success-soft">Payée</Chip>
Anatomy
<Chip>
<Chip.Dot />
<Chip.Avatar />
<Chip.Icon />
<Chip.Label />
<Chip.Close />
</Chip>
Chip— the root. AView, or aPressableFeedbackwhenisPressableis set. It resolves the recipe once and publishes the resolved styles to its slots. A string child is wrapped in aChip.Labelautomatically, which is the majority case:<Chip>Payée</Chip>is the whole component most of the time.Chip.Label— the text. Single-line by default, likeButton.Labeland for the same reason: a chip has a fixed height, so a label too long for it truncates rather than deforming the capsule.Chip.Dot— the status mark. A filled circle in the variant's foreground, sized by the chip.Chip.Icon— a glyph that takes the chip's size and colour without being told either. The three forms ofIcon:as, a raw SVG child, orsource.Chip.Avatar— a round, chip-sized frame:sourcerenders an image, anything else is centred and clipped the same way. Its diameter always fits inside the chip's height.Chip.Close— the dismiss affordance, and a control in its own right. It owns its press state rather than reading the chip's, and with no children it draws its own cross — so a dismissible chip works in a project that has installed no icon set.
JSX order is screen order (R4). No slot carries a margin: what separates a dot from its
label, or a label from its cross, is the root's gap. That is why there is no
startContent / endContent — a leading cross and a trailing one differ by where you
wrote the slot, and nothing has to be undone to swap them.
Usage
Basic
<Chip>Nouveau</Chip>
<Chip variant="success-soft">Payée</Chip>
Composed
<Chip variant="tertiary">
<Chip.Dot />
<Chip.Label>En cours</Chip.Label>
</Chip>
<Chip variant="default">
<Chip.Avatar source={author.photo} />
<Chip.Label>Amina</Chip.Label>
<Chip.Close accessibilityLabel="Retirer Amina" onPress={remove} />
</Chip>
<Chip variant="warning-soft">
<Chip.Icon as={AlertIcon} />
<Chip.Label>Expire demain</Chip.Label>
</Chip>
Pressable
<Chip
isPressable
variant={isOn ? 'primary' : 'tertiary'}
accessibilityState={{ selected: isOn }}
onPress={toggle}
>
Design
</Chip>
isPressable is a prop rather than an inference from onPress being present, because the
two answers are different elements — a View and a Pressable. Inferring it would
remount the chip, and change what a screen reader announces, on the render where a handler
happens to become undefined.
A chip carrying only a Chip.Close stays static: the control is the cross, not the chip
around it.
Dismissible
{
tags.map(tag => (
<Chip key={tag} variant="default">
<Chip.Label>{tag}</Chip.Label>
<Chip.Close
accessibilityLabel={`Retirer ${tag}`}
onPress={() => remove(tag)}
/>
</Chip>
))
}
Pass a child to replace the built-in cross:
<Chip.Close accessibilityLabel="Retirer">
<Chip.Icon as={XIcon} />
</Chip.Close>
As another element
<Chip asChild variant="secondary">
<Link href="/tags/design">Design</Link>
</Chip>
The caller's element is the chip, so the auto-wrap does not apply — write the label yourself, or a slot, inside it.
Sizes
size drives the height, the horizontal padding, the gap and the type — never the
width. A chip hugs its label: alignSelf is flex-start, so a chip in a column stays
the width of its text instead of stretching, which is the one place its layout deliberately
differs from a Button's.
size | Height | Padding | Label | Dot | Avatar |
|---|---|---|---|---|---|
xs | 20 | 8 | 12/16 | 4 | 14 |
sm | 24 | 10 | 12/16 | 5 | 18 |
md | 28 | 12 | 14/20 | 6 | 22 |
lg | 36 | 16 | 16/24 | 8 | 28 |
The height is fixed where the reference implementation uses vertical padding. Same numbers — theirs resolve to 20, 28 and 36 — and a different reason to arrive at them: with padding, a chip carrying an avatar is taller than the chip beside it carrying only text, and a row of filters stops lining up.
Variants
Eleven flat names, replacing the reference implementation's variant × color matrix — four emphases times five
intents, of which nine combinations paint the same thing.
The first five are the Button's ladder, descending by how much accent is left. The six
that follow are the three status families the Button deliberately refused: a button is
something you press and neither a success nor a warning is an action, while a chip
reports — the outcome is what it says.
variant | Background | Border | Text |
|---|---|---|---|
primary | accent | — | accentForeground |
secondary | accentSoft | — | accentSoftForeground |
default | default | — | defaultForeground |
tertiary | transparent | border | foreground |
ghost | transparent | — | foreground |
success | success | — | successForeground |
success-soft | successSoft | — | successSoftForeground |
warning | warning | — | warningForeground |
warning-soft | warningSoft | — | warningSoftForeground |
danger | danger | — | dangerForeground |
danger-soft | dangerSoft | — | dangerSoftForeground |
The dot and the cross take the text colour, not a status token of their own: on a
filled success chip the only readable colour is the one the label already uses, and on a
success-soft one that token is the green. One rule, eleven variants, and a tinted chip
gets it for free.
Colour
<Chip color="#7c3aed">Sprint 12</Chip>
<Chip variant="tertiary" color="#7c3aed">
<Chip.Dot />
<Chip.Label>Sprint 12</Chip.Label>
</Chip>
A raw tint, never a token (R7). Where it lands follows the variant, exactly as on a
Button: the fill of a primary, the border and text of a tertiary, the text of a
ghost. Its soft, contrasted and pressed slices are derived in OKLab, so the dot and the
cross follow without being told a second colour — and so a tinted chip presses the same way
a token one does.
It resolves outside the style cache, which is why it must be a hex value and why it is the one prop that allocates per render.
Style as props
Every node takes its own style keys as props (R14) — full React Native names, full React Native values, no hidden scale:
<Chip paddingHorizontal={20} maxWidth={160}>
<Chip.Label fontSize={13} letterSpacing={0.4}>
Un libellé très long qui sera tronqué
</Chip.Label>
</Chip>
<Chip variant="tertiary">
<Chip.Dot backgroundColor={theme.colors.success} />
<Chip.Label>En ligne</Chip.Label>
</Chip>
A dot that reports something other than what its chip does is the case backgroundColor
exists for. padding={16} is 16 points; reach for the scale explicitly with
padding={theme.spacing(4)}.
Everything else goes through style
- A gradient fill, a tinted shadow, a border in a colour the fill does not imply — the variant names one token for the edge, and a second one is not a variant.
radiusoverrides the capsule. A chip is a pill at every size, which is what the name means, so this is the escape hatch for the tag that wants corners.
Root props
ChipProps
The node's inherited React Native props apply too, and so do its style props — padding, margin, width and the rest.
| Prop | Type | Required | Description |
|---|---|---|---|
| variant | ChipVariant | undefined | No | — |
| size | Size | undefined | No | Height, horizontal padding, gap and the type of `Label`. Never width. |
| radius | RadiusKey | undefined | No | Overrides the capsule. A chip is a pill at every size — that is the shape the name means — so this is the prop for the rare tag that wants square corners. |
| color | string | undefined | No | A raw tint (`'#7c3aed'`), never a token (R7). Where it lands follows the variant: the fill of a `primary`, the border and text of a `tertiary`, the text of a `ghost`. Its contrasted, soft and pressed slices are derived in OKLab, so it behaves exactly like `accent` — which is also why it must be a hex value. |
| isDisabled | boolean | undefined | No | Dims the chip and, on a pressable one, stops the touch. |
| isPressable | boolean | undefined | No | Makes the chip a control — a filter, a toggle, a token you can open. A `PressableFeedback` with `accessibilityRole="button"`, the shared scale, and the variant's own pressed colour. It is a prop and not an inference from `onPress` being present, because the two answers are different **elements** — a `View` and a `Pressable` — and inferring it would remount the chip, and change what a screen reader announces, on the render where a handler happens to become `undefined`. A chip carrying only a `Chip.Close` stays static: the control is the close, not the chip around it. |
| style | StyleProp<ViewStyle> | ((state: PressableStateCallbackType) => StyleProp<ViewStyle>) | No | R9 — `Pressable`'s function form as much as an object or an array. |
| children | ReactNode | No | — |
Slots
ChipAvatarProps
The node's inherited React Native props apply too, and so do its style props — padding, margin, width and the rest.
| Prop | Type | Required | Description |
|---|---|---|---|
| source | ImageSourcePropType | undefined | No | An image, clipped to the circle the chip's size defines. |
| children | ReactNode | No | Anything else — initials, a future `<Avatar>` — centred and clipped the same way. |
ChipCloseProps
The node's inherited React Native props apply too, and so do its style props — padding, margin, width and the rest.
ChipDotProps
The node's inherited React Native props apply too, and so do its style props — padding, margin, width and the rest.
ChipIconProps
The node's inherited React Native props apply too, and so do its style props — padding, margin, width and the rest.
ChipLabelProps
The node's inherited React Native props apply too, and so do its style props — padding, margin, width and the rest.
| Prop | Type | Required | Description |
|---|---|---|---|
| children | ReactNode | No | — |
Variants, sizes and colour
Accessibility
- A static chip has no role of its own and announces nothing: it is a label, and the text inside it is what a screen reader reads.
- A pressable chip is
accessibilityRole="button"by default and stays overridable, and itsaccessibilityStatecarriesdisabledmerged with any state you pass — addingselected, which is what a filter chip announces, does not erase it. Chip.Closeneeds anaccessibilityLabel, and warns in development without one. A cross is not text, and unlike an icon-only button there is nothing to fall back on: the label beside it names what is being removed, not the action.- A dot is decoration. A chip whose colour or dot is its only meaning is unreadable to a screen reader and to anyone who does not distinguish the hue — put the status in the label.
- A pressable chip is one target. Putting a
Chip.Closeinside one gives a screen reader two overlapping controls; it works, and it is worth asking whether the chip needs to be pressable at all.
Migration from legacy
| Legacy | v1 |
|---|---|
variant="solid" + themeColor | variant="primary" / "success" / "danger"… |
variant="flat" / "faded" | the -soft slice: variant="success-soft" |
variant="bordered" | variant="tertiary" |
variant="light" | variant="ghost" |
variant="dot" | variant="tertiary" + <Chip.Dot /> |
variant="shadow" | removed — a chip is not a surface. style if you need it |
themeColor="primary" | color={theme.colors.accent} |
size="sm" | "md" | "lg" | size — now xs … lg, and xs is the legacy sm |
radius="none" | radius="xs" |
avatar={<Image />} | <Chip.Avatar source={…} /> |
startContent / endContent | a slot, written before or after Chip.Label |
onClose | <Chip.Close onPress={…} /> — composed, so you place it |
onPress | isPressable + onPress |
customAppearance={{ container }} | style on the root |
customAppearance={{ text }} | style on Chip.Label |
customAppearance={{ dot }} | style on Chip.Dot |
customAppearance={{ closeButton }} | style on Chip.Close |
ChipGroup / ChipItem | not in the 1.0 core — the group lands with P5 |
Implementation notes
Touch feedback
A pressed chip scales and its fill goes one step down, using the variant's own
…Pressed token — the Button's treatment, not the Card's. A component picks one
pressed treatment, never both, and a chip picks this one because a wash over something this
small reads as a smudge, while the fill moving reads as the thing itself being pushed.
Chip.Close owns a press state of its own. The chip may be a View, and even when it is
pressable the two are different targets — pressing the cross must not read as pressing the
chip around it. hitSlop grows the cross's target outwards, because a cross big enough to
hit is a cross too big to look right.
animation={false} renders a different component rather than the same one with a branch
inside — no Reanimated hook is reached at all. 'disable-all' does the same for every
descendant, which is how a long list of chips switches its rows off with one prop.
Extending it
The context hook is exported, so a third party can write their own slot against the same resolved values the built-in ones read:
import { useChip } from '@xaui/native/chip'
function ChipCount({ children }) {
const { labelStyle } = useChip()
return <Text style={[labelStyle, { opacity: 0.6 }]}>{children}</Text>
}
Used outside a <Chip> it throws by name, pointing at the misplaced component rather than
failing three frames later on an undefined style.