Component
Checkbox & Radio Button
Selection controls share the --components-checkbox-radio-* tokens. Use Checkbox for independent, non-exclusive options and RadioButton for a single choice from a mutually exclusive set.
Playground
Type
Selection
Size
State
Label heading
Label
Helper content
Helper text
Preview
JSX
<Checkbox
type="primary"
size="sm"
selectionType="default"
showLabelHeading
showHelperContent
label="Checkbox Title"
helperText="Helper text write here..."
/>Props
The full CheckboxProps API. Visual selection is driven by selectionType, color by type.
| Prop | Type | Default | Description |
|---|---|---|---|
selectionType | 'default' | 'selected' | 'indeterminate' | 'default' | Visual selection state. 'indeterminate' is for partial group selection only (e.g. select-all). |
type | 'primary' | 'error' | 'warning' | 'success' | 'information' | 'primary' | Intent — controls the --components-checkbox-radio-* color tokens. |
size | 'sm' | 'md' | 'sm' | Box and icon dimension. 'sm' = 16px, 'md' = 20px. |
state | 'default' | 'focus' | 'hover' | 'disabled' | 'default' | Interaction state. 'hover' / 'focus' paint the subtle background ring; 'disabled' greys the box. |
showLabelHeading | boolean | true | Show the label title beside the box. |
showHelperContent | boolean | true | Show the helper text below the label (requires helperText). |
label | string | 'Checkbox Title' | Label title text. Linked to the box as the accessible name. |
helperText | string | 'Helper text write here...' | Supporting helper text shown when showHelperContent is true. |
onChange | (next: boolean) => void | — | Called when the box is toggled. Omit for a read-only sample. |
className | string | — | Layout-only classes (margin, positioning). |
Usage guidelines — Checkbox
When to use
- Let users independently toggle one or more non-exclusive options — filters, permissions, settings.
- Use for agree-to-terms / consent flows, paired with a clear label heading.
- Use selectionType="indeterminate" for a bulk select-all when only some items in the group are checked.
When not to use
- Don't use for mutually exclusive choices — reach for RadioButton instead.
- Don't use for an instant on/off action with immediate effect — use a Toggle / Switch.
- Don't use indeterminate as a loading or unknown state — it only means partial group selection.
Usage guidelines — Radio Button
When to use
- Let users pick exactly one option from a mutually exclusive set — payment method, shipping speed.
- Always render at least two radios sharing a name so the group is meaningful.
- Group related radios inside a fieldset with a legend describing the choice.
When not to use
- Don't use when more than one option can be selected at once — use Checkbox instead.
- Don't use a single standalone radio — a lone radio with no group is meaningless.
- Don't use for a binary on/off toggle with immediate effect — use a Toggle / Switch.
nexus/components/checkbox.tsx
import {
CheckBoxOutlineIcon,
CheckBoxFillIcon,
IndeterminateCheckBoxFillIcon,
} from "nexus/icons";
export type CheckboxType =
| "primary"
| "error"
| "warning"
| "success"
| "information";
export type CheckboxSelectionType = "default" | "selected" | "indeterminate";
export type CheckboxSize = "sm" | "md";
export type CheckboxState = "default" | "focus" | "hover" | "disabled";
export interface CheckboxProps {
/** Visual selection state of the box. */
selectionType?: CheckboxSelectionType;
/** Intent — drives the `--components-checkbox-radio-*` color tokens. */
type?: CheckboxType;
/** Box + icon dimension. `sm` = 16px, `md` = 20px (Figma default: `sm`). */
size?: CheckboxSize;
/** Interaction state. Only `focus` paints the subtle background ring. */
state?: CheckboxState;
/** Show the label title beside the box. */
showLabelHeading?: boolean;
/** Show the helper text below the label (requires `helperText`). */
showHelperContent?: boolean;
/** Label title text (requires `showLabelHeading`). */
label?: string;
/** Supporting helper text (requires `showHelperContent`). */
helperText?: string;
/** Called when the control is toggled (omit for a read-only sample). */
onChange?: (next: boolean) => void;
/** Layout-only classes (margin, positioning). */
className?: string;
}
/**
* Per-type icon fill classes, spelled out as literal strings so Tailwind emits
* every utility. The four keys map to the State → Token table in the spec:
* `default` (empty box), `primary` (checked fill), `onPrimary` (checked hover),
* `disabled` (greyed). All are token references — never raw colors.
*/
const ICON_FILL: Record<
CheckboxType,
Record<"default" | "primary" | "onPrimary" | "disabled", string>
> = {
primary: {
default: "[&_path]:fill-[var(--components-checkbox-radio-color-primary-icon-default)]",
primary: "[&_path]:fill-[var(--components-checkbox-radio-color-primary-icon-primary)]",
onPrimary: "[&_path]:fill-[var(--components-checkbox-radio-color-primary-icon-on-primary)]",
disabled: "[&_path]:fill-[var(--components-checkbox-radio-color-primary-icon-disabled)]",
},
error: {
default: "[&_path]:fill-[var(--components-checkbox-radio-color-error-icon-default)]",
primary: "[&_path]:fill-[var(--components-checkbox-radio-color-error-icon-primary)]",
onPrimary: "[&_path]:fill-[var(--components-checkbox-radio-color-error-icon-on-primary)]",
disabled: "[&_path]:fill-[var(--components-checkbox-radio-color-error-icon-disabled)]",
},
warning: {
default: "[&_path]:fill-[var(--components-checkbox-radio-color-warning-icon-default)]",
primary: "[&_path]:fill-[var(--components-checkbox-radio-color-warning-icon-primary)]",
onPrimary: "[&_path]:fill-[var(--components-checkbox-radio-color-warning-icon-on-primary)]",
disabled: "[&_path]:fill-[var(--components-checkbox-radio-color-warning-icon-disabled)]",
},
success: {
default: "[&_path]:fill-[var(--components-checkbox-radio-color-success-icon-default)]",
primary: "[&_path]:fill-[var(--components-checkbox-radio-color-success-icon-primary)]",
onPrimary: "[&_path]:fill-[var(--components-checkbox-radio-color-success-icon-on-primary)]",
disabled: "[&_path]:fill-[var(--components-checkbox-radio-color-success-icon-disabled)]",
},
information: {
default: "[&_path]:fill-[var(--components-checkbox-radio-color-information-icon-default)]",
primary: "[&_path]:fill-[var(--components-checkbox-radio-color-information-icon-primary)]",
onPrimary: "[&_path]:fill-[var(--components-checkbox-radio-color-information-icon-on-primary)]",
disabled: "[&_path]:fill-[var(--components-checkbox-radio-color-information-icon-disabled)]",
},
};
/** Subtle background ring painted behind the box on focus. */
const RING: Record<CheckboxType, string> = {
primary: "bg-[var(--components-checkbox-radio-color-primary-background-subtle)]",
error: "bg-[var(--components-checkbox-radio-color-error-background-subtle)]",
warning: "bg-[var(--components-checkbox-radio-color-warning-background-subtle)]",
success: "bg-[var(--components-checkbox-radio-color-success-background-subtle)]",
information: "bg-[var(--components-checkbox-radio-color-information-background-subtle)]",
};
/**
* Solid box fill for the **disabled + unchecked** state. Figma (node 376:411)
* renders this as a filled grey rounded square, not a recolored outline — a
* recolored `CheckBoxOutline` reads as the Default box, so paint the fill
* directly. All five intents resolve to the same neutral disabled grey, but the
* intent-scoped token names are kept for consistency with `ICON_FILL`.
*/
const DISABLED_BOX_FILL: Record<CheckboxType, string> = {
primary: "bg-[var(--components-checkbox-radio-color-primary-icon-disabled)]",
error: "bg-[var(--components-checkbox-radio-color-error-icon-disabled)]",
warning: "bg-[var(--components-checkbox-radio-color-warning-icon-disabled)]",
success: "bg-[var(--components-checkbox-radio-color-success-icon-disabled)]",
information: "bg-[var(--components-checkbox-radio-color-information-icon-disabled)]",
};
const SELECTION_ICON = {
default: CheckBoxOutlineIcon,
selected: CheckBoxFillIcon,
indeterminate: IndeterminateCheckBoxFillIcon,
} as const;
/**
* Nexus Checkbox — a token-styled checkbox that pairs a Material-style box icon
* (`CheckBoxOutline` / `CheckBoxFill` / `IndeterminateCheckBoxFill`) with an
* optional label heading and helper text. Visual selection is driven by
* `selectionType`; color by `type`; the subtle ring by `state`.
*/
export const Checkbox = ({
selectionType = "default",
type = "primary",
size = "sm",
state = "default",
showLabelHeading = true,
showHelperContent = true,
label = "Checkbox Title",
helperText = "Helper text write here...",
onChange,
className = "",
}: CheckboxProps) => {
const disabled = state === "disabled";
// Figma node 376:411: only the Focus nodes carry `backgroundSubtle` — Hover
// nodes do not. Painting the ring on hover made Hover indistinguishable from
// Focus, so gate the ring on `focus` alone.
const showRing = state === "focus";
// Per-state icon color, verified against node 376:411's Primary column:
// • disabled → greyed `iconDisabled`
// • focus → colored `iconPrimary` for every selection (turns the empty
// Uncheck box's border the intent color; keeps checked filled)
// • uncheck default/hover → neutral `iconDefault` outline (no Hover node
// exists for Uncheck, so it mirrors Default)
// • checked/indeterminate hover → darker `iconOnPrimary`
// • checked/indeterminate default → colored `iconPrimary`
const fillKey =
disabled
? "disabled"
: state === "focus"
? "primary"
: selectionType === "default"
? "default"
: state === "hover"
? "onPrimary"
: "primary";
const Icon = SELECTION_ICON[selectionType];
// Disabled + unchecked is a filled grey square in Figma, which no outline icon
// can reproduce — render a plain filled box for that one case instead.
const disabledEmptyBox = disabled && selectionType === "default";
const boxSize = size === "sm" ? "size-16" : "size-20";
const iconPx = size === "sm" ? 16 : 20;
const ariaChecked: "true" | "false" | "mixed" =
selectionType === "indeterminate"
? "mixed"
: selectionType === "selected"
? "true"
: "false";
// Figma node 376:411 (M-Checkbox): the control hugs its content up to a
// 1600px cap; the text column keeps a per-size min width (SM 153 / MD 177)
// and caps at 1572px, with no truncation — long content wraps.
const textColumnWidth =
size === "sm"
? "min-w-[153px] max-w-[1572px]"
: "min-w-[177px] max-w-[1572px]";
return (
<label
className={`inline-flex max-w-[1600px] items-center gap-8 ${
disabled ? "cursor-not-allowed" : "cursor-pointer"
} ${className}`}
>
<span
role="checkbox"
aria-checked={ariaChecked}
aria-disabled={disabled || undefined}
aria-label={!showLabelHeading ? label : undefined}
tabIndex={disabled ? -1 : 0}
onClick={disabled ? undefined : () => onChange?.(selectionType !== "selected")}
className={`relative grid shrink-0 place-items-center rounded-4 ${boxSize} ${
showRing ? RING[type] : ""
}`}
>
{disabledEmptyBox ? (
<span
aria-hidden
className={`${boxSize} rounded-4 ${DISABLED_BOX_FILL[type]}`}
/>
) : (
<Icon
width={iconPx}
height={iconPx}
aria-hidden
className={ICON_FILL[type][fillKey]}
/>
)}
</span>
{(showLabelHeading || showHelperContent) && (
<span
className={`flex flex-col justify-center gap-2 break-words ${textColumnWidth}`}
>
{showLabelHeading && (
<span
className={`${
size === "sm" ? "typography-b2-medium-14" : "typography-b1-medium-16"
} text-[var(--components-checkbox-radio-color-text-Heading)]`}
>
{label}
</span>
)}
{showHelperContent && (
<span
className={`${
size === "sm" ? "typography-b2-regular-14" : "typography-b1-regular-16"
} text-[var(--components-checkbox-radio-color-text-Muted)]`}
>
{helperText}
</span>
)}
</span>
)}
</label>
);
};nexus/components/radio-button.tsx
import {
RadioButtonUncheckedOutlineIcon,
RadioButtonCheckedFillIcon,
} from "nexus/icons";
export type RadioButtonType =
| "primary"
| "error"
| "warning"
| "success"
| "information";
export type RadioButtonSelectionType = "default" | "selected";
export type RadioButtonSize = "sm" | "md";
export type RadioButtonState = "default" | "focus" | "hover" | "disabled";
export interface RadioButtonProps {
/** Visual selection state of the circle. No `indeterminate` on a radio. */
selectionType?: RadioButtonSelectionType;
/** Intent — drives the `--components-checkbox-radio-*` color tokens. */
type?: RadioButtonType;
/** Circle + icon dimension. `sm` = 16px, `md` = 20px (Figma default: `sm`). */
size?: RadioButtonSize;
/** Interaction state. Only `focus` paints the subtle background ring. */
state?: RadioButtonState;
/** Show the label title beside the circle. */
showLabelHeading?: boolean;
/** Show the helper text below the label (requires `helperText`). */
showHelperContent?: boolean;
/** Label title text (requires `showLabelHeading`). */
label?: string;
/** Supporting helper text (requires `showHelperContent`). */
helperText?: string;
/** Native radio group name — keeps a set mutually exclusive. */
name?: string;
/** Called when the control is selected (omit for a read-only sample). */
onChange?: () => void;
/** Layout-only classes (margin, positioning). */
className?: string;
}
/**
* Per-type icon fill classes, spelled out as literal strings so Tailwind emits
* every utility. Radio Button shares the `--components-checkbox-radio-*` token
* set with Checkbox; only the shape differs (circle vs. rounded square).
*/
const ICON_FILL: Record<
RadioButtonType,
Record<"default" | "primary" | "onPrimary" | "disabled", string>
> = {
primary: {
default: "[&_path]:fill-[var(--components-checkbox-radio-color-primary-icon-default)]",
primary: "[&_path]:fill-[var(--components-checkbox-radio-color-primary-icon-primary)]",
onPrimary: "[&_path]:fill-[var(--components-checkbox-radio-color-primary-icon-on-primary)]",
disabled: "[&_path]:fill-[var(--components-checkbox-radio-color-primary-icon-disabled)]",
},
error: {
default: "[&_path]:fill-[var(--components-checkbox-radio-color-error-icon-default)]",
primary: "[&_path]:fill-[var(--components-checkbox-radio-color-error-icon-primary)]",
onPrimary: "[&_path]:fill-[var(--components-checkbox-radio-color-error-icon-on-primary)]",
disabled: "[&_path]:fill-[var(--components-checkbox-radio-color-error-icon-disabled)]",
},
warning: {
default: "[&_path]:fill-[var(--components-checkbox-radio-color-warning-icon-default)]",
primary: "[&_path]:fill-[var(--components-checkbox-radio-color-warning-icon-primary)]",
onPrimary: "[&_path]:fill-[var(--components-checkbox-radio-color-warning-icon-on-primary)]",
disabled: "[&_path]:fill-[var(--components-checkbox-radio-color-warning-icon-disabled)]",
},
success: {
default: "[&_path]:fill-[var(--components-checkbox-radio-color-success-icon-default)]",
primary: "[&_path]:fill-[var(--components-checkbox-radio-color-success-icon-primary)]",
onPrimary: "[&_path]:fill-[var(--components-checkbox-radio-color-success-icon-on-primary)]",
disabled: "[&_path]:fill-[var(--components-checkbox-radio-color-success-icon-disabled)]",
},
information: {
default: "[&_path]:fill-[var(--components-checkbox-radio-color-information-icon-default)]",
primary: "[&_path]:fill-[var(--components-checkbox-radio-color-information-icon-primary)]",
onPrimary: "[&_path]:fill-[var(--components-checkbox-radio-color-information-icon-on-primary)]",
disabled: "[&_path]:fill-[var(--components-checkbox-radio-color-information-icon-disabled)]",
},
};
/** Subtle background ring painted behind the circle on hover / focus. */
const RING: Record<RadioButtonType, string> = {
primary: "bg-[var(--components-checkbox-radio-color-primary-background-subtle)]",
error: "bg-[var(--components-checkbox-radio-color-error-background-subtle)]",
warning: "bg-[var(--components-checkbox-radio-color-warning-background-subtle)]",
success: "bg-[var(--components-checkbox-radio-color-success-background-subtle)]",
information: "bg-[var(--components-checkbox-radio-color-information-background-subtle)]",
};
/**
* Nexus RadioButton — a token-styled radio that pairs a Material-style circle
* icon (`RadioButtonUncheckedOutline` / `RadioButtonCheckedFill`) with an
* optional label heading and helper text. Always render at least two in a group.
*/
export const RadioButton = ({
selectionType = "default",
type = "primary",
size = "sm",
state = "default",
showLabelHeading = true,
showHelperContent = true,
label = "Radio Button Title",
helperText = "Helper text write here...",
name,
onChange,
className = "",
}: RadioButtonProps) => {
const disabled = state === "disabled";
// Figma node 378:1776. The purple ring (`iconPrimary` + `backgroundSubtle`) is
// the "active" affordance, but which state owns it differs by selection:
// • Default (unchecked) selection has NO Focus node — its states are
// Default / Hover / Disabled, and the ring lives on HOVER (node 378:2317).
// • Selected (checked) selection DOES have a Focus node, and the ring lives on
// FOCUS (node 378:2302); its Hover uses the darker `iconOnPrimary`, no ring.
const showRing =
!disabled &&
(selectionType === "default"
? state === "hover" || state === "focus"
: state === "focus");
// Per-state icon color (shares Checkbox's token set):
// • disabled → greyed `iconDisabled`
// • unchecked (Default selection): default → light `iconDefault`;
// hover/focus → colored `iconPrimary` (paints the empty circle's ring)
// • checked (Selected selection): hover → darker `iconOnPrimary`;
// default/focus → `iconPrimary`
const fillKey =
disabled
? "disabled"
: selectionType === "default"
? state === "default"
? "default"
: "primary"
: state === "hover"
? "onPrimary"
: "primary";
const Icon =
selectionType === "selected"
? RadioButtonCheckedFillIcon
: RadioButtonUncheckedOutlineIcon;
const boxSize = size === "sm" ? "size-16" : "size-20";
const iconPx = size === "sm" ? 16 : 20;
// Figma node 378:1776 (M-Radio Button): the control hugs its content up to a
// 1600px cap; the text column keeps a per-size min width (SM 149 / MD 177)
// and caps at 1572px, with no truncation — long content wraps.
const textColumnWidth =
size === "sm"
? "min-w-[149px] max-w-[1572px]"
: "min-w-[177px] max-w-[1572px]";
return (
<label
className={`inline-flex max-w-[1600px] items-center gap-8 ${
disabled ? "cursor-not-allowed" : "cursor-pointer"
} ${className}`}
>
<span
role="radio"
aria-checked={selectionType === "selected"}
aria-disabled={disabled || undefined}
aria-label={!showLabelHeading ? label : undefined}
data-name={name}
tabIndex={disabled ? -1 : 0}
onClick={disabled ? undefined : () => onChange?.()}
className={`relative grid shrink-0 place-items-center rounded-full ${boxSize} ${
showRing ? RING[type] : ""
}`}
>
<Icon
width={iconPx}
height={iconPx}
aria-hidden
className={ICON_FILL[type][fillKey]}
/>
</span>
{(showLabelHeading || showHelperContent) && (
<span
className={`flex flex-col justify-center gap-2 break-words ${textColumnWidth}`}
>
{showLabelHeading && (
<span
className={`${
size === "sm" ? "typography-b2-medium-14" : "typography-b1-medium-16"
} text-[var(--components-checkbox-radio-color-text-Heading)]`}
>
{label}
</span>
)}
{showHelperContent && (
<span
className={`${
size === "sm" ? "typography-b2-regular-14" : "typography-b1-regular-16"
} text-[var(--components-checkbox-radio-color-text-Muted)]`}
>
{helperText}
</span>
)}
</span>
)}
</label>
);
};