Chips
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> );}Selectable chip
Section titled “Selectable chip”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> );}Small size
Section titled “Small size”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> );}Single selection (radio)
Section titled “Single selection (radio)”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> );}Dismissible chip
Section titled “Dismissible chip”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.
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> );}Loading
Section titled “Loading”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.
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> );}Accessibility
Section titled “Accessibility”Key responsibilities from the WCAG audits (selectable, dismissible):
- Use the right ARIA pattern —
aria-pressedfor multi-select toggles,aria-checkedwithrole="radio"for single-select. Dismissible chips need a dismiss<button>with a descriptivearia-label(e.g. “Remove EX90”). - Loading states — use
aria-busy="true"and a<progress>element witharia-labelso 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.