Checkbox Group
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 forms/checkbox-groupPlain-CSS theme — no build step
npx astrocraft-ui add forms/checkbox-group --theme cssTailwind theme — needs Tailwind v4
npx astrocraft-ui add forms/checkbox-group --theme tailwindPlain-CSS theme, re-pointed onto Lumos tokens
npx astrocraft-ui add forms/checkbox-group --theme css --bridge lumosTailwind theme, re-pointed onto Lumos tokens
npx astrocraft-ui add forms/checkbox-group --theme tailwind --bridge lumosLive demo
RadioGroup & CheckboxGroup
Both are real fieldsets, so the legend names the group. The checkbox group’s first box is the select-all — tick one child and it goes indeterminate, the state HTML has no attribute for.
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 |
|---|---|---|---|---|
| `CheckboxGroup.astro` | `checkbox-group` | `data-orientation`: `vertical` · `horizontal` | — | — |
| `CheckboxGroupItem.astro` | `checkbox-group-item` `checkbox-group-item-label` | `data-toggle-all`: `true` when set | — | — |
| `CheckboxGroupLabel.astro` | `checkbox-group-label` | — | — | — |
Source
What the command copies — 5 files, alias-free so the copy lands standing alone. This is the whole component; there is nothing else.
---
// src/components/ui/forms/checkbox-group/CheckboxGroup.astro — headless primitive (see ../../README.md).
// A <fieldset> of related checkboxes with an optional "select all" parent — mark that one item
// `toggleAll` and the bundled script keeps the two in sync in both directions.
//
// The script exists for exactly one reason: `indeterminate` is a DOM PROPERTY with no matching HTML
// attribute. There is no way to write the mixed state in markup, and `aria-checked="mixed"` alone
// would claim a state the checkbox does not actually have. So the parent renders unchecked and the
// script promotes it to mixed once it can see the items. Everything else here — grouping, labelling,
// submission — is native and works with the script removed.
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"fieldset"> & { orientation?: "vertical" | "horizontal" };
const { orientation = "vertical", class: className, ...rest } = Astro.props;
---
<fieldset class={className} data-slot="checkbox-group" data-orientation={orientation} {...rest}>
<slot />
</fieldset>
<script>
import { onReadyOnce } from "../../_once";
import { groupState } from "./group-state";
function wire(root: HTMLElement) {
const all = root.querySelector<HTMLInputElement>('[data-toggle-all="true"]');
const boxes = [...root.querySelectorAll<HTMLInputElement>('[data-slot="checkbox"]')].filter(
(box) => box !== all,
);
if (!all || boxes.length === 0) return;
const sync = () => {
const state = groupState(boxes.map((box) => box.checked));
all.checked = state === "all";
all.indeterminate = state === "some";
};
all.addEventListener("change", () => {
// A click on a mixed box lands here with `checked === true`, which is the behavior people
// expect: the first click selects everything, the next clears it.
//
// Read the target ONCE. Each `change` we dispatch below bubbles back into the item listener,
// which re-runs sync() and rewrites `all.checked` — so re-reading it inside the loop means the
// parent's state changes halfway through and the remaining boxes are set to the wrong value.
const next = all.checked;
for (const box of boxes) {
if (box.disabled || box.checked === next) continue;
box.checked = next;
box.dispatchEvent(new Event("change", { bubbles: true }));
}
sync();
});
for (const box of boxes) box.addEventListener("change", sync);
sync();
}
onReadyOnce('[data-slot="checkbox-group"]', wire);
</script>
---
// src/components/ui/forms/checkbox-group/CheckboxGroupItem.astro — CheckboxGroup compound part (see ../../README.md).
// One checkbox + its text in a <label>, so the text is part of the hit target with no `id`/`for` pair
// (the nested control is the native implicit association).
//
// `toggleAll` marks this item as the group's parent box. It renders `data-toggle-all="true"` — the
// hook CheckboxGroup's script looks for — and nothing else: the mixed state cannot be expressed in
// markup, so this attribute is the only part of it that can be.
import type { HTMLAttributes } from "astro/types";
import Checkbox from "../../forms/checkbox";
type Props = HTMLAttributes<"input"> & { toggleAll?: boolean };
const { toggleAll, class: className, ...rest } = Astro.props;
---
{/* eslint-disable-next-line astro/jsx-a11y/label-has-associated-control */}
<label data-slot="checkbox-group-item">
<Checkbox class={className} data-toggle-all={toggleAll ? "true" : undefined} {...rest} />
<span data-slot="checkbox-group-item-label"><slot /></span>
</label>
---
// src/components/ui/forms/checkbox-group/CheckboxGroupLabel.astro — CheckboxGroup compound part (see ../../README.md).
// The group's <legend> — what names the set for a screen reader, so each box is announced as part of
// "Notifications" rather than on its own. It must be the FIRST child of the CheckboxGroup; HTML only
// treats a <legend> as the fieldset's caption in that position.
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"legend">;
const { class: className, ...rest } = Astro.props;
---
<legend class={className} data-slot="checkbox-group-label" {...rest}><slot /></legend>
// src/components/ui/forms/checkbox-group/group-state.ts — the tri-state rule behind CheckboxGroup's
// select-all box, in a plain module so it is unit-checkable (see group-state.test.ts) without a DOM.
export type GroupState = "none" | "some" | "all";
/**
* Reduce a group's checked flags to the state its parent "select all" box should show.
*
* The empty case is the whole reason this is a function rather than an inline `every()`:
* `[].every(Boolean)` is `true`, so a group that has lost its items would render its select-all
* box CHECKED — claiming everything is selected when there is nothing to select.
*
* @param flags - each item's `checked`, in DOM order
* @returns `"all"` when every item is checked, `"some"` for a mixed group (the indeterminate case),
* `"none"` when nothing is checked — and for a group with no items at all
* @example groupState([true, false]) // => "some"
*/
export function groupState(flags: readonly boolean[]): GroupState {
if (flags.length === 0 || !flags.some(Boolean)) return "none";
return flags.every(Boolean) ? "all" : "some";
}
import CheckboxGroup from "./CheckboxGroup.astro";
import CheckboxGroupItem from "./CheckboxGroupItem.astro";
import CheckboxGroupLabel from "./CheckboxGroupLabel.astro";
export { type GroupState, groupState } from "./group-state";
export { CheckboxGroup, CheckboxGroupItem, CheckboxGroupLabel };
export default CheckboxGroup;
What you get
The component source, copied into your project by npx astrocraft-ui add forms/checkbox-group — no package dependency, no CSS to fight. Whatever the paid blocks compose, this is it.