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.
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.
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>
)
}Labels with linksLink to this section
label is an HTML string, inserted with dangerouslySetInnerHTML, so it can carry a link or emphasis. Only pass trusted strings, never visitor input.
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.
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
| Prop | Type | Default | Description |
|---|---|---|---|
value (required) | string | - | |
label (required) | string | - | |
name (required) | string | - | |
id | string | - | |
prepend | ReactNode | - | |
append | ReactNode | - | |
className | string | - | |
wrapperProps | Omit<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, 'ref'> | - | |
…and all <input> attributes |
FormCheckboxLink to this section
| Prop | Type | Default | Description |
|---|---|---|---|
value (required) | string | - | |
name (required) | string | - | |
id | string | - | |
label (required) | string | - | |
prepend | ReactNode | - | |
append | ReactNode | - | |
className | string | - | |
wrapperProps | Omit<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.
| Class | Styles |
|---|---|
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-wrapper | position: 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-field | position: 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,
Spaceand screen-reader states work as usual. - Name a group of radios or checkboxes: wrap it in a
role="radiogroup"orrole="group"element witharia-labelledby, or useRadioField/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-radioif your tokens need one.