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, shown without its comments; the files you install keep them, with the reasoning behind every rule.
---
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"li"> & {
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>
---
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"ul"> & {
label?: string;
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>
<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"]';
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)];
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;
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);
group.dispatchEvent(
new CustomEvent("chip:remove", { bubbles: true, detail: { value: chip.dataset.value } }),
);
});
name();
}
onReadyOnce('[data-slot="chip-group"]', wire);
</script>
---
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"button"> & {
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.