Skip to main content
astrocraft-ui/ components · 101

Checkbox Group

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 forms/checkbox-group

Plain-CSS theme — no build step

Install command
npx astrocraft-ui add forms/checkbox-group --theme css

Tailwind theme — needs Tailwind v4

Install command
npx astrocraft-ui add forms/checkbox-group --theme tailwind

Plain-CSS theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add forms/checkbox-group --theme css --bridge lumos

Tailwind theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add forms/checkbox-group --theme tailwind --bridge lumos

Live 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.

Plan
Billing period
Notifications

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
`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
---
// 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
---
// 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
---
// 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
// 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";
}
src/components/ui/forms/checkbox-group/index.ts
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.