diff --git a/.changeset/control-icons.md b/.changeset/control-icons.md
new file mode 100644
index 000000000000..db56c8bf68b1
--- /dev/null
+++ b/.changeset/control-icons.md
@@ -0,0 +1,7 @@
+---
+'@astryxdesign/core': patch
+---
+
+[feature] Add themeable control icon renderers for stateful checkbox and radio visuals.
+
+@cixzhang
diff --git a/.changeset/form-control-icon-glyphs.md b/.changeset/form-control-icon-glyphs.md
new file mode 100644
index 000000000000..34f3898dcccf
--- /dev/null
+++ b/.changeset/form-control-icon-glyphs.md
@@ -0,0 +1,7 @@
+---
+'@astryxdesign/core': patch
+---
+
+[feature] Route checkbox and radio internals through themeable control icon renderers and demonstrate custom control icons.
+
+@cixzhang
diff --git a/apps/storybook/stories/CheckboxInput.stories.tsx b/apps/storybook/stories/CheckboxInput.stories.tsx
index 3763cded3b10..1650efcf7cac 100644
--- a/apps/storybook/stories/CheckboxInput.stories.tsx
+++ b/apps/storybook/stories/CheckboxInput.stories.tsx
@@ -3,6 +3,7 @@
import {useState} from 'react';
import type {Meta, StoryObj} from '@storybook/react';
import {CheckboxInput} from '@astryxdesign/core/CheckboxInput';
+import {Theme, defineTheme} from '@astryxdesign/core/theme';
import {
BellIcon,
EnvelopeIcon,
@@ -494,3 +495,38 @@ export const DisabledWithMessage: Story = {
disabledMessage: 'Terms are managed by your administrator',
},
};
+
+const formControlIconTheme = defineTheme({
+ name: 'form-control-icon-demo',
+ controlIcons: {
+ checkbox: ({state, size}) => (
+
+ {state === 'checked' ? '★' : state === 'indeterminate' ? '–' : ''}
+
+ ),
+ },
+});
+
+export const ThemedStateGlyphs: Story = {
+ render: () => (
+
+
+
+
+
+
+ ),
+};
diff --git a/packages/core/src/CheckboxInput/CheckboxInput.test.tsx b/packages/core/src/CheckboxInput/CheckboxInput.test.tsx
index 9f511cf6110d..9937fd0ba74f 100644
--- a/packages/core/src/CheckboxInput/CheckboxInput.test.tsx
+++ b/packages/core/src/CheckboxInput/CheckboxInput.test.tsx
@@ -582,13 +582,13 @@ describe('CheckboxInput', () => {
// compiled output includes the forced-colors rule; visual behavior needs
// manual verification under Windows High Contrast.
describe('forced colors (WCAG 1.4.11)', () => {
- it('compiles a forced-colors fill so the indeterminate mark survives Windows High Contrast', () => {
+ it('compiles a forced-colors color so the indeterminate mark survives Windows High Contrast', () => {
render(
{}} />,
);
- // The painted indeterminate bar would be stripped to Canvas (invisible);
+ // The indeterminate glyph would be stripped to Canvas (invisible);
// CanvasText keeps it perceivable.
- expect(getForcedColorsRules()).toContain('background-color: canvastext;');
+ expect(getForcedColorsRules()).toContain('color: canvastext;');
});
it('compiles a forced-colors color so the checkmark survives Windows High Contrast', () => {
diff --git a/packages/core/src/CheckboxInput/CheckboxInput.tsx b/packages/core/src/CheckboxInput/CheckboxInput.tsx
index 696c0f686245..f964f66096d1 100644
--- a/packages/core/src/CheckboxInput/CheckboxInput.tsx
+++ b/packages/core/src/CheckboxInput/CheckboxInput.tsx
@@ -30,19 +30,15 @@ import * as stylex from '@stylexjs/stylex';
import {
colorVars,
spacingVars,
- radiusVars,
- durationVars,
- easeVars,
typographyVars,
typeScaleVars,
fontWeightVars,
- borderVars,
} from '../theme/tokens.stylex';
import type {BaseProps} from '../BaseProps';
import type {SizeValue} from '../utils/types';
import {FieldLabel} from '../Field/FieldLabel';
import {FieldStatus} from '../FieldStatus/FieldStatus';
-import type {IconType} from '../Icon';
+import {useControlIcon, type IconType} from '../Icon';
import type {InputStatus} from '../Field/types';
import {Spinner} from '../Spinner';
import {useTooltip} from '../Tooltip';
@@ -83,15 +79,10 @@ const styles = stylex.create({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
- borderWidth: borderVars['--border-width'],
- borderStyle: 'solid',
- borderRadius: radiusVars['--radius-inner'],
- transitionProperty: 'background-color, border-color',
- transitionDuration: {
- default: durationVars['--duration-fast'],
- '@media (prefers-reduced-motion: reduce)': '0s',
+ color: {
+ default: colorVars['--color-on-accent'],
+ '@media (forced-colors: active)': 'CanvasText',
},
- transitionTimingFunction: easeVars['--ease-standard'],
},
checkboxFocus: {
outline: {
@@ -104,39 +95,16 @@ const styles = stylex.create({
[stylex.when.ancestor(':has(:focus-visible)', checkboxScope)]: '2px',
},
},
- // State-dependent colors with ancestor hover behavior
checkboxUnchecked: {
// Foreground for the inherit-shade loading spinner (reads currentColor):
// brand accent on the light surface fill.
color: colorVars['--color-accent'],
- borderColor: {
- default: colorVars['--color-border-emphasized'],
- [stylex.when.ancestor(':hover', checkboxScope)]: {
- '@media (hover: hover)': `color-mix(in srgb, ${colorVars['--color-border-emphasized']}, ${colorVars['--color-tint-hover']} 20%)`,
- },
- },
- backgroundColor: {
- default: colorVars['--color-background-surface'],
- [stylex.when.ancestor(':hover', checkboxScope)]: {
- '@media (hover: hover)': `color-mix(in srgb, ${colorVars['--color-background-surface']}, ${colorVars['--color-tint-hover']} 5%)`,
- },
- },
},
checkboxChecked: {
- // Foreground for the inherit-shade loading spinner (reads currentColor):
- // on-accent color against the accent fill.
- color: colorVars['--color-on-accent'],
- borderColor: {
- default: colorVars['--color-accent'],
- [stylex.when.ancestor(':hover', checkboxScope)]: {
- '@media (hover: hover)': `color-mix(in srgb, ${colorVars['--color-accent']}, ${colorVars['--color-tint-hover']} 15%)`,
- },
- },
- backgroundColor: {
- default: colorVars['--color-accent'],
- [stylex.when.ancestor(':hover', checkboxScope)]: {
- '@media (hover: hover)': `color-mix(in srgb, ${colorVars['--color-accent']}, ${colorVars['--color-tint-hover']} 15%)`,
- },
+ // Foreground for the inherit-shade loading spinner and registry glyphs.
+ color: {
+ default: colorVars['--color-on-accent'],
+ '@media (forced-colors: active)': 'CanvasText',
},
},
checkboxDisabled: {
@@ -148,44 +116,6 @@ const styles = stylex.create({
},
},
},
- checkboxDisabledUnchecked: {
- backgroundColor: {
- default: colorVars['--color-background-muted'],
- [stylex.when.ancestor(':hover', checkboxScope)]: {
- '@media (hover: hover)': colorVars['--color-background-muted'],
- },
- },
- },
- checkmark: {
- display: 'none',
- color: {
- default: colorVars['--color-on-accent'],
- // Forced colors (Windows High Contrast) does not reliably force an SVG
- // stroke painted with currentColor, so the check stays the same white as
- // the flattened (Canvas) box fill — a white check on a white box.
- // CanvasText keeps it perceivable on the Canvas box, matching the
- // indeterminate mark (WCAG 1.4.11).
- '@media (forced-colors: active)': 'CanvasText',
- },
- },
- checkmarkVisible: {
- display: 'block',
- },
- indeterminateMark: {
- display: 'none',
- backgroundColor: {
- default: colorVars['--color-on-accent'],
- // Forced colors (Windows High Contrast) strips painted backgrounds,
- // which would make the indeterminate bar invisible; CanvasText keeps it
- // perceivable on the Canvas box fill (WCAG 1.4.11). The checkmark carries
- // the matching CanvasText treatment on its own style.
- '@media (forced-colors: active)': 'CanvasText',
- },
- borderRadius: radiusVars['--radius-full'],
- },
- indeterminateMarkVisible: {
- display: 'block',
- },
labelWrapper: {
display: 'flex',
flexDirection: 'column',
@@ -222,28 +152,6 @@ const checkboxSizeStyles = stylex.create({
},
});
-const checkmarkSizeStyles = stylex.create({
- sm: {
- width: 12,
- height: 12,
- },
- md: {
- width: 14,
- height: 14,
- },
-});
-
-const indeterminateSizeStyles = stylex.create({
- sm: {
- width: 10,
- height: 2,
- },
- md: {
- width: 12,
- height: 2,
- },
-});
-
export type CheckboxInputSize = keyof typeof wrapperSizeStyles;
export interface CheckboxInputProps extends Omit {
@@ -443,6 +351,8 @@ export function CheckboxInput({
isEnabled: showsDisabledMessage,
});
+ const CheckboxControlIcon = useControlIcon('checkbox');
+
const isIndeterminate = optimisticValue === 'indeterminate';
const isChecked = optimisticValue === true;
const isCheckedOrIndeterminate = isChecked || isIndeterminate;
@@ -567,39 +477,22 @@ export function CheckboxInput({
? styles.checkboxChecked
: styles.checkboxUnchecked,
isDisabled && styles.checkboxDisabled,
- isDisabled &&
- !isCheckedOrIndeterminate &&
- styles.checkboxDisabledUnchecked,
),
)}>
{isBusy ? (
) : (
- <>
-
-
- >
+
)}
diff --git a/packages/core/src/DropdownMenu/DropdownMenu.doc.mjs b/packages/core/src/DropdownMenu/DropdownMenu.doc.mjs
index cd2456726008..0664dd0f7810 100644
--- a/packages/core/src/DropdownMenu/DropdownMenu.doc.mjs
+++ b/packages/core/src/DropdownMenu/DropdownMenu.doc.mjs
@@ -26,12 +26,12 @@ export const docs = {
{className: 'astryx-dropdown-menu'},
{className: 'astryx-dropdown-menu-item', visualProps: ['size']},
{
- className: 'astryx-dropdown-menu-radio',
+ className: 'astryx-dropdown-menu-checkbox',
visualProps: ['size'],
states: ['checked', 'disabled'],
},
{
- className: 'astryx-dropdown-menu-radio-dot',
+ className: 'astryx-dropdown-menu-radio',
visualProps: ['size'],
states: ['checked', 'disabled'],
},
diff --git a/packages/core/src/DropdownMenu/DropdownMenuCheckboxItem.tsx b/packages/core/src/DropdownMenu/DropdownMenuCheckboxItem.tsx
index fe24bbd57113..77a6808aa899 100644
--- a/packages/core/src/DropdownMenu/DropdownMenuCheckboxItem.tsx
+++ b/packages/core/src/DropdownMenu/DropdownMenuCheckboxItem.tsx
@@ -15,24 +15,17 @@
* Enter/Space activation come from the parent DropdownMenu's useListFocus +
* activation path, which matches menuitemcheckbox alongside plain menuitem rows.
*
- * The checkbox visual composes the real CheckboxInput primitive so its checkmark
- * matches CheckboxListItem and picks up the standard `checkbox` theming slots.
- * It is purely decorative: the composed control is wrapped in an element that is
- * both `aria-hidden` and `inert`, so it contributes nothing to the row's
- * accessible name, and its native and sr-only label stay out of the tab
- * order and the accessibility tree while pointer clicks fall through to the row
- * — the same shim MultiSelector uses. The row owns the checked state and
- * accessible name. The control size is derived from the menu's item size (a
- * `sm` menu gets the compact control; `md`/`lg` get the standard one) and it
- * swaps to the inline-end of the row on coarse-pointer (touch) devices via CSS
- * `order`, so it lands where selection toggles are conventionally placed on
- * mobile.
+ * The checkbox visual is decorative: the row owns the role, checked state, and
+ * accessible name. The glyph resolves through a component icon slot so themes can
+ * align menu selection marks with CheckboxInput or intentionally diverge. The
+ * control size is derived from the menu's item size and swaps to the inline-end
+ * of the row on coarse-pointer (touch) devices via CSS `order`, so it lands
+ * where selection toggles are conventionally placed on mobile.
*/
import {useCallback, type PointerEvent, type ReactNode} from 'react';
import * as stylex from '@stylexjs/stylex';
-import {renderIconSlot, type IconType} from '../Icon';
-import {CheckboxInput} from '../CheckboxInput/CheckboxInput';
+import {renderIconSlot, useControlIcon, type IconType} from '../Icon';
import {Item} from '../Item';
import {useDropdownMenuContext} from './DropdownMenuContext';
import {focusMenuItemOnHover} from './menuItemHover';
@@ -63,6 +56,8 @@ const styles = stylex.create({
// which owns the role and activation.
markerBox: {
display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
flexShrink: 0,
pointerEvents: 'none',
order: {
@@ -76,6 +71,11 @@ const styles = stylex.create({
},
});
+const markerSizeStyles = stylex.create({
+ sm: {width: 18, height: 18},
+ md: {width: 22, height: 22},
+});
+
export interface DropdownMenuCheckboxItemProps extends Omit<
BaseProps,
'onChange' | 'role' | 'aria-checked' | 'tabIndex'
@@ -157,12 +157,7 @@ export function DropdownMenuCheckboxItem({
const ctx = useDropdownMenuContext();
const menuSize = ctx?.menuSize ?? 'md';
const controlSize = menuSize === 'sm' ? 'sm' : 'md';
-
- // The composed checkbox is decorative and inert, so its label never reaches
- // the accessibility tree — the row's `label` prop provides the announced
- // name. CheckboxInput still requires a string label, so pass one through when
- // the row label is a plain string.
- const checkboxLabel = typeof label === 'string' ? label : '';
+ const CheckboxControlIcon = useControlIcon('checkbox');
const handleClick = useCallback(() => {
if (isDisabled) {
@@ -187,15 +182,22 @@ export function DropdownMenuCheckboxItem({
tabIndex={isDisabled ? undefined : -1}
onPointerMove={handlePointerMove}
marker={
-
-
+
-
+
}
startContent={
icon
diff --git a/packages/core/src/DropdownMenu/DropdownMenuRadioItem.tsx b/packages/core/src/DropdownMenu/DropdownMenuRadioItem.tsx
index ffd2ac2537a7..87f91417acba 100644
--- a/packages/core/src/DropdownMenu/DropdownMenuRadioItem.tsx
+++ b/packages/core/src/DropdownMenu/DropdownMenuRadioItem.tsx
@@ -23,17 +23,11 @@
import {useCallback, type PointerEvent, type ReactNode} from 'react';
import * as stylex from '@stylexjs/stylex';
-import {renderIconSlot, type IconType} from '../Icon';
+import {renderIconSlot, useControlIcon, type IconType} from '../Icon';
import {Item} from '../Item';
import {useDropdownMenuContext} from './DropdownMenuContext';
import {focusMenuItemOnHover} from './menuItemHover';
-import {
- colorVars,
- spacingVars,
- durationVars,
- easeVars,
- borderVars,
-} from '../theme/tokens.stylex';
+import {colorVars, spacingVars} from '../theme/tokens.stylex';
import {mergeProps, themeProps} from '../utils';
import type {BaseProps} from '../BaseProps';
import {useDropdownMenuRadioGroupContext} from './DropdownMenuContext';
@@ -61,16 +55,6 @@ const styles = stylex.create({
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
- boxSizing: 'border-box',
- borderWidth: borderVars['--border-width'],
- borderStyle: 'solid',
- borderRadius: '50%',
- transitionProperty: 'background-color, border-color',
- transitionDuration: {
- default: durationVars['--duration-fast'],
- '@media (prefers-reduced-motion: reduce)': '0s',
- },
- transitionTimingFunction: easeVars['--ease-standard'],
order: {
default: 0,
'@media (pointer: coarse)': 1,
@@ -80,18 +64,6 @@ const styles = stylex.create({
'@media (pointer: coarse)': 'auto',
},
},
- unchecked: {
- borderColor: colorVars['--color-border-emphasized'],
- backgroundColor: colorVars['--color-background-surface'],
- },
- checked: {
- borderColor: colorVars['--color-accent'],
- backgroundColor: colorVars['--color-accent'],
- },
- dot: {
- borderRadius: '50%',
- backgroundColor: colorVars['--color-on-accent'],
- },
});
const circleSizeStyles = stylex.create({
@@ -99,11 +71,6 @@ const circleSizeStyles = stylex.create({
md: {width: 22, height: 22},
});
-const dotSizeStyles = stylex.create({
- sm: {width: 6, height: 6},
- md: {width: 8, height: 8},
-});
-
export interface DropdownMenuRadioItemProps extends Omit<
BaseProps,
'role' | 'aria-checked' | 'tabIndex'
@@ -173,6 +140,7 @@ export function DropdownMenuRadioItem({
const menuSize = menuCtx?.menuSize ?? 'md';
const controlSize = menuSize === 'sm' ? 'sm' : 'md';
const isChecked = groupCtx.value === value;
+ const RadioControlIcon = useControlIcon('radio');
const handleClick = useCallback(() => {
if (isDisabled) {
@@ -205,24 +173,13 @@ export function DropdownMenuRadioItem({
checked: isChecked ? 'checked' : null,
disabled: isDisabled ? 'disabled' : null,
}),
- stylex.props(
- styles.circle,
- circleSizeStyles[controlSize],
- isChecked ? styles.checked : styles.unchecked,
- ),
+ stylex.props(styles.circle, circleSizeStyles[controlSize]),
)}>
- {isChecked && (
-
- )}
+
}
startContent={
diff --git a/packages/core/src/DropdownMenu/DropdownMenuSelectable.test.tsx b/packages/core/src/DropdownMenu/DropdownMenuSelectable.test.tsx
index 5f2df0473ad5..e6fc8d1764bb 100644
--- a/packages/core/src/DropdownMenu/DropdownMenuSelectable.test.tsx
+++ b/packages/core/src/DropdownMenu/DropdownMenuSelectable.test.tsx
@@ -78,7 +78,7 @@ describe('DropdownMenuCheckboxItem', () => {
expect(onChangeSpy).toHaveBeenCalledWith(true);
});
- it('keeps the composed checkbox decorative (row is the only announced control)', async () => {
+ it('keeps the checkbox glyph decorative (row is the only announced control)', async () => {
const user = userEvent.setup();
render(
@@ -92,21 +92,14 @@ describe('DropdownMenuCheckboxItem', () => {
screen.getAllByRole('menuitemcheckbox', {hidden: true}),
).toHaveLength(1);
- // The composed CheckboxInput is present in the DOM but sits inside an
- // `aria-hidden` + `inert` subtree: it contributes nothing to the row's
- // accessible name and its native is out of the tab order and the
- // accessibility tree, so it is not a second announced/focusable control.
- // (Browsers enforce inert; jsdom does not model its a11y removal, so this
- // asserts the aria-hidden/inert boundary directly rather than via role.)
const row = screen.getByRole('menuitemcheckbox', {
name: /Show archived/,
hidden: true,
});
- const input = row.querySelector('input[type="checkbox"]');
- expect(input).not.toBeNull();
- const marker = input?.closest('[inert]');
- expect(marker).not.toBeNull();
+ const marker = row.querySelector('.astryx-dropdown-menu-checkbox');
+ expect(marker).toBeInTheDocument();
expect(marker).toHaveAttribute('aria-hidden', 'true');
+ expect(row.querySelector('input[type="checkbox"]')).toBeNull();
});
it('does not toggle when disabled', async () => {
@@ -177,19 +170,20 @@ describe('DropdownMenuRadioGroup / RadioItem', () => {
name: 'Newest',
hidden: true,
});
- const dot = checked.querySelector('.astryx-dropdown-menu-radio-dot');
- expect(dot).toBeInTheDocument();
- // Mirrors the radio container's visual props/states for consistent theming.
- expect(dot).toHaveAttribute('data-size', 'md');
- expect(dot).toHaveAttribute('data-checked', 'checked');
- // The unchecked radio has no dot, so no dot slot either.
+ const checkedControl = checked.querySelector('.astryx-dropdown-menu-radio');
+ expect(checkedControl).toBeInTheDocument();
+ expect(checkedControl).toHaveAttribute('data-size', 'md');
+ expect(checkedControl).toHaveAttribute('data-checked', 'checked');
+
const unchecked = screen.getByRole('menuitemradio', {
name: 'Oldest',
hidden: true,
});
- expect(
- unchecked.querySelector('.astryx-dropdown-menu-radio-dot'),
- ).not.toBeInTheDocument();
+ const uncheckedControl = unchecked.querySelector(
+ '.astryx-dropdown-menu-radio',
+ );
+ expect(uncheckedControl).toBeInTheDocument();
+ expect(uncheckedControl).not.toHaveAttribute('data-checked');
});
it('calls onChange with the selected value', async () => {
diff --git a/packages/core/src/Icon/controlIconRegistry.ts b/packages/core/src/Icon/controlIconRegistry.ts
new file mode 100644
index 000000000000..684992194c62
--- /dev/null
+++ b/packages/core/src/Icon/controlIconRegistry.ts
@@ -0,0 +1,33 @@
+// Copyright (c) Meta Platforms, Inc. and affiliates.
+
+/**
+ * @file controlIconRegistry.ts
+ * @input Theme source and control icon name
+ * @output Exports getControlIcon/useControlIcon backing helpers
+ * @position Server-safe control icon resolver for stateful checkbox/radio visuals
+ */
+
+import type {DefinedTheme} from '../theme/defineTheme';
+import {getRegisteredTheme} from '../theme/themeRegistry';
+import {
+ defaultControlIcons,
+ type ControlIconName,
+ type ControlIconRenderer,
+} from './controlIcons';
+
+export type ControlIconRegistrySource =
+ DefinedTheme | string | null | undefined;
+
+function getTheme(source: ControlIconRegistrySource): DefinedTheme | null {
+ if (source == null) {
+ return null;
+ }
+ return typeof source === 'string' ? getRegisteredTheme(source) : source;
+}
+
+export function getControlIcon(
+ name: ControlIconName,
+ source?: ControlIconRegistrySource,
+): ControlIconRenderer {
+ return getTheme(source)?.controlIcons?.[name] ?? defaultControlIcons[name];
+}
diff --git a/packages/core/src/Icon/controlIcons.tsx b/packages/core/src/Icon/controlIcons.tsx
new file mode 100644
index 000000000000..e7a2eb0558ed
--- /dev/null
+++ b/packages/core/src/Icon/controlIcons.tsx
@@ -0,0 +1,205 @@
+// Copyright (c) Meta Platforms, Inc. and affiliates.
+
+/**
+ * @file controlIcons.tsx
+ * @input Control icon state props
+ * @output Default checkbox/radio control icon renderers and control icon types
+ * @position Stateful control-icon layer used for checkbox/radio visuals
+ */
+
+import type {ReactNode} from 'react';
+
+export interface ControlIconRenderArgs {
+ state: 'unchecked' | 'checked' | 'indeterminate';
+ size?: 'sm' | 'md' | (string & {});
+ isDisabled?: boolean;
+ isHovered?: boolean;
+ isPressed?: boolean;
+}
+
+export type ControlIconRenderer = (props: ControlIconRenderArgs) => ReactNode;
+
+export interface ControlIconMap {
+ checkbox: true;
+ radio: true;
+}
+
+export type ControlIconName = keyof ControlIconMap & string;
+export type ControlIconRegistry = Partial<
+ Record
+>;
+
+const controlSize = (size: ControlIconRenderArgs['size']): number =>
+ size === 'sm' ? 20 : 24;
+
+const boxStyle = {
+ boxSizing: 'border-box',
+ display: 'inline-flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ flexShrink: 0,
+} as const;
+
+function checkboxColor(
+ state: ControlIconRenderArgs['state'],
+ isDisabled?: boolean,
+) {
+ if (isDisabled && state === 'unchecked') {
+ return {
+ borderColor: 'var(--color-border)',
+ backgroundColor: 'var(--color-background-muted)',
+ color: 'var(--color-on-accent)',
+ opacity: 0.5,
+ };
+ }
+ if (isDisabled) {
+ return {
+ borderColor: 'var(--color-border)',
+ backgroundColor: 'var(--color-accent)',
+ color: 'var(--color-on-accent)',
+ opacity: 0.5,
+ };
+ }
+ if (state === 'unchecked') {
+ return {
+ borderColor: 'var(--color-border-emphasized)',
+ backgroundColor: 'var(--color-background-surface)',
+ color: 'var(--color-accent)',
+ opacity: 1,
+ };
+ }
+ return {
+ borderColor: 'var(--color-accent)',
+ backgroundColor: 'var(--color-accent)',
+ color: 'var(--color-on-accent)',
+ opacity: 1,
+ };
+}
+
+export function defaultCheckboxControlIcon({
+ state,
+ size = 'md',
+ isDisabled,
+}: ControlIconRenderArgs): ReactNode {
+ const px = controlSize(size);
+ const colors = checkboxColor(state, isDisabled);
+
+ return (
+
+ {state === 'checked' && (
+
+ )}
+ {state === 'indeterminate' && (
+
+ )}
+
+ );
+}
+
+function radioColor(
+ state: ControlIconRenderArgs['state'],
+ isDisabled?: boolean,
+) {
+ if (isDisabled && state === 'unchecked') {
+ return {
+ borderColor: 'var(--color-border)',
+ backgroundColor: 'var(--color-background-muted)',
+ color: 'var(--color-on-accent)',
+ opacity: 0.5,
+ };
+ }
+ if (isDisabled) {
+ return {
+ borderColor: 'var(--color-border)',
+ backgroundColor: 'var(--color-accent)',
+ color: 'var(--color-on-accent)',
+ opacity: 0.5,
+ };
+ }
+ if (state === 'unchecked') {
+ return {
+ borderColor: 'var(--color-border-emphasized)',
+ backgroundColor: 'var(--color-background-surface)',
+ color: 'var(--color-on-accent)',
+ opacity: 1,
+ };
+ }
+ return {
+ borderColor: 'var(--color-accent)',
+ backgroundColor: 'var(--color-accent)',
+ color: 'var(--color-on-accent)',
+ opacity: 1,
+ };
+}
+
+export function defaultRadioControlIcon({
+ state,
+ size = 'md',
+ isDisabled,
+}: ControlIconRenderArgs): ReactNode {
+ const px = controlSize(size);
+ const colors = radioColor(state, isDisabled);
+
+ return (
+
+ {state === 'checked' && (
+
+ )}
+
+ );
+}
+
+export const defaultControlIcons: Required = {
+ checkbox: defaultCheckboxControlIcon,
+ radio: defaultRadioControlIcon,
+};
diff --git a/packages/core/src/Icon/index.ts b/packages/core/src/Icon/index.ts
index d83b6e66a0b9..45e349a023dc 100644
--- a/packages/core/src/Icon/index.ts
+++ b/packages/core/src/Icon/index.ts
@@ -13,6 +13,7 @@
export {Icon, renderIconSlot} from './Icon';
export {useIcon} from './useIcon';
+export {useControlIcon} from './useControlIcon';
export type {IconProps, IconColor, IconSize, IconType} from './Icon';
// Global registry (RSC-compatible, no 'use client')
@@ -25,6 +26,7 @@ export {
getComponentIconName,
resetIcons,
} from './globalIconRegistry';
+export {getControlIcon} from './controlIconRegistry';
export type {
IconName,
ExtendedIconName,
@@ -34,3 +36,15 @@ export type {
ComponentIconSlotName,
ComponentIconMap,
} from './globalIconRegistry';
+
+export {
+ defaultCheckboxControlIcon,
+ defaultRadioControlIcon,
+} from './controlIcons';
+export type {
+ ControlIconMap,
+ ControlIconName,
+ ControlIconRenderArgs,
+ ControlIconRegistry,
+ ControlIconRenderer,
+} from './controlIcons';
diff --git a/packages/core/src/Icon/useControlIcon.test.tsx b/packages/core/src/Icon/useControlIcon.test.tsx
new file mode 100644
index 000000000000..49060203cd44
--- /dev/null
+++ b/packages/core/src/Icon/useControlIcon.test.tsx
@@ -0,0 +1,38 @@
+// Copyright (c) Meta Platforms, Inc. and affiliates.
+
+import {describe, expect, it} from 'vitest';
+import {renderHook} from '@testing-library/react';
+import type {PropsWithChildren, ReactNode} from 'react';
+import {Theme} from '../theme/Theme';
+import {defineTheme} from '../theme/defineTheme';
+import {useControlIcon} from './useControlIcon';
+
+function createThemeWrapper(theme: ReturnType) {
+ function ThemeWrapper({children}: PropsWithChildren): ReactNode {
+ return {children};
+ }
+ return ThemeWrapper;
+}
+
+describe('useControlIcon', () => {
+ it('returns the default control icon renderer without a theme override', () => {
+ const {result} = renderHook(() => useControlIcon('checkbox'));
+
+ expect(result.current({state: 'checked'})).toBeTruthy();
+ });
+
+ it('resolves a control icon renderer from the nearest theme', () => {
+ const themeRenderer = () => 'theme-checkbox';
+ const theme = defineTheme({
+ name: 'brand-control-icons',
+ controlIcons: {checkbox: themeRenderer},
+ });
+
+ const {result} = renderHook(() => useControlIcon('checkbox'), {
+ wrapper: createThemeWrapper(theme),
+ });
+
+ expect(result.current).toBe(themeRenderer);
+ expect(result.current({state: 'unchecked'})).toBe('theme-checkbox');
+ });
+});
diff --git a/packages/core/src/Icon/useControlIcon.ts b/packages/core/src/Icon/useControlIcon.ts
new file mode 100644
index 000000000000..a7d5b559d27a
--- /dev/null
+++ b/packages/core/src/Icon/useControlIcon.ts
@@ -0,0 +1,18 @@
+// Copyright (c) Meta Platforms, Inc. and affiliates.
+
+'use client';
+
+/**
+ * @file useControlIcon.ts
+ * @input Control icon name
+ * @output Exports useControlIcon hook for theme-aware control icon renderers
+ * @position Client hook for stateful checkbox/radio visuals
+ */
+
+import {useThemeName} from '../theme/useTheme';
+import {getControlIcon} from './controlIconRegistry';
+import type {ControlIconName, ControlIconRenderer} from './controlIcons';
+
+export function useControlIcon(name: ControlIconName): ControlIconRenderer {
+ return getControlIcon(name, useThemeName());
+}
diff --git a/packages/core/src/RadioList/RadioList.test.tsx b/packages/core/src/RadioList/RadioList.test.tsx
index 1f59da0b31a7..595811353215 100644
--- a/packages/core/src/RadioList/RadioList.test.tsx
+++ b/packages/core/src/RadioList/RadioList.test.tsx
@@ -653,14 +653,14 @@ describe('RadioList', () => {
// compiled output includes the forced-colors rule; visual behavior needs
// manual verification under Windows High Contrast.
describe('forced colors (WCAG 1.4.11)', () => {
- it('compiles a forced-colors fill so the selected dot survives Windows High Contrast', () => {
+ it('compiles a forced-colors color so the selected dot survives Windows High Contrast', () => {
render(
{}}>
,
);
- // The painted inner dot would be stripped to Canvas (invisible), making
+ // The radio glyph would be stripped to Canvas (invisible), making
// checked and unchecked radios identical; CanvasText keeps it perceivable.
- expect(getForcedColorsRules()).toContain('background-color: canvastext;');
+ expect(getForcedColorsRules()).toContain('color: canvastext;');
});
});
diff --git a/packages/core/src/RadioList/RadioListItem.tsx b/packages/core/src/RadioList/RadioListItem.tsx
index 35d97257bc63..b4043de79733 100644
--- a/packages/core/src/RadioList/RadioListItem.tsx
+++ b/packages/core/src/RadioList/RadioListItem.tsx
@@ -21,16 +21,11 @@
import React, {use, useId, type ReactNode} from 'react';
import * as stylex from '@stylexjs/stylex';
import type {BaseProps} from '../BaseProps';
-import {
- colorVars,
- spacingVars,
- durationVars,
- easeVars,
- borderVars,
-} from '../theme/tokens.stylex';
+import {colorVars, spacingVars} from '../theme/tokens.stylex';
import {RadioListContext} from './RadioList';
import {mergeProps} from '../utils';
import {radioScope} from './radio.markers.stylex';
+import {useControlIcon} from '../Icon';
import {Item} from '../Item';
import {themeProps} from '../utils/themeProps';
@@ -63,40 +58,9 @@ const styles = stylex.create({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
- borderWidth: borderVars['--border-width'],
- borderStyle: 'solid',
- borderRadius: '50%',
- transitionProperty: 'background-color, border-color',
- transitionDuration: durationVars['--duration-fast'],
- transitionTimingFunction: easeVars['--ease-standard'],
- boxSizing: 'border-box',
- },
- radioUnchecked: {
- borderColor: {
- default: colorVars['--color-border-emphasized'],
- [stylex.when.ancestor(':hover', radioScope)]: {
- '@media (hover: hover)': `color-mix(in srgb, ${colorVars['--color-border-emphasized']}, ${colorVars['--color-tint-hover']} 20%)`,
- },
- },
- backgroundColor: {
- default: colorVars['--color-background-surface'],
- [stylex.when.ancestor(':hover', radioScope)]: {
- '@media (hover: hover)': `color-mix(in srgb, ${colorVars['--color-background-surface']}, ${colorVars['--color-tint-hover']} 5%)`,
- },
- },
- },
- radioChecked: {
- borderColor: {
- default: colorVars['--color-accent'],
- [stylex.when.ancestor(':hover', radioScope)]: {
- '@media (hover: hover)': `color-mix(in srgb, ${colorVars['--color-accent']}, ${colorVars['--color-tint-hover']} 15%)`,
- },
- },
- backgroundColor: {
- default: colorVars['--color-accent'],
- [stylex.when.ancestor(':hover', radioScope)]: {
- '@media (hover: hover)': `color-mix(in srgb, ${colorVars['--color-accent']}, ${colorVars['--color-tint-hover']} 15%)`,
- },
+ color: {
+ default: colorVars['--color-on-accent'],
+ '@media (forced-colors: active)': 'CanvasText',
},
},
radioWrapperFocus: {
@@ -114,20 +78,6 @@ const styles = stylex.create({
opacity: 0.5,
borderColor: colorVars['--color-border'],
},
- radioDisabledUnchecked: {
- backgroundColor: colorVars['--color-background-muted'],
- },
- innerDot: {
- borderRadius: '50%',
- backgroundColor: {
- default: colorVars['--color-on-accent'],
- // Forced colors (Windows High Contrast) strips painted backgrounds,
- // which would make the selected dot invisible — checked and unchecked
- // radios would look identical. CanvasText keeps the dot perceivable on
- // the Canvas circle fill (WCAG 1.4.11).
- '@media (forced-colors: active)': 'CanvasText',
- },
- },
labelDisabled: {
color: colorVars['--color-text-disabled'],
cursor: 'not-allowed',
@@ -156,17 +106,6 @@ const radioSizeStyles = stylex.create({
},
});
-const dotSizeStyles = stylex.create({
- sm: {
- width: 8,
- height: 8,
- },
- md: {
- width: 10,
- height: 10,
- },
-});
-
const embeddedStyles = stylex.create({
root: {
paddingBlock: 0,
@@ -248,6 +187,7 @@ export function RadioListItem({
context.hasDisabledMessage && !isItemDisabled;
const isChecked = context.value === value;
const size = context.size;
+ const RadioControlIcon = useControlIcon('radio');
const radioCircle = (
- {isChecked && (
-
- )}
+
);
diff --git a/packages/core/src/theme/defineTheme.test.ts b/packages/core/src/theme/defineTheme.test.ts
index 4418c15dd0c0..190706c0bffc 100644
--- a/packages/core/src/theme/defineTheme.test.ts
+++ b/packages/core/src/theme/defineTheme.test.ts
@@ -1071,6 +1071,20 @@ describe('defineTheme extends', () => {
});
});
+ it('preserves and merges control icon renderers', () => {
+ const checkbox = () => 'checkbox';
+ const radio = () => 'radio';
+ const base = defineTheme({name: 'base', controlIcons: {checkbox}});
+ const child = defineTheme({
+ name: 'child',
+ extends: base,
+ controlIcons: {radio},
+ });
+
+ expect(child.controlIcons?.checkbox).toBe(checkbox);
+ expect(child.controlIcons?.radio).toBe(radio);
+ });
+
it('preserves component icon mappings', () => {
const theme = defineTheme({
name: 'icons',
diff --git a/packages/core/src/theme/defineTheme.ts b/packages/core/src/theme/defineTheme.ts
index ce106c8dd5f0..6633f4b920de 100644
--- a/packages/core/src/theme/defineTheme.ts
+++ b/packages/core/src/theme/defineTheme.ts
@@ -30,6 +30,7 @@
* ```
*/
+import type {ControlIconRegistry} from '../Icon/controlIcons';
import type {ComponentIconMap, IconRegistry} from '../Icon/globalIconRegistry';
import type {TypographyConfig, FontWeight} from './types';
import {
@@ -282,6 +283,8 @@ export interface DefineThemeInput {
components?: ComponentStyleMap;
/** Icon registry — maps semantic icon names to React nodes */
icons?: Partial;
+ /** Stateful control icon renderers for checkbox/radio visuals. */
+ controlIcons?: ControlIconRegistry;
/**
* Component icon slot mappings — maps component-specific purposes to global
* semantic icon names. Use `null` to intentionally render no icon.
@@ -336,6 +339,8 @@ export interface DefinedTheme {
components?: ComponentStyleMap;
/** Icon registry */
icons?: Partial;
+ /** Stateful control icon renderers for checkbox/radio visuals. */
+ controlIcons?: ControlIconRegistry;
/** Component icon slot mappings */
componentIcons?: ComponentIconMap;
/** Whether this theme has been pre-compiled by theme build CLI */
@@ -632,6 +637,11 @@ export function defineTheme(input: DefineThemeInput): DefinedTheme {
? {...base.icons, ...input.icons}
: (input.icons ?? base?.icons);
+ const controlIcons =
+ input.controlIcons && base?.controlIcons
+ ? {...base.controlIcons, ...input.controlIcons}
+ : (input.controlIcons ?? base?.controlIcons);
+
// 6. Merge component icon mappings — input mappings override base mappings.
// `null` is an intentional override meaning “render no icon”.
const componentIcons =
@@ -644,6 +654,7 @@ export function defineTheme(input: DefineThemeInput): DefinedTheme {
tokens,
components,
icons,
+ controlIcons,
componentIcons,
__inputTokens: input.tokens,
__onDark,
diff --git a/packages/core/src/theme/index.ts b/packages/core/src/theme/index.ts
index 8e060a077d3a..104bd889936e 100644
--- a/packages/core/src/theme/index.ts
+++ b/packages/core/src/theme/index.ts
@@ -139,6 +139,11 @@ export type {
ComponentIconMap,
ComponentIconSlotMap,
ComponentIconSlotName,
+ ControlIconMap,
+ ControlIconName,
+ ControlIconRenderArgs,
+ ControlIconRegistry,
+ ControlIconRenderer,
} from '../Icon';
export {
resolveThemeToken,