Chip
Free · MITHeadless, 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
npx astrocraft-ui add display/chipPlain-CSS theme — no build step
npx astrocraft-ui add display/chip --theme cssTailwind theme — needs Tailwind v4
npx astrocraft-ui add display/chip --theme tailwindPlain-CSS theme, re-pointed onto Lumos tokens
npx astrocraft-ui add display/chip --theme css --bridge lumosTailwind theme, re-pointed onto Lumos tokens
npx astrocraft-ui add display/chip --theme tailwind --bridge lumosLive 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.
| Component | Slots | Variants | Runtime state | Native 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 — 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 — 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 — 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>
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.