Skip to content

Chips

Edit on GitHub
@volvo-cars/css v2.3.0

Chips are compact interactive elements used for selecting options or dismissing applied filters. Built with @volvo-cars/css using the chip-selectable and chip-dismissible classes. There are two types: selectable and dismissible.

import { useState } from 'react';
const options = ['Hybrid', 'Electric', 'Petrol'];
export function ChipSelectable() {
const [selected, setSelected] = useState<string[]>([]);
const toggle = (option: string) => {
setSelected((prev) =>
prev.includes(option)
? prev.filter((s) => s !== option)
: [...prev, option],
);
};
return (
<div className="flex gap-8">
{options.map((option) => (
<button
key={option}
type="button"
className="chip-selectable"
aria-pressed={selected.includes(option)}
onClick={() => toggle(option)}
>
{option}
</button>
))}
</div>
);
}

Use chip-selectable on a <button>. Toggle selection with aria-pressed.

import { useState } from 'react';
const options = ['Hybrid', 'Electric', 'Petrol'];
export function ChipSelectable() {
const [selected, setSelected] = useState<string[]>([]);
const toggle = (option: string) => {
setSelected((prev) =>
prev.includes(option)
? prev.filter((s) => s !== option)
: [...prev, option],
);
};
return (
<div className="flex gap-8">
{options.map((option) => (
<button
key={option}
type="button"
className="chip-selectable"
aria-pressed={selected.includes(option)}
onClick={() => toggle(option)}
>
{option}
</button>
))}
</div>
);
}

Add data-size="small" for a compact variant.

import { useState } from 'react';
const options = ['Hybrid', 'Electric', 'Petrol'];
export function ChipSelectableSmall() {
const [selected, setSelected] = useState<string[]>([]);
const toggle = (option: string) => {
setSelected((prev) =>
prev.includes(option)
? prev.filter((s) => s !== option)
: [...prev, option],
);
};
return (
<div className="flex gap-8">
{options.map((option) => (
<button
key={option}
type="button"
className="chip-selectable"
data-size="small"
aria-pressed={selected.includes(option)}
onClick={() => toggle(option)}
>
{option}
</button>
))}
</div>
);
}

For mutually exclusive selection, add role="radiogroup" on the container and role="radio" on each button. Use aria-checked instead of aria-pressed.

import { useState } from 'react';
const options = ['Hybrid', 'Electric', 'Petrol'];
export function ChipRadio() {
const [selected, setSelected] = useState('Hybrid');
return (
<div role="radiogroup" aria-label="Powertrain" className="flex gap-8">
{options.map((option) => (
<button
key={option}
type="button"
role="radio"
className="chip-selectable"
aria-checked={selected === option}
onClick={() => setSelected(option)}
>
{option}
</button>
))}
</div>
);
}

Use chip-dismissible on a <div> containing text and a dismiss button. The dismiss button must use slot="dismiss" for correct styling. Use IconButton from @volvo-cars/react-icons with variant="clear" and bleed for the dismiss action.

For a set of removable items, add role="list" on the container and role="listitem" on each chip.

EX90
EX30
XC90
import { IconButton } from '@volvo-cars/react-icons';
import { useState } from 'react';
const initial = ['EX90', 'EX30', 'XC90'];
export function ChipDismissible() {
const [chips, setChips] = useState(initial);
return (
<div
className="flex flex-wrap gap-8"
role="list"
aria-label="Selected models"
>
{chips.map((chip) => (
<div key={chip} role="listitem" className="chip-dismissible">
{chip}
<IconButton
aria-label={`Remove ${chip}`}
slot="dismiss"
variant="clear"
bleed
icon="x"
onClick={() => setChips((prev) => prev.filter((c) => c !== chip))}
/>
</div>
))}
</div>
);
}

Set data-loading="true" and aria-busy="true" on the chip container. The dismiss button is automatically hidden and a <progress class="spinner"> takes its place.

EX90
EX30
import { IconButton } from '@volvo-cars/react-icons';
import { useState } from 'react';
export function ChipDismissibleLoading() {
const [loading, setLoading] = useState(false);
return (
<div className="flex gap-8">
<div
className="chip-dismissible"
data-loading={loading || undefined}
aria-busy={loading}
>
{loading ? (
<>
<span className="invisible">EX90</span>
<progress className="spinner" aria-label="Loading" />
</>
) : (
'EX90'
)}
<IconButton
aria-label="Remove EX90"
slot="dismiss"
variant="clear"
bleed
icon="x"
onClick={() => {
setLoading(true);
setTimeout(() => setLoading(false), 2000);
}}
/>
</div>
<div className="chip-dismissible">
EX30
<IconButton
aria-label="Remove EX30"
slot="dismiss"
variant="clear"
bleed
icon="x"
/>
</div>
</div>
);
}

Key responsibilities from the WCAG audits (selectable, dismissible):

  • Use the right ARIA patternaria-pressed for multi-select toggles, aria-checked with role="radio" for single-select. Dismissible chips need a dismiss <button> with a descriptive aria-label (e.g. “Remove EX90”).
  • Loading states — use aria-busy="true" and a <progress> element with aria-label so screen readers announce the loading state.
  • Keyboard — all chips must be focusable and activatable via Enter or Space. Tab order should follow visual order (left-to-right, top-to-bottom).
  • Contrast — text and background contrast in both selected and unselected states needs verification (WCAG 1.4.3, pending audit).
  • Target size — chip and dismiss button target sizes should be at least 24×24 px (WCAG 2.5.8, pending audit).
  • No unexpected side effects — selecting or dismissing a chip should not cause disorienting changes elsewhere on the page.