Docs
Next

Radios and checkboxes

FormRadio and FormCheckbox, single choice controls with a token-styled box, an HTML label and checked, invalid and disabled states.

On this page

FormRadio and FormCheckbox render one choice each: the native input, made invisible but still focusable, under a box drawn from the form tokens, followed by its label. For a labelled group with a description, an error message and group roles, use RadioField and CheckboxField.

Checkboxes
import { FormCheckbox } from '@systhemaui/next'

export default function Demo() {
  return (
    <div style={{ display: 'grid', gap: 12 }}>
      <FormCheckbox
        id="choice-news"
        name="choice-topics"
        value="news"
        label="Product news"
        defaultChecked
      />
      <FormCheckbox
        id="choice-events"
        name="choice-topics"
        value="events"
        label="Events and webinars"
      />
      <FormCheckbox id="choice-offers" name="choice-topics" value="offers" label="Offers" />
    </div>
  )
}

ImportLink to this section

import { FormCheckbox, FormRadio } from '@systhemaui/next'

In a React app without Next.js, import them from @systhemaui/react.

ExamplesLink to this section

RadiosLink to this section

Radios that share a name form one choice. Give each one its own id: without it, id falls back to name, so every radio of the group would get the same id and each label would point at the first radio.

Radio group
import { FormRadio } from '@systhemaui/next'

export default function Demo() {
  return (
    <div style={{ display: 'grid', gap: 12 }}>
      <FormRadio id="plan-monthly" name="plan" value="monthly" label="Monthly" defaultChecked />
      <FormRadio id="plan-yearly" name="plan" value="yearly" label="Yearly (2 months free)" />
      <FormRadio id="plan-lifetime" name="plan" value="lifetime" label="Lifetime" />
    </div>
  )
}

label is an HTML string, inserted with dangerouslySetInnerHTML, so it can carry a link or emphasis. Only pass trusted strings, never visitor input.

Consent checkbox
import { FormCheckbox } from '@systhemaui/next'

export default function Demo() {
  return (
    <FormCheckbox
      id="choice-terms"
      name="choice-terms"
      value="accepted"
      label='I accept the <a class="link" href="#labels-with-links">terms and conditions</a>'
      required
    />
  )
}

StatesLink to this section

The box takes the checked colors when its input is checked, the invalid colors once a required input is :user-invalid (after the visitor has interacted with it or tried to submit), and the disabled opacity when the input is disabled.

Checked and disabled
import { FormCheckbox, FormRadio } from '@systhemaui/next'

export default function Demo() {
  return (
    <div style={{ display: 'grid', gap: 12 }}>
      <FormCheckbox
        id="state-checked"
        name="state-checked"
        value="1"
        label="Checked"
        defaultChecked
      />
      <FormCheckbox id="state-disabled" name="state-disabled" value="1" label="Disabled" disabled />
      <FormRadio
        id="state-radio-disabled"
        name="state-radio"
        value="1"
        label="Disabled radio"
        disabled
        defaultChecked
      />
    </div>
  )
}

Extra contentLink to this section

prepend and append render inside the wrapper before the input and after the label, for a badge or a price next to the choice. wrapperProps forwards attributes to the wrapper <div>; className also goes on the wrapper, while every other prop goes to the <input>.

PropsLink to this section

FormRadioLink to this section

PropTypeDefaultDescription
value (required)string-
label (required)string-
name (required)string-
idstring-
prependReactNode-
appendReactNode-
classNamestring-
wrapperPropsOmit<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, 'ref'>-
…and all <input> attributes

FormCheckboxLink to this section

PropTypeDefaultDescription
value (required)string-
name (required)string-
idstring-
label (required)string-
prependReactNode-
appendReactNode-
classNamestring-
wrapperPropsOmit<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, 'ref'>-
…and all <input> attributes

HTML and CSSLink to this section

<div class="form-checkbox">
  <input type="checkbox" id="news" name="topics" value="news" />
  <label for="news">Product news</label>
</div>

<div class="form-radio">
  <input type="radio" id="plan-monthly" name="plan" value="monthly" checked />
  <label for="plan-monthly">Monthly</label>
</div>

The wrapper draws the box with ::before and the mark with ::after: a dot for radios, and for checkboxes the icon set with blocks.form.checkboxIcon (see Component CSS blocks). Wrap several choices in .form-inline-input-group to space them by --form-input-inline-space-y.

ClassStyles
form& > :not([hidden], input[type="hidden"]) ~ :not([hidden], input[type="hidden"]) { margin-top: var(--form-row-gap-y); }
form-row@media (width < 640px) { & { display: flex; flex-direction: column; row-gap: var(--form-row-gap-y); } }@media (width >= 640px) { & { display: flex; flex-wrap: wrap; gap: var(--form-row-gap-x); } }@media (width >= 640px) { & > .form-group { flex: 1 1 0%; min-width: 0; } }@media (width >= 640px) { & > .form-group[style*="--width"] { flex: 0 0 auto; width: calc(var(--width) - var(--form-row-gap-x) * (1 - var(--width) / 100%)); } }
form-group@media (width >= 640px) { .form-row > .form-group { flex: 1 1 0%; min-width: 0; } }@media (width >= 640px) { .form-row > .form-group[style*="--width"] { flex: 0 0 auto; width: calc(var(--width) - var(--form-row-gap-x) * (1 - var(--width) / 100%)); } }& > :not([hidden]) ~ :not([hidden]) { margin-top: var(--form-group-gap-y); }&:has(:required) .form-label::after { content: ' *'; color: var(--color-form-label-required); }+ 1 more rule
form-label& { display: block; color: var(--color-form-label); }& { font-family: var(--font-form-label-font-family); font-size: var(--typography-form-label-font-size); font-weight: var(--font-form-label-font-weight); letter-spacing: var(--typography-form-label-letter-spacing); line-height: var(--typography-form-label-line-height); text-transform: none; text-decoration: none; font-style: var(--font-form-label-font-style); }+ 1 more rule
form-description& { display: block; color: var(--color-form-description); }& { font-family: var(--font-form-description-font-family); font-size: var(--typography-form-description-font-size); font-weight: var(--font-form-description-font-weight); letter-spacing: var(--typography-form-description-letter-spacing); line-height: var(--typography-form-description-line-height); text-transform: none; text-decoration: none; font-style: var(--font-form-description-font-style); }
form-input& { --tw-shadow-color: var(--color-form-input-block-default-shadow); --tw-shadow: var(--tw-shadow-color); width: 100%; outline: none; border-style: solid; padding-inline: var(--form-input-block-padding-x); padding-block: var(--form-input-block-padding-y); border-radius: var(--form-input-block-border-radius); border-width: var(--form-input-block-border-width); background-color: var(--color-form-input-block-default-background); border-color: var(--color-form-input-block-defaul…+ 4 more rules
form-select& { --tw-shadow-color: var(--color-form-input-block-default-shadow); --tw-shadow: var(--tw-shadow-color); width: 100%; outline: none; border-style: solid; padding-inline: var(--form-input-block-padding-x); padding-block: var(--form-input-block-padding-y); border-radius: var(--form-input-block-border-radius); border-width: var(--form-input-block-border-width); background-color: var(--color-form-input-block-default-background); border-color: var(--color-form-input-block-defaul…+ 12 more rules
form-textarea& { --tw-shadow-color: var(--color-form-input-block-default-shadow); --tw-shadow: var(--tw-shadow-color); width: 100%; outline: none; border-style: solid; padding-inline: var(--form-input-block-padding-x); padding-block: var(--form-input-block-padding-y); border-radius: var(--form-input-block-border-radius); border-width: var(--form-input-block-border-width); background-color: var(--color-form-input-block-default-background); border-color: var(--color-form-input-block-defaul…+ 5 more rules
search-field-input& { --tw-shadow-color: var(--color-form-input-block-default-shadow); --tw-shadow: var(--tw-shadow-color); width: 100%; outline: none; border-style: solid; padding-inline: var(--form-input-block-padding-x); padding-block: var(--form-input-block-padding-y); border-radius: var(--form-input-block-border-radius); border-width: var(--form-input-block-border-width); background-color: var(--color-form-input-block-default-background); border-color: var(--color-form-input-block-defaul…+ 9 more rules
is-invalid:is(.form-select:user-invalid ~ .form-select-icon, .form-select.is-invalid ~ .form-select-icon) { color: var(--color-form-input-block-invalid-icon); background-color: var(--color-form-input-block-invalid-icon); }:is(.form-group:has(:user-invalid) .form-invalid-message, .form-group:has(.is-invalid) .form-invalid-message) { display: block; }
form-select-wrapperposition: relative;
form-select-icon:is(.form-select ~ .form-select-icon) { pointer-events: none; position: absolute; top: 50%; transform: translateY(-50%); object-fit: contain; object-position: center; overflow: visible; line-height: 100%; right: var(--form-input-block-padding-x); width: var(--form-input-block-icon-size); height: var(--form-input-block-icon-size); font-size: var(--form-input-block-icon-size); color: var(--color-form-input-block-default-icon); }+ 3 more rules
search-fieldposition: relative;
search-field-icon:is(.search-field-input ~ .search-field-icon) { pointer-events: none; position: absolute; top: 50%; transform: translateY(-50%); object-fit: contain; object-position: center; overflow: visible; line-height: 100%; right: var(--form-input-block-padding-x); width: var(--form-input-block-icon-size); height: var(--form-input-block-icon-size); font-size: var(--form-input-block-icon-size); color: var(--color-form-input-block-default-icon); }+ 2 more rules
form-inline-input-group& > :not([hidden]) ~ :not([hidden]) { margin-top: var(--form-input-inline-space-y); }
form-radio& { position: relative; user-select: none; }& input[type="radio"], .form-radio input[type="checkbox"], .form-radio:before { content: ""; position: absolute; left: 0; top: calc(var(--typography-form-inline-line-height) / 2); transform: translateY(-50%); width: var(--form-input-inline-input-size); height: var(--form-input-inline-input-size); }& input[type="radio"], .form-radio input[type="checkbox"] { opacity: 0; cursor: pointer; }&:has(:checked):after { opacity: 1; }+ 13 more rules
form-checkbox& { position: relative; user-select: none; }& input[type="radio"], .form-checkbox input[type="checkbox"], .form-checkbox:before { content: ""; position: absolute; left: 0; top: calc(var(--typography-form-inline-line-height) / 2); transform: translateY(-50%); width: var(--form-input-inline-input-size); height: var(--form-input-inline-input-size); }& input[type="radio"], .form-checkbox input[type="checkbox"] { opacity: 0; cursor: pointer; }+ 14 more rules
form-invalid-message& { display: none; color: var(--color-form-invalid-message); }:is(.form-group:has(:user-invalid) .form-invalid-message, .form-group:has(.is-invalid) .form-invalid-message) { display: block; }
form-file-input& { cursor: pointer; padding: 0; height: calc(var(--form-input-block-padding-y) * 2 + var(--typography-form-block-line-height)); display: flex; align-items: center; }+ 2 more rules

Next.jsLink to this section

@systhemaui/next re-exports FormRadio and FormCheckbox from @systhemaui/react unchanged.

AccessibilityLink to this section

  • The native input stays in the page (transparent, on top of the box), so keyboard focus, Space and screen-reader states work as usual.
  • Name a group of radios or checkboxes: wrap it in a role="radiogroup" or role="group" element with aria-labelledby, or use RadioField / CheckboxField, which do it for you.
  • A checkbox or radio has no visible focus style beyond what you add; the input is transparent, so style the box with a :has(:focus-visible) selector on .form-checkbox / .form-radio if your tokens need one.