Skip to content
txbrendan/ui

Chip Glide

beta

Moves 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

DesignDocsBugPerformanceAccessibility5 of 8 labels are in

Install

  1. 1. Add the registry to components.json, once per app. Without it, the shadcn CLI looks up @txbrendan in a public list that txbrendan/ui does not control.

    npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"
  2. 2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.

    npx shadcn@latest add @txbrendan/theme
  3. 3. 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 }

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>
  );
}