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, shown without its comments; the files you install keep them, with the reasoning behind every rule.

src/components/ui/display/chip/Chip.astro
---
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>
src/components/ui/display/chip/ChipGroup.astro
---
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>
src/components/ui/display/chip/ChipRemove.astro
---
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>
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.