Skip to the content

ComponentsMisc

Chip

A compact, rounded label for a value someone chose, such as a tag, a recipient or a filter, which they may remove.

Import

import { Chip, ChipGroup } from "@booleanpress/ui/chip"

Usage

import { Chip, ChipGroup } from "@booleanpress/ui/chip"

export function Recipients({ list, remove }: { list: string[]; remove: (address: string) => void }) {
  return (
    <ChipGroup aria-label="Recipients">
      {list.map((address) => (
        <Chip key={address} label={address} onRemove={() => remove(address)} />
      ))}
    </ChipGroup>
  )
}

label is the text. icon puts a 14 px icon before it; image a round 22 px picture (imageAlt is empty by default, because the label names the chip). onRemove adds a remove button named "Remove {label}" (the provider's removeItem); the chip does not remove itself, so take it out of your list in the handler.

ChipGroup lays chips out in a wrapping row and makes them a list. When a chip is removed, focus moves to the next chip's remove button, or the previous one's, or the group when none is left, so a keyboard user can keep removing. Name the group with aria-label or aria-labelledby. A chip is not a button and not a toggle: for a choice that stays pressed, use a toggle group.

Examples

Basic

A row of labels.

import { Chip } from "@booleanpress/ui/chip"

export default function ChipBasic() {
  return (
    <div className="flex flex-wrap justify-center gap-2">
      <Chip label="Transactional" />
      <Chip label="Marketing" />
      <Chip label="Password resets" />
    </div>
  )
}

Icon

icon puts a 14 px icon before the label.

import { GlobeIcon, KeyRoundIcon, MailIcon, ServerIcon } from "lucide-react"
import { Chip } from "@booleanpress/ui/chip"

export default function ChipIcon() {
  return (
    <div className="flex flex-wrap justify-center gap-2">
      <Chip icon={<MailIcon />} label="SMTP" />
      <Chip icon={<ServerIcon />} label="Amazon SES" />
      <Chip icon={<GlobeIcon />} label="mail.example.com" />
      <Chip icon={<KeyRoundIcon />} label="API key" />
    </div>
  )
}

Image

image puts a round picture before the label.

import { Chip } from "@booleanpress/ui/chip"

function portrait(fill: string, face: string) {
  return (
    "data:image/svg+xml;utf8," +
    encodeURIComponent(
      `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" fill="${fill}"/><circle cx="32" cy="25" r="11" fill="${face}"/><path d="M10 64c2-16 12-24 22-24s20 8 22 24z" fill="${face}"/></svg>`
    )
  )
}

export default function ChipImage() {
  return (
    <div className="flex flex-wrap justify-center gap-2">
      <Chip image={portrait("#6366f1", "#e0e7ff")} label="Ada Lovelace" />
      <Chip image={portrait("#0ea5e9", "#e0f2fe")} label="Grace Hopper" />
      <Chip image={portrait("#f97316", "#ffedd5")} label="Alan Turing" />
    </div>
  )
}

Removable

onRemove adds a remove button; Backspace and Delete remove the chip too.

import { useState } from "react"
import { Button } from "@booleanpress/ui/button"
import { Chip, ChipGroup } from "@booleanpress/ui/chip"

const TAGS = ["Billing", "Refund", "Priority", "VIP"]

export default function ChipRemovable() {
  const [tags, setTags] = useState(TAGS)

  return (
    <div className="flex flex-col items-center gap-3">
      {/* In a group, focus lands on the group when the last chip goes, not on the page. */}
      <ChipGroup aria-label="Ticket tags" className="justify-center">
        {tags.map((tag) => (
          <Chip key={tag} label={tag} onRemove={() => setTags((current) => current.filter((t) => t !== tag))} />
        ))}
      </ChipGroup>
      {tags.length < TAGS.length ? (
        <Button variant="link" size="sm" onClick={() => setTags(TAGS)}>
          Restore tags
        </Button>
      ) : null}
    </div>
  )
}

Group

ChipGroup makes a named list; removing a chip moves focus to the next one.

import { useState } from "react"
import { Chip, ChipGroup } from "@booleanpress/ui/chip"

const RECIPIENTS = ["ops@example.com", "billing@example.com", "support@example.com", "alerts@example.com"]

export default function ChipGroupExample() {
  const [recipients, setRecipients] = useState(RECIPIENTS)

  return (
    <div className="flex w-full max-w-sm flex-col gap-2">
      <p id="chip-group-recipients" className="text-sm font-medium">
        Failure alerts go to
      </p>
      <ChipGroup aria-labelledby="chip-group-recipients">
        {recipients.map((address) => (
          <Chip
            key={address}
            label={address}
            onRemove={() => setRecipients((current) => current.filter((a) => a !== address))}
          />
        ))}
      </ChipGroup>
      {recipients.length === 0 ? <p className="text-sm text-muted-foreground">No one gets failure alerts.</p> : null}
    </div>
  )
}

Disabled

disabled dims the chip and turns its remove button off, beside a chip that can still be removed.

import { Chip, ChipGroup } from "@booleanpress/ui/chip"

export default function ChipDisabled() {
  return (
    <ChipGroup aria-label="Ticket tags">
      <Chip label="Billing" onRemove={() => {}} />
      <Chip label="Refund" disabled onRemove={() => {}} />
    </ChipGroup>
  )
}

Accessibility

Semantics
A div; in a ChipGroup (role="list") each chip is a listitem. The remove button is a native button whose 22 px circle takes presses over a 24 px square, the minimum target of WCAG 2.5.8; the icon and the image are decorative unless you give imageAlt.
Labels
The remove button is named from the provider's removeItem string with the label filled in: "Remove billing@example.com".
Focus
A chip without a remove button is not focusable. A removable chip's remove button is its tab stop, and the chip turns one step darker while the button has keyboard focus. After a removal, focus goes to the next chip's remove button, else the previous one's, else the group.
Known limits
  • The chip calls onRemove and moves focus at once; it does not wait for your list to change. If you ask before removing, move focus yourself.
  • A disabled chip's remove button leaves the tab order, and focus skips it after a removal.
  • Outside a ChipGroup focus can only move to a neighbouring chip in the same parent; when the last removable chip there goes, focus has nowhere to land. Put removable chips in a ChipGroup.

Keyboard

Keyboard
KeyBehaviour
TabMoves to the next removable chip's remove button.
EnterOn the remove button: removes the chip and moves focus to the next chip, or the previous one.
SpaceOn the remove button: removes the chip and moves focus to the next chip, or the previous one.
BackspaceOn the remove button: removes the chip and moves focus to the next chip, or the previous one.
DeleteOn the remove button: removes the chip and moves focus to the next chip, or the previous one.

API

Chip

Renders a div and passes it every other prop.

Chip props
PropTypeDefaultDescription
labelrequiredstringThe chip's text; the remove button is named after it.
disabledbooleanfalseDims the chip and disables its remove button.
iconReactNodeAn icon before the label, drawn at 14px.
imagestringThe address of a round image before the label, such as a person's photo.
imageAltstringThe image's text alternative; empty (decorative) by default, as the label names the chip.
onRemove(() => void)Shows a remove button, named "Remove {label}", which calls this; Backspace and Delete on it call it too.

ChipGroup

Renders a div and passes it every other prop.

Every part takes className, merged with its defaults by cn(), and ref, which reaches the element it renders.

Data attributes: data-slot="chip" (Chip), data-slot="chip-group" (ChipGroup), and data-removable, data-disabled.

Provider strings: removeItem (BooleanUIProvider's strings).

Theming

A chip fills with --secondary (--secondary-hover while its remove button has focus) and draws its text and icons in --accent-foreground.

The tokens its classes read. Change their values in your theme, and it follows.

Theme tokens
TokenUsed for
--accent-foregroundtext
--ringoutline
--secondarybackground
--secondary-hoverbackground