Chip Glide
betaMoves a wrapping row of chips when values come and go. A removed chip fades where it sits, then the others glide to their new places. It shows nothing.
Preview
Install
1. Add the registry to
components.json, once per app. Without it, the shadcn CLI looks up@txbrendanin a public list that txbrendan/ui does not control.npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.
npx shadcn@latest add @txbrendan/theme3. Add the component.
npx shadcn@latest add @txbrendan/chip-glide
Use it for
- A row of chips that people add to and remove from, like Token Field and Combobox with multiple.
- Make a leaving chip inert, so a press passes through it. Give the box content-start, so its lines stay put while it glides to a new height.
- rows and box alone glide the chips. hold and tip keep the pressed remove button in place and give it its tooltip.
Avoid it for
- A list that changes all at once, like search results. Fade the new list in.
- Rows that reorder by drag. Use a reorder list.
Props
- useChipGlide
Hook. Pass the values in order. Returns the rows to render, the props for the wrapping box, and hold and tip for each remove button.
(values: readonly string[]) => { rows, box, hold, tip }
- rows
What to render, in order. A removed value stays, leaving, until it has faded.
{ value: string; leaving: boolean }[]
- box
Spread on the element that wraps the chips and the text field. Put data-glide on each chip, set to its value, data-glide="" on the text field, and data-spot on each remove button.
{ ref, onPointerMove, onPointerLeave }
- hold
The remove button's onPointerDown. A mouse holds the removed chip in place, faded, until it moves off the button. A finger does not.
(value: string) => (event: PointerEvent) => void
- tip
The remove button's tooltip. Spread it on the TooltipTrigger, and render one Tooltip per row, outside the chips, with this handle. It fades out where its chip was.
(value: string) => { handle, id }
| Prop | Type | Default | What it does |
|---|---|---|---|
| useChipGlide | (values: readonly string[]) => { rows, box, hold, tip } | - | Hook. Pass the values in order. Returns the rows to render, the props for the wrapping box, and hold and tip for each remove button. |
| rows | { value: string; leaving: boolean }[] | - | What to render, in order. A removed value stays, leaving, until it has faded. |
| box | { ref, onPointerMove, onPointerLeave } | - | Spread on the element that wraps the chips and the text field. Put data-glide on each chip, set to its value, data-glide="" on the text field, and data-spot on each remove button. |
| hold | (value: string) => (event: PointerEvent) => void | - | The remove button's onPointerDown. A mouse holds the removed chip in place, faded, until it moves off the button. A finger does not. |
| tip | (value: string) => { handle, id } | - | The remove button's tooltip. Spread it on the TooltipTrigger, and render one Tooltip per row, outside the chips, with this handle. It fades out where its chip was. |
Accessibility
- A leaving chip is inert and hidden from screen readers while it fades, so it is never read twice or pressed.
- A press where a held chip was lands on the box behind it, not on the next chip or the text field.
- A chip removed from the keyboard hands focus to the next chip's remove button, or the one before it.
- Reduced motion keeps the fades, and puts each chip in its new place at once. Motion off does both at once.
Examples
Remove chips and glide the rest (needs @txbrendan/tooltip)
function Labels({ values, onRemove }: { values: string[]; onRemove: (value: string) => void }) {
const glide = useChipGlide(values);
return (
<div {...glide.box} className="flex flex-wrap content-start gap-1">
{glide.rows.map(({ value, leaving }) => (
<span key={value} data-glide={value} inert={leaving} className="inline-flex items-center gap-1 rounded-md bg-secondary ps-2">
{value}
<TooltipTrigger {...glide.tip(value)} data-spot aria-label={`Remove ${value}`} onPointerDown={glide.hold(value)} onClick={() => onRemove(value)}>
×
</TooltipTrigger>
</span>
))}
{glide.rows.map(({ value }) => (
<Tooltip key={value} handle={glide.tip(value).handle}>
<TooltipPopup>Remove {value}</TooltipPopup>
</Tooltip>
))}
</div>
);
}