Skip to content

role group

Checkbox group draft v0.1.0

Lets people pick any number of options from a short, visible set, under one legend.

A checkbox group shows every option at once, with a legend naming the choice, and lets people check any number of them, including none. It is the grouped form of the Checkbox: the group carries the legend, the shared help text and the error message, and each option its own label, description, value and checked state. Exactly one choice is a Radio group; a long list is a Select.

Web implementation

Root .ds-checkbox-group on a <fieldset>. Properties map to attributes: orientation → data-orientationsize → data-sizedisabled → disabledinvalid → data-invalid

packages/components/src/components/checkbox-group/checkbox-group.css
/*
 * Checkbox group — vanilla CSS reference implementation.
 * Spec:   packages/components/specs/components/checkbox-group.spec.json
 * Tokens: packages/tokens/dist/css/components/checkbox-group.css (generated from packages/tokens/src/components/checkbox-group.tokens.json)
 *
 * Rules:
 *  - Only --checkbox-group-* custom properties may appear here. No raw values, no semantic tokens.
 *  - The root is a fieldset (legend, description, options, error message); each option is a grid of
 *    a native checkbox (the control, drawn by the component), its label and its description. The check is
 *    a pseudo-element of the input.
 *  - Orientation and size are data attributes on the root; states are native pseudo-classes of the
 *    radio. A state is forced for review with [data-state="<state>"] on the root (every option) or on
 *    one option (the states matrix forces one entry). Precedence follows the spec order:
 *    default < hover < focus-visible < checked < invalid < disabled.
 *  - Private properties (--_*) are the single place an option or a state writes to.
 */

.ds-checkbox-group {
  /* Defaults = orientation "vertical", size "medium", state "default". */
  --_options-gap: var(--checkbox-group-container-gap-vertical);
  --_control-size: var(--checkbox-group-control-size-medium);
  --_control-radius: var(--checkbox-group-control-border-radius-medium);
  --_legend-color: var(--checkbox-group-legend-color-default);
  --_description-color: var(--checkbox-group-description-color-default);
  --_label-font-family: var(--checkbox-group-option-label-typography-medium-font-family);
  --_label-font-size: var(--checkbox-group-option-label-typography-medium-font-size);
  --_label-font-weight: var(--checkbox-group-option-label-typography-medium-font-weight);
  --_label-line-height: var(--checkbox-group-option-label-typography-medium-line-height);
  --_label-letter-spacing: var(--checkbox-group-option-label-typography-medium-letter-spacing);
  --_option-description-font-family: var(--checkbox-group-option-description-typography-medium-font-family);
  --_option-description-font-size: var(--checkbox-group-option-description-typography-medium-font-size);
  --_option-description-font-weight: var(--checkbox-group-option-description-typography-medium-font-weight);
  --_option-description-line-height: var(--checkbox-group-option-description-typography-medium-line-height);
  --_option-description-letter-spacing: var(--checkbox-group-option-description-typography-medium-letter-spacing);

  box-sizing: border-box;
  display: flex;
  flex-direction: column;
  gap: var(--checkbox-group-container-row-gap);
  min-inline-size: 0;
  margin: 0;
  padding: 0;
  border: none;
}

.ds-checkbox-group[data-size="small"] {
  --_control-size: var(--checkbox-group-control-size-small);
  --_control-radius: var(--checkbox-group-control-border-radius-small);
  --_label-font-family: var(--checkbox-group-option-label-typography-small-font-family);
  --_label-font-size: var(--checkbox-group-option-label-typography-small-font-size);
  --_label-font-weight: var(--checkbox-group-option-label-typography-small-font-weight);
  --_label-line-height: var(--checkbox-group-option-label-typography-small-line-height);
  --_label-letter-spacing: var(--checkbox-group-option-label-typography-small-letter-spacing);
  --_option-description-font-family: var(--checkbox-group-option-description-typography-small-font-family);
  --_option-description-font-size: var(--checkbox-group-option-description-typography-small-font-size);
  --_option-description-font-weight: var(--checkbox-group-option-description-typography-small-font-weight);
  --_option-description-line-height: var(--checkbox-group-option-description-typography-small-line-height);
  --_option-description-letter-spacing: var(--checkbox-group-option-description-typography-small-letter-spacing);
}

.ds-checkbox-group[data-orientation="horizontal"] {
  --_options-gap: var(--checkbox-group-container-gap-horizontal);
}

/* ---- Group parts --------------------------------------------------------- */

.ds-checkbox-group__legend {
  padding: 0;
  color: var(--_legend-color);
  font-family: var(--checkbox-group-legend-typography-font-family);
  font-size: var(--checkbox-group-legend-typography-font-size);
  font-weight: var(--checkbox-group-legend-typography-font-weight);
  line-height: var(--checkbox-group-legend-typography-line-height);
  letter-spacing: var(--checkbox-group-legend-typography-letter-spacing);
}
/* A legend sits outside the flex flow of its fieldset: the gap is written as a margin. */
.ds-checkbox-group__legend + * {
  margin-block-start: 0;
}

.ds-checkbox-group__description {
  margin: 0;
  color: var(--_description-color);
  font-family: var(--checkbox-group-description-typography-font-family);
  font-size: var(--checkbox-group-description-typography-font-size);
  font-weight: var(--checkbox-group-description-typography-font-weight);
  line-height: var(--checkbox-group-description-typography-line-height);
  letter-spacing: var(--checkbox-group-description-typography-letter-spacing);
}

.ds-checkbox-group__options {
  display: flex;
  flex-direction: column;
  gap: var(--_options-gap);
  margin: 0;
  padding: 0;
  list-style: none;
}
.ds-checkbox-group[data-orientation="horizontal"] .ds-checkbox-group__options {
  flex-direction: row;
  flex-wrap: wrap;
}

.ds-checkbox-group__error-message {
  margin: 0;
  color: var(--checkbox-group-error-message-color);
  font-family: var(--checkbox-group-error-message-typography-font-family);
  font-size: var(--checkbox-group-error-message-typography-font-size);
  font-weight: var(--checkbox-group-error-message-typography-font-weight);
  line-height: var(--checkbox-group-error-message-typography-line-height);
  letter-spacing: var(--checkbox-group-error-message-typography-letter-spacing);
}

/* ---- Option ------------------------------------------------------------- */

.ds-checkbox-group__option {
  --_control-background: var(--checkbox-group-control-background-default);
  --_control-border-color: var(--checkbox-group-control-border-color-default);
  --_control-color: var(--checkbox-group-control-color-checked);
  --_label-color: var(--checkbox-group-option-label-color-default);
  --_option-description-color: var(--checkbox-group-option-description-color-default);

  display: inline-grid;
  grid-template-columns: auto 1fr;
  column-gap: var(--checkbox-group-option-column-gap);
  row-gap: var(--checkbox-group-option-row-gap);
  align-items: start;
}

.ds-checkbox-group__control {
  box-sizing: border-box;
  display: inline-grid;
  place-content: center;
  inline-size: var(--_control-size);
  block-size: var(--_control-size);
  margin: 0;
  margin-block-start: calc((var(--_label-line-height) * var(--_label-font-size) - var(--_control-size)) / 2);
  border: var(--checkbox-group-control-border-width) solid var(--_control-border-color);
  border-radius: var(--_control-radius);
  background-color: var(--_control-background);
  color: var(--_control-color);
  cursor: pointer;
  appearance: none;
  -webkit-appearance: none;
  transition:
    background-color var(--checkbox-group-control-transition-duration) var(--checkbox-group-control-transition-easing),
    border-color var(--checkbox-group-control-transition-duration) var(--checkbox-group-control-transition-easing);
}

/* The check mark: two borders rotated, in currentColor from the control's color token; a fraction of the control size. */
.ds-checkbox-group__control::after {
  content: "";
  display: block;
  box-sizing: border-box;
  inline-size: calc(var(--_control-size) * 0.3);
  block-size: calc(var(--_control-size) * 0.55);
  margin-block-end: calc(var(--_control-size) * 0.12);
  border: solid currentColor;
  border-width: 0 calc(var(--_control-size) * 0.12) calc(var(--_control-size) * 0.12) 0;
  transform: rotate(45deg);
  opacity: 0;
}

.ds-checkbox-group__option-label {
  grid-column: 2;
  color: var(--_label-color);
  font-family: var(--_label-font-family);
  font-size: var(--_label-font-size);
  font-weight: var(--_label-font-weight);
  line-height: var(--_label-line-height);
  letter-spacing: var(--_label-letter-spacing);
  cursor: pointer;
}

.ds-checkbox-group__option-description {
  grid-column: 2;
  margin: 0;
  color: var(--_option-description-color);
  font-family: var(--_option-description-font-family);
  font-size: var(--_option-description-font-size);
  font-weight: var(--_option-description-font-weight);
  line-height: var(--_option-description-line-height);
  letter-spacing: var(--_option-description-letter-spacing);
}

/* ---- States -------------------------------------------------------------- */
/* Forced on the root (every option) or on one option; native pseudo-classes on the radio.
   Order matters: hover < checked < invalid < disabled; hover never repaints a checked or disabled radio. */

:where(.ds-checkbox-group[data-state="hover"], .ds-checkbox-group__option[data-state="hover"]) .ds-checkbox-group__control:not(:checked, :disabled),
.ds-checkbox-group:not([data-state]) .ds-checkbox-group__option:not([data-state]) .ds-checkbox-group__control:hover:not(:checked, :disabled) {
  --_control-background: var(--checkbox-group-control-background-hover);
  --_control-border-color: var(--checkbox-group-control-border-color-hover);
}

:where(.ds-checkbox-group[data-state="checked"], .ds-checkbox-group__option[data-state="checked"]) .ds-checkbox-group__control,
.ds-checkbox-group__control:checked {
  --_control-background: var(--checkbox-group-control-background-checked);
  --_control-border-color: var(--checkbox-group-control-border-color-checked);
  --_control-color: var(--checkbox-group-control-color-checked);
}
:where(.ds-checkbox-group[data-state="checked"], .ds-checkbox-group__option[data-state="checked"]) .ds-checkbox-group__control::after,
.ds-checkbox-group__control:checked::after {
  opacity: 1;
}

:where(.ds-checkbox-group[data-state="invalid"], .ds-checkbox-group__option[data-state="invalid"]) .ds-checkbox-group__control,
.ds-checkbox-group__control[aria-invalid="true"] {
  --_control-border-color: var(--checkbox-group-control-border-color-invalid);
}

.ds-checkbox-group:where([data-state="disabled"]),
.ds-checkbox-group:disabled {
  --_legend-color: var(--checkbox-group-legend-color-disabled);
  --_description-color: var(--checkbox-group-description-color-disabled);
}
:where(.ds-checkbox-group[data-state="disabled"], .ds-checkbox-group__option[data-state="disabled"]) .ds-checkbox-group__option,
:where(.ds-checkbox-group[data-state="disabled"], .ds-checkbox-group__option[data-state="disabled"]),
.ds-checkbox-group__option:has(.ds-checkbox-group__control:disabled) {
  --_label-color: var(--checkbox-group-option-label-color-disabled);
  --_option-description-color: var(--checkbox-group-option-description-color-disabled);
}
:where(.ds-checkbox-group[data-state="disabled"], .ds-checkbox-group__option[data-state="disabled"]) .ds-checkbox-group__control,
.ds-checkbox-group__control:disabled {
  --_control-background: var(--checkbox-group-control-background-disabled);
  --_control-border-color: var(--checkbox-group-control-border-color-disabled);
  --_control-color: var(--checkbox-group-control-color-disabled);
  cursor: not-allowed;
}
:where(.ds-checkbox-group[data-state="disabled"], .ds-checkbox-group__option[data-state="disabled"]) .ds-checkbox-group__option-label,
.ds-checkbox-group__option:has(.ds-checkbox-group__control:disabled) .ds-checkbox-group__option-label {
  cursor: not-allowed;
}

.ds-checkbox-group__control:focus {
  outline: none;
}

:where(.ds-checkbox-group[data-state="focus-visible"], .ds-checkbox-group__option[data-state="focus-visible"]) .ds-checkbox-group__control,
.ds-checkbox-group__control:focus-visible {
  outline: var(--checkbox-group-control-outline-width-focus-visible) solid var(--checkbox-group-control-outline-color-focus-visible);
  outline-offset: var(--checkbox-group-control-outline-offset-focus-visible);
}

@media (prefers-reduced-motion: reduce) {
  .ds-checkbox-group__control {
    transition: none;
  }
}
packages/components/src/components/checkbox-group/checkbox-group.template.mjs
/**
 * Checkbox group — HTML template for the web implementation.
 * Pure function of the spec properties (packages/components/specs/components/checkbox-group.spec.json) -> HTML string.
 * Used by the docs site (server and client side). No DOM, no framework, no script: native
 * checkboxes in a fieldset whose legend names the group; each option is checked independently.
 *
 * Extra props: `id` names the group (option ids derive from it; unique in the page; without it
 * instances are numbered in render order); `attrs` adds HTML attributes to every radio.
 * Extra sandbox-only props: `state` forces a visual state through data-state on the root (every
 * option); an entry's `state` forces it on that option only.
 */
import { escapeHtml, escapeAttr } from '../../lib/template-utils.mjs';

export const meta = { id: 'checkbox-group', selector: '.ds-checkbox-group' };

let count = 0;

export function render(props = {}) {
  const {
    label = 'Options',
    description = null,
    errorMessage = null,
    name = null,
    orientation = 'vertical',
    size = 'medium',
    options = [{ label: 'Option 1', value: '1' }, { label: 'Option 2', value: '2' }],
    required = false,
    disabled = false,
    invalid = false,
    state = null,
    id = null,
    attrs: extra = {},
  } = props;

  const groupId = id ?? `ds-checkbox-group-${++count}`;
  const groupName = name ?? groupId;
  // Each checkbox submits its own value under the shared name (name[] conventions belong to the consumer).
  const descriptionId = `${groupId}-description`;
  const errorId = `${groupId}-error`;
  const describedBy = [description ? descriptionId : null, invalid && errorMessage ? errorId : null].filter(Boolean).join(' ');

  const rootAttrs = ['class="ds-checkbox-group"', `data-orientation="${escapeAttr(orientation)}"`, `data-size="${escapeAttr(size)}"`];
  if (disabled) rootAttrs.push('data-disabled', 'disabled');
  if (invalid) rootAttrs.push('data-invalid');
  if (state && state !== 'default') rootAttrs.push(`data-state="${escapeAttr(state)}"`);

  const items = options.map((entry, i) => {
    const { label: optionLabel = `Option ${i + 1}`, value = String(i + 1), description: optionDescription = null, checked = false, disabled: optionDisabled = false, state: optionState = null } = entry;
    const optionId = `${groupId}-${i + 1}`;
    const optionDescriptionId = `${optionId}-description`;
    const optionAttrs = ['class="ds-checkbox-group__option"', 'data-part="option"'];
    if (optionState && optionState !== 'default') optionAttrs.push(`data-state="${escapeAttr(optionState)}"`);
    const inputAttrs = ['type="checkbox"', 'class="ds-checkbox-group__control"', 'data-part="control"', `id="${escapeAttr(optionId)}"`, `name="${escapeAttr(groupName)}"`, `value="${escapeAttr(value)}"`];
    if (checked) inputAttrs.push('checked');
    if (optionDisabled) inputAttrs.push('disabled');
    if (required && i === 0) inputAttrs.push('required');
    if (invalid) inputAttrs.push('aria-invalid="true"');
    const ownDescribedBy = [optionDescription ? optionDescriptionId : null, describedBy || null].filter(Boolean).join(' ');
    if (ownDescribedBy) inputAttrs.push(`aria-describedby="${escapeAttr(ownDescribedBy)}"`);
    for (const [k, v] of Object.entries(extra)) {
      if (v === false || v == null) continue;
      inputAttrs.push(v === true ? escapeAttr(k) : `${escapeAttr(k)}="${escapeAttr(v)}"`);
    }
    const parts = [
      `<input ${inputAttrs.join(' ')}>`,
      `<label class="ds-checkbox-group__option-label" data-part="option-label" for="${escapeAttr(optionId)}">${escapeHtml(optionLabel)}</label>`,
    ];
    if (optionDescription) parts.push(`<p class="ds-checkbox-group__option-description" data-part="option-description" id="${escapeAttr(optionDescriptionId)}">${escapeHtml(optionDescription)}</p>`);
    return `<li><div ${optionAttrs.join(' ')}>${parts.join('')}</div></li>`;
  });

  const parts = [`<legend class="ds-checkbox-group__legend" data-part="legend">${escapeHtml(label)}</legend>`];
  if (description) parts.push(`<p class="ds-checkbox-group__description" data-part="description" id="${escapeAttr(descriptionId)}">${escapeHtml(description)}</p>`);
  parts.push(`<ul class="ds-checkbox-group__options">${items.join('')}</ul>`);
  if (invalid && errorMessage) parts.push(`<p class="ds-checkbox-group__error-message" data-part="error-message" id="${escapeAttr(errorId)}">${escapeHtml(errorMessage)}</p>`);
  return `<fieldset ${rootAttrs.join(' ')}>${parts.join('')}</fieldset>`;
}

No other platform implementation recorded yet. Platform token outputs live under Platforms.