Skip to main content
astrocraft-ui/ components · 101

Chip

Free · MIT

Headless, accessible, and styled entirely through the attribute surface below — bring your own CSS system, or start from the reference theme.

Install

Without a flag you get no styling at all — plain HTML carrying the attribute surface below. --theme copies the same theme in two dialects;--bridge lumos re-points either one onto Lumos for Astro's tokens (it needs a theme to re-point, and Lumos itself, already in your project).

Unstyled — behavior only, you write the CSS

Install command
npx astrocraft-ui add display/chip

Plain-CSS theme — no build step

Install command
npx astrocraft-ui add display/chip --theme css

Tailwind theme — needs Tailwind v4

Install command
npx astrocraft-ui add display/chip --theme tailwind

Plain-CSS theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add display/chip --theme css --bridge lumos

Tailwind theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add display/chip --theme tailwind --bridge lumos

Live demo

Chip

Remove one with the keyboard and watch where focus goes: to the next chip's remove button, then the previous one, then the group — never to <body>, which is where the browser puts it when the focused element is deleted. Each remove button is named from its own chip, so no two are announced alike.

  • Draft
  • Assigned to me
  • Q3 2026

Attribute surface

With no class-merging machinery, these attributes are the whole extension point. State is a data-attribute or a native attribute; visibility is the hiddenattribute. Generated from source by pnpm slots.

ComponentSlotsVariantsRuntime stateNative state
`Chip.astro``chip``data-variant`: `primary` · `secondary` · `muted` · `outline`
`data-size`: `sm` · `md` · `lg`
——
`ChipGroup.astro``chip-group` `chip-group-status``data-size`: `sm` · `md` · `lg`——
`ChipRemove.astro``chip-remove`———

Source

What the command copies — 4 files, alias-free so the copy lands standing alone. This is the whole component; there is nothing else.

src/components/ui/display/chip/Chip.astro
---
// src/components/ui/display/chip/Chip.astro — ChipGroup compound part (see ../../README.md).
// One chip: an <li>, because the group is a <ul> and that is the only child a list may have.
//
// `value` is what `chip:remove` reports when this one is dismissed — the id your filter state is
// keyed on, which is rarely the same string as the label the user reads ("draft" vs "Draft only").
//
// The chip's text is whatever you put in the slot. ChipGroup reads it — minus the remove button's
// own contents — to name that button, so an icon or a count beside the label never ends up in the
// announcement.
import type { HTMLAttributes } from "astro/types";

type Props = HTMLAttributes<"li"> & {
  /** Reported in `chip:remove`'s `detail.value`. */
  value?: string;
  variant?: "primary" | "secondary" | "muted" | "outline";
  size?: "sm" | "md" | "lg";
};

const { value, variant = "muted", size = "md", class: className, ...rest } = Astro.props;
---

<li
  class={className}
  data-slot="chip"
  data-value={value}
  data-variant={variant}
  data-size={size}
  {...rest}
>
  <slot />
</li>
src/components/ui/display/chip/ChipGroup.astro
---
// src/components/ui/display/chip/ChipGroup.astro — headless primitive (see ../../README.md).
// The row of dismissible chips above a result list: applied filters, selected facets, active
// recipients. A <ul>, so it is announced as "list, 4 items" and a screen reader user knows how many
// filters are on before reading them.
//
// WHAT EARNS THIS A FILE IS WHERE FOCUS GOES. Removing a chip destroys the element that had focus,
// and the browser's answer to that is to drop focus on <body> — which silently teleports a keyboard
// user to the top of the page, mid-task, with no announcement. So this moves focus deliberately: to
// the next chip's remove button, else the previous one's, else the group itself. That is the entire
// reason a row of chips is behavior rather than markup.
//
// The second thing it fixes is naming. Five buttons all called "Remove" are five identical entries
// in a screen reader's control list, with nothing to tell them apart. Any remove button left without
// its own name is given one here, from the text of the chip it sits in.
//
//   <ChipGroup label="Active filters">
//     <Chip value="draft">Draft<ChipRemove /></Chip>
//     <Chip value="mine">Assigned to me<ChipRemove /></Chip>
//   </ChipGroup>
//
// Removing a chip fires `chip:remove` on the group (bubbling, `detail.value`) — that is your hook to
// drop the filter from your own state. The chip itself is already gone by then; the DOM is the thing
// the user is looking at, and waiting for a round trip to remove it is the lag everyone notices.
//
// For a chip that is not removable, use Badge — it is the same pill with none of this behavior. For
// chips a user TYPES, use TagsInput, which owns the text box, the parsing and the hidden inputs.
import type { HTMLAttributes } from "astro/types";

type Props = HTMLAttributes<"ul"> & {
  /** Names the row, so the chips are announced as one set rather than loose buttons. */
  label?: string;
  /** Prefixes every generated remove-button name: "Remove Draft". */
  removeLabel?: string;
  size?: "sm" | "md" | "lg";
};

const { label, removeLabel = "Remove", size = "md", class: className, ...rest } = Astro.props;
---

<ul
  tabindex="-1"
  aria-label={label}
  class={className}
  data-slot="chip-group"
  data-remove-label={removeLabel}
  data-size={size}
  {...rest}
>
  <slot />
</ul>
{/* Removing a chip moves focus but announces nothing of itself; this says how many are left. */}
<span role="status" aria-live="polite" data-slot="chip-group-status"></span>

<script>
  import { onReadyOnce } from "../../_once";

  const CHIP = '[data-slot="chip"]';
  const REMOVE = '[data-slot="chip-remove"]';

  /** A chip's own text — the remove button's contents deliberately left out of it. */
  function chipText(chip: HTMLElement): string {
    return [...chip.childNodes]
      .filter((node) => !(node instanceof Element && node.matches(REMOVE)))
      .map((node) => node.textContent ?? "")
      .join("")
      .trim();
  }

  function wire(group: HTMLElement) {
    const status = group.nextElementSibling;
    const chips = () => [...group.querySelectorAll<HTMLElement>(CHIP)];

    /** Give every unnamed remove button a name of its own, so no two are announced alike. */
    const name = () => {
      const prefix = group.dataset.removeLabel ?? "Remove";
      for (const chip of chips()) {
        const button = chip.querySelector<HTMLElement>(REMOVE);
        const text = chipText(chip);
        if (!button || button.hasAttribute("aria-label") || !text) continue;
        button.setAttribute("aria-label", `${prefix} ${text}`);
      }
    };

    group.addEventListener("click", (event) => {
      if (!(event.target instanceof Element)) return;
      const button = event.target.closest<HTMLElement>(REMOVE);
      const chip = button?.closest<HTMLElement>(CHIP);
      if (!button || !chip || !group.contains(chip)) return;

      // Work out where focus goes BEFORE the element holding it leaves the document.
      const all = chips();
      const i = all.indexOf(chip);
      const next = all[i + 1] ?? all[i - 1];
      const target = next?.querySelector<HTMLElement>(REMOVE) ?? next ?? group;

      chip.remove();
      target.focus();
      if (status instanceof HTMLElement) status.textContent = String(chips().length);
      // The value rather than the text: it is what your filter state is keyed on.
      group.dispatchEvent(
        new CustomEvent("chip:remove", { bubbles: true, detail: { value: chip.dataset.value } }),
      );
    });

    name();
  }

  onReadyOnce('[data-slot="chip-group"]', wire);
</script>
src/components/ui/display/chip/ChipRemove.astro
---
// src/components/ui/display/chip/ChipRemove.astro — ChipGroup compound part (see ../../README.md).
// The chip's dismiss button. A real <button> — not an <svg> with a click handler, which is neither
// focusable nor announced nor operable with the keyboard.
//
// It renders with NO accessible name unless you give it one, and that is deliberate: ChipGroup fills
// it in from the chip's own text ("Remove Draft"), so the name is right without anyone repeating the
// label twice in the markup and without it going stale when the label changes. Pass `label` to
// override — for a chip whose visible text is not what you want announced.
//
// `type="button"` matters: inside a form, a button with no type is a submit button, and dismissing a
// filter would submit the search.
import type { HTMLAttributes } from "astro/types";

type Props = HTMLAttributes<"button"> & {
  /** The full accessible name. Leave it off and ChipGroup derives one from the chip's text. */
  label?: string;
};

const { label, class: className, ...rest } = Astro.props;
---

<button type="button" aria-label={label} class={className} data-slot="chip-remove" {...rest}>
  <slot
    ><svg
      xmlns="http://www.w3.org/2000/svg"
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      stroke-width="2"
      stroke-linecap="round"
      aria-hidden="true"
    >
      <path d="M18 6 6 18M6 6l12 12"></path>
    </svg></slot
  >
</button>
src/components/ui/display/chip/index.ts
import Chip from "./Chip.astro";
import ChipGroup from "./ChipGroup.astro";
import ChipRemove from "./ChipRemove.astro";

export { Chip, ChipGroup, ChipRemove };
export default ChipGroup;

What you get

The component source, copied into your project by npx astrocraft-ui add display/chip — no package dependency, no CSS to fight. Whatever the paid blocks compose, this is it.