diff --git a/.changeset/mosaic-radio.md b/.changeset/mosaic-radio.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/mosaic-radio.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/packages/mosaic/src/components/radio/index.ts b/packages/mosaic/src/components/radio/index.ts
new file mode 100644
index 00000000000..3a4366aa830
--- /dev/null
+++ b/packages/mosaic/src/components/radio/index.ts
@@ -0,0 +1,2 @@
+export { Radio } from './radio';
+export type { RadioGroupProps, RadioItemProps } from './radio';
diff --git a/packages/mosaic/src/components/radio/radio.markers.stylex.ts b/packages/mosaic/src/components/radio/radio.markers.stylex.ts
new file mode 100644
index 00000000000..aa10bfbee15
--- /dev/null
+++ b/packages/mosaic/src/components/radio/radio.markers.stylex.ts
@@ -0,0 +1,3 @@
+import * as stylex from '@stylexjs/stylex';
+
+export const radioInputMarker = stylex.defineMarker();
diff --git a/packages/mosaic/src/components/radio/radio.styles.ts b/packages/mosaic/src/components/radio/radio.styles.ts
new file mode 100644
index 00000000000..5d04a7ea2ce
--- /dev/null
+++ b/packages/mosaic/src/components/radio/radio.styles.ts
@@ -0,0 +1,87 @@
+import * as stylex from '@stylexjs/stylex';
+
+import { colorVars, durationVars, radiusVars, space } from '../../tokens.stylex';
+import { radioInputMarker } from './radio.markers.stylex';
+
+const hoverBorderColor = 'light-dark(#bebebe, #525252)';
+
+export const styles = stylex.create({
+ group: {
+ gap: space['3'],
+ display: 'flex',
+ flexDirection: 'column',
+ },
+ root: {
+ display: 'inline-flex',
+ flexShrink: 0,
+ position: 'relative',
+ verticalAlign: 'middle',
+ },
+ input: {
+ borderColor: {
+ default: colorVars['--cl-color-border'],
+ ':checked': colorVars['--cl-color-brand'],
+ '@media (hover: hover)': {
+ ':hover:not(:disabled):not(:checked)': hoverBorderColor,
+ },
+ },
+ borderRadius: radiusVars['--cl-radius-full'],
+ borderStyle: 'solid',
+ borderWidth: '1px',
+ appearance: 'none',
+ backgroundColor: {
+ default: colorVars['--cl-color-background'],
+ ':checked': colorVars['--cl-color-brand'],
+ },
+ cursor: { default: 'pointer', ':disabled': 'not-allowed' },
+ display: 'block',
+ flexShrink: 0,
+ opacity: { default: null, ':disabled': 0.5 },
+ position: 'relative',
+ transitionDuration: durationVars['--cl-duration-fast'],
+ transitionProperty: 'background-color, border-color',
+ transitionTimingFunction: 'linear',
+ },
+ hitTarget: {
+ '::before': {
+ inset: `calc(-1 * ${space['3']})`,
+ content: '""',
+ display: { default: 'none', '@media (pointer: coarse)': 'block' },
+ position: 'absolute',
+ },
+ },
+ fieldHitTarget: {
+ '::before': {
+ inset: `calc(-1 * (${space['2']} + 1px))`,
+ display: 'block',
+ },
+ },
+ indicator: {
+ borderRadius: radiusVars['--cl-radius-full'],
+ backgroundColor: colorVars['--cl-color-brand-foreground'],
+ display: {
+ default: 'none',
+ [stylex.when.siblingBefore(':checked', radioInputMarker)]: 'block',
+ },
+ insetBlockStart: '50%',
+ insetInlineStart: '50%',
+ pointerEvents: 'none',
+ position: 'absolute',
+ transform: 'translate(-50%, -50%)',
+ },
+});
+
+export const sizes = stylex.create({
+ sm: { height: space['3.5'], width: space['3.5'] },
+ md: { height: space['4'], width: space['4'] },
+});
+
+export const firstLine = stylex.create({
+ sm: { marginTop: `max(0px, calc((1lh - ${space['3.5']}) / 2))` },
+ md: { marginTop: `max(0px, calc((1lh - ${space['4']}) / 2))` },
+});
+
+export const indicatorSizes = stylex.create({
+ sm: { height: space['1.5'], width: space['1.5'] },
+ md: { height: space['1.5'], width: space['1.5'] },
+});
diff --git a/packages/mosaic/src/components/radio/radio.test.tsx b/packages/mosaic/src/components/radio/radio.test.tsx
new file mode 100644
index 00000000000..768ccba55f4
--- /dev/null
+++ b/packages/mosaic/src/components/radio/radio.test.tsx
@@ -0,0 +1,207 @@
+import * as stylex from '@stylexjs/stylex';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import React from 'react';
+import { describe, expect, it, vi } from 'vitest';
+
+import { Field } from '../field';
+import { Radio } from './radio';
+
+const atoms = stylex.create({
+ spaced: { marginTop: '8px' },
+});
+
+describe('Mosaic Radio', () => {
+ it('renders a native radio wrapped in the slot element', () => {
+ render();
+ const input = screen.getByRole('radio', { name: 'All organizations' });
+ expect(input).toHaveClass('cl-radio-input');
+ expect(input.parentElement).toHaveClass('cl-radio');
+ });
+
+ it('checks one radio at a time within a name', async () => {
+ const onChange = vi.fn();
+ render(
+ <>
+
+
+ >,
+ );
+
+ await userEvent.click(screen.getByRole('radio', { name: 'Selected' }));
+
+ expect(screen.getByRole('radio', { name: 'Selected' })).toBeChecked();
+ expect(screen.getByRole('radio', { name: 'All' })).not.toBeChecked();
+ expect(onChange).toHaveBeenCalledTimes(1);
+ });
+
+ it('reflects the size axis on the slot element', () => {
+ render(
+ ,
+ );
+ expect(screen.getByRole('radio').parentElement).toHaveAttribute('data-size', 'sm');
+ });
+
+ it('disables the input', () => {
+ render(
+ ,
+ );
+ expect(screen.getByRole('radio')).toBeDisabled();
+ });
+
+ it('merges xstyle atoms onto the slot element', () => {
+ render(
+ ,
+ );
+ expect(screen.getByRole('radio').parentElement).toHaveClass('cl-radio', stylex.props(atoms.spaced).className ?? '');
+ });
+
+ it('forwards the ref and arbitrary input props to the input', () => {
+ const ref = React.createRef();
+ render(
+ ,
+ );
+ const input = screen.getByRole('radio');
+ expect(ref.current).toBe(input);
+ expect(input).toHaveAttribute('name', 'access');
+ expect(input).toHaveAttribute('value', 'all');
+ });
+
+ it('takes its label, description and disabled state from a Field', async () => {
+ render(
+
+
+
+ All organizations
+ Applies to every organization you belong to.
+
+ ,
+ );
+
+ const radio = screen.getByRole('radio', { name: 'All organizations' });
+ expect(radio).toHaveAttribute(
+ 'aria-describedby',
+ screen.getByText('Applies to every organization you belong to.').id,
+ );
+ await userEvent.click(screen.getByText('All organizations'));
+ expect(radio).toBeChecked();
+ });
+
+ it('disables the radio inside a disabled Field', () => {
+ render(
+
+
+ All organizations
+ ,
+ );
+ expect(screen.getByRole('radio', { name: 'All organizations' })).toBeDisabled();
+ });
+
+ describe('Group', () => {
+ function Group(props: Partial>) {
+ return (
+
+
+
+
+ );
+ }
+
+ it('renders a labelled radiogroup', () => {
+ render();
+ expect(screen.getByRole('radiogroup', { name: 'Organization access' })).toHaveClass('cl-radio-group');
+ });
+
+ it('checks the item matching its value and reports the one the user picks', async () => {
+ const onValueChange = vi.fn();
+ render(
+ ,
+ );
+ expect(screen.getByRole('radio', { name: 'All' })).toBeChecked();
+
+ await userEvent.click(screen.getByRole('radio', { name: 'Selected' }));
+
+ expect(onValueChange).toHaveBeenCalledWith('selected');
+ expect(screen.getByRole('radio', { name: 'All' })).toBeChecked();
+ });
+
+ it('tracks the selection itself from a default value', async () => {
+ render();
+
+ await userEvent.click(screen.getByRole('radio', { name: 'Selected' }));
+
+ expect(screen.getByRole('radio', { name: 'Selected' })).toBeChecked();
+ expect(screen.getByRole('radio', { name: 'All' })).not.toBeChecked();
+ });
+
+ it('gives every item the same name', () => {
+ render();
+ const [all, selected] = screen.getAllByRole('radio');
+ expect(all.getAttribute('name')).toBeTruthy();
+ expect(selected).toHaveAttribute('name', all.getAttribute('name') ?? '');
+ });
+
+ it('posts the name it is given', () => {
+ render();
+ expect(screen.getByRole('radio', { name: 'All' })).toHaveAttribute('name', 'access');
+ });
+
+ it('disables every item', () => {
+ render();
+ for (const radio of screen.getAllByRole('radio')) {
+ expect(radio).toBeDisabled();
+ }
+ });
+
+ it('labels each item with its own Field', () => {
+ render(
+
+
+
+ All organizations
+
+ ,
+ );
+ expect(screen.getByRole('radio', { name: 'All organizations' })).toHaveAttribute('value', 'all');
+ });
+ });
+});
diff --git a/packages/mosaic/src/components/radio/radio.tsx b/packages/mosaic/src/components/radio/radio.tsx
new file mode 100644
index 00000000000..cc6af15d4e6
--- /dev/null
+++ b/packages/mosaic/src/components/radio/radio.tsx
@@ -0,0 +1,147 @@
+import * as stylex from '@stylexjs/stylex';
+import React from 'react';
+
+import { useControllableState } from '../../primitives/hooks';
+import type { MosaicStyleProps } from '../../props';
+import { mergeStyleProps, themeProps } from '../../props';
+import { focusOutline } from '../../styles/focus-outline.styles';
+import { reset } from '../../styles/reset.styles';
+import { useOptionalFieldContext, useOptionalFieldControlProps } from '../field/field.context';
+import { radioInputMarker } from './radio.markers.stylex';
+import { firstLine, indicatorSizes, sizes, styles } from './radio.styles';
+
+export interface RadioGroupProps
+ extends Omit, 'defaultValue' | 'className' | 'style'>, MosaicStyleProps {
+ value?: string;
+ defaultValue?: string;
+ onValueChange?: (value: string) => void;
+ name?: string;
+ disabled?: boolean;
+}
+
+interface RadioGroupContextValue {
+ name: string;
+ value: string | undefined;
+ setValue: (value: string) => void;
+ disabled: boolean | undefined;
+}
+
+const RadioGroupContext = React.createContext(null);
+
+const RadioGroup = React.forwardRef(function MosaicRadioGroup(
+ { value: valueProp, defaultValue, onValueChange, name: nameProp, disabled, xstyle, ...rest },
+ ref,
+) {
+ const generatedName = React.useId();
+ const [value, setValue] = useControllableState(valueProp, defaultValue, next => {
+ if (next !== undefined) {
+ onValueChange?.(next);
+ }
+ });
+ const context = React.useMemo(
+ () => ({ name: nameProp ?? generatedName, value, setValue, disabled }),
+ [nameProp, generatedName, value, setValue, disabled],
+ );
+
+ return (
+
+
+
+ );
+});
+
+export interface RadioItemProps
+ extends
+ Omit, 'type' | 'size' | 'value' | 'className' | 'style'>,
+ MosaicStyleProps {
+ size?: 'sm' | 'md';
+ value?: string;
+}
+
+const RadioItem = React.forwardRef(function MosaicRadioItem(
+ {
+ size = 'md',
+ value,
+ checked: checkedProp,
+ onChange,
+ name: nameProp,
+ disabled: disabledProp,
+ required: requiredProp,
+ id,
+ 'aria-labelledby': ariaLabelledBy,
+ 'aria-describedby': ariaDescribedBy,
+ xstyle,
+ ...rest
+ },
+ ref,
+) {
+ const horizontalField = useOptionalFieldContext()?.orientation === 'horizontal';
+ const fieldProps = useOptionalFieldControlProps({
+ id,
+ disabled: disabledProp,
+ required: requiredProp,
+ ariaLabelledBy,
+ ariaDescribedBy,
+ });
+ const group = React.useContext(RadioGroupContext);
+ const disabled = (fieldProps?.disabled ?? disabledProp) || group?.disabled;
+ const checked = group && value !== undefined ? group.value === value : checkedProp;
+
+ return (
+
+ {
+ onChange?.(event);
+ if (group && value !== undefined) {
+ group.setValue(value);
+ }
+ }}
+ disabled={disabled}
+ required={fieldProps?.required ?? requiredProp}
+ id={fieldProps?.id ?? id}
+ aria-labelledby={fieldProps?.['aria-labelledby'] ?? ariaLabelledBy}
+ aria-describedby={fieldProps?.['aria-describedby'] ?? ariaDescribedBy}
+ {...mergeStyleProps(
+ themeProps('radio-input'),
+ stylex.props(
+ reset.base,
+ focusOutline.visible,
+ styles.input,
+ styles.hitTarget,
+ horizontalField && styles.fieldHitTarget,
+ sizes[size],
+ radioInputMarker,
+ ),
+ rest,
+ )}
+ />
+
+
+ );
+});
+
+export const Radio = {
+ Group: RadioGroup,
+ Item: RadioItem,
+};
diff --git a/packages/mosaic/src/styles/index.ts b/packages/mosaic/src/styles/index.ts
index eb987e3917e..99619198819 100644
--- a/packages/mosaic/src/styles/index.ts
+++ b/packages/mosaic/src/styles/index.ts
@@ -91,6 +91,8 @@ export type {
} from '../components/menu';
export { scrollAreaRoot, scrollAreaVars, scrollAreaViewport } from '../components/scroll-area';
export type { ScrollAreaAxis, ScrollAreaGutter } from '../components/scroll-area';
+export { Radio } from '../components/radio';
+export type { RadioGroupProps, RadioItemProps } from '../components/radio';
export { Select } from '../components/select';
export type {
SelectItem,
diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx
index 67506cfd3ce..3ad5ae0e5a6 100644
--- a/packages/swingset/src/components/DocsViewer.tsx
+++ b/packages/swingset/src/components/DocsViewer.tsx
@@ -93,6 +93,7 @@ const docModules: Record> = {
panel: dynamic(() => import('../stories/panel.component.mdx')),
popover: dynamic(() => import('../stories/popover.component.mdx')),
profile: dynamic(() => import('../stories/profile.component.mdx')),
+ radio: dynamic(() => import('../stories/radio.mdx')),
section: dynamic(() => import('../stories/section.mdx')),
'segmented-control': dynamic(() => import('../stories/segmented-control.mdx')),
select: dynamic(() => import('../stories/select.component.mdx')),
diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts
index ce1fbadf49f..28d448fa5e2 100644
--- a/packages/swingset/src/lib/registry.ts
+++ b/packages/swingset/src/lib/registry.ts
@@ -225,6 +225,13 @@ import {
meta as profileComponentMeta,
Transitions as ProfileTransitions,
} from '../stories/profile.component.stories';
+import {
+ Default as RadioDefault,
+ Descriptions as RadioDescriptions,
+ meta as radioMeta,
+ Sizes as RadioSizes,
+ States as RadioStates,
+} from '../stories/radio.stories';
import {
AuthenticatorOTP as ReverificationAuthenticatorOTP,
BackupCode as ReverificationBackupCode,
@@ -620,6 +627,13 @@ const profileComponentModule: StoryModule = {
Customized: ProfileCustomized,
Transitions: ProfileTransitions,
};
+const radioModule: StoryModule = {
+ meta: radioMeta,
+ Default: RadioDefault,
+ Descriptions: RadioDescriptions,
+ States: RadioStates,
+ Sizes: RadioSizes,
+};
const dataListModule: StoryModule = {
meta: dataListMeta,
@@ -1056,6 +1070,7 @@ export const registry: StoryModule[] = [
panelModule,
popoverComponentModule,
profileComponentModule,
+ radioModule,
sectionModule,
segmentedControlModule,
selectComponentModule,
diff --git a/packages/swingset/src/stories/radio.mdx b/packages/swingset/src/stories/radio.mdx
new file mode 100644
index 00000000000..93ca08d6aa7
--- /dev/null
+++ b/packages/swingset/src/stories/radio.mdx
@@ -0,0 +1,68 @@
+import * as RadioStories from './radio.stories';
+
+# Radio
+
+`Radio.Group` holds one choice out of several, and each `Radio.Item` is a styled native ``. The group gives its items a shared `name` and checks the item whose `value` matches its own, controlled (`value` + `onValueChange`) or uncontrolled (`defaultValue`). Put each item in a [Field](/components/field) with `orientation='horizontal'` to give it a label and a description.
+
+## Example
+
+
+
+## Usage
+
+```tsx
+import { Field } from '@clerk/mosaic/components/field';
+import { Radio } from '@clerk/mosaic/components/radio';
+
+
+
+
+ Monthly
+
+
+
+ Yearly
+
+;
+```
+
+## Parts
+
+| Part | Slot | Description |
+| ------------- | ----------------------- | ---------------------------------------------------------------------------------------- |
+| `Radio.Group` | `radio-group` | The `radiogroup`. Owns `value`, `defaultValue`, `onValueChange`, `name` and `disabled`. |
+| `Radio.Item` | `radio` / `radio-input` | One choice. `data-size`, `data-disabled`. Forwards its ref and input props to the input. |
+| ↳ indicator | `radio-indicator` | The dot, shown while the item is checked. |
+
+---
+
+## Examples
+
+### Descriptions
+
+
+
+### States
+
+
+
+### Sizes
+
+
diff --git a/packages/swingset/src/stories/radio.stories.tsx b/packages/swingset/src/stories/radio.stories.tsx
new file mode 100644
index 00000000000..738b197a41b
--- /dev/null
+++ b/packages/swingset/src/stories/radio.stories.tsx
@@ -0,0 +1,93 @@
+import { Field } from '@clerk/mosaic/components/field';
+import { Radio } from '@clerk/mosaic/components/radio';
+
+import type { StoryMeta } from '@/lib/types';
+
+export { default as __source } from './radio.stories?raw';
+
+export const meta: StoryMeta = {
+ group: 'Components',
+ status: 'wip',
+ title: 'Radio',
+ source: 'packages/mosaic/src/components/radio/radio.tsx',
+};
+
+export function Default() {
+ return (
+
+
+
+ Monthly
+
+
+
+ Yearly
+
+
+ );
+}
+
+export function Descriptions() {
+ return (
+
+
+
+
+ All repositories
+ Applies to every current and future repository.
+
+
+
+
+
+ Only select repositories
+ Select at least one repository.
+
+
+
+ );
+}
+
+export function States() {
+ return (
+
+
+
+
+
+
+ );
+}
+
+export function Sizes() {
+ return (
+
+
+
+
+ );
+}