diff --git a/docs/data/base/components/radio-group/UnstyledRadioGroupIntroduction/system/index.js b/docs/data/base/components/radio-group/UnstyledRadioGroupIntroduction/system/index.js
new file mode 100644
index 0000000000..020f334beb
--- /dev/null
+++ b/docs/data/base/components/radio-group/UnstyledRadioGroupIntroduction/system/index.js
@@ -0,0 +1,76 @@
+import * as React from 'react';
+import * as RadioGroup from '@base_ui/react/RadioGroup';
+import * as Radio from '@base_ui/react/Radio';
+import { styled } from '@mui/system';
+
+export default function UnstyledRadioGroupIntroduction() {
+ return (
+
+
+
+ Light
+
+
+
+ Medium
+
+
+
+ Heavy
+
+
+ );
+}
+
+const grey = {
+ 100: '#E5EAF2',
+ 200: '#D8E0E9',
+ 300: '#CBD4E2',
+};
+
+const blue = {
+ 400: '#3399FF',
+ 600: '#0072E6',
+ 800: '#004C99',
+};
+
+const RadioItem = styled(Radio.Root)`
+ display: flex;
+ align-items: center;
+ padding: 8px 16px;
+ border-radius: 4px;
+ border: none;
+ background-color: ${grey[100]};
+ color: black;
+ outline: none;
+ font-size: 16px;
+ cursor: default;
+
+ &:hover {
+ background-color: ${grey[100]};
+ }
+
+ &:focus-visible {
+ outline: 2px solid ${blue[400]};
+ outline-offset: 2px;
+ }
+
+ &[data-radio='checked'] {
+ background-color: ${blue[600]};
+ color: white;
+ }
+`;
+
+const Indicator = styled(Radio.Indicator)`
+ border-radius: 50%;
+ width: 8px;
+ height: 8px;
+ margin-right: 8px;
+ outline: 1px solid black;
+
+ &[data-radio='checked'] {
+ background-color: white;
+ border: none;
+ outline: none;
+ }
+`;
diff --git a/docs/data/base/components/radio-group/UnstyledRadioGroupIntroduction/system/index.tsx b/docs/data/base/components/radio-group/UnstyledRadioGroupIntroduction/system/index.tsx
new file mode 100644
index 0000000000..020f334beb
--- /dev/null
+++ b/docs/data/base/components/radio-group/UnstyledRadioGroupIntroduction/system/index.tsx
@@ -0,0 +1,76 @@
+import * as React from 'react';
+import * as RadioGroup from '@base_ui/react/RadioGroup';
+import * as Radio from '@base_ui/react/Radio';
+import { styled } from '@mui/system';
+
+export default function UnstyledRadioGroupIntroduction() {
+ return (
+
+
+
+ Light
+
+
+
+ Medium
+
+
+
+ Heavy
+
+
+ );
+}
+
+const grey = {
+ 100: '#E5EAF2',
+ 200: '#D8E0E9',
+ 300: '#CBD4E2',
+};
+
+const blue = {
+ 400: '#3399FF',
+ 600: '#0072E6',
+ 800: '#004C99',
+};
+
+const RadioItem = styled(Radio.Root)`
+ display: flex;
+ align-items: center;
+ padding: 8px 16px;
+ border-radius: 4px;
+ border: none;
+ background-color: ${grey[100]};
+ color: black;
+ outline: none;
+ font-size: 16px;
+ cursor: default;
+
+ &:hover {
+ background-color: ${grey[100]};
+ }
+
+ &:focus-visible {
+ outline: 2px solid ${blue[400]};
+ outline-offset: 2px;
+ }
+
+ &[data-radio='checked'] {
+ background-color: ${blue[600]};
+ color: white;
+ }
+`;
+
+const Indicator = styled(Radio.Indicator)`
+ border-radius: 50%;
+ width: 8px;
+ height: 8px;
+ margin-right: 8px;
+ outline: 1px solid black;
+
+ &[data-radio='checked'] {
+ background-color: white;
+ border: none;
+ outline: none;
+ }
+`;
diff --git a/docs/data/base/components/radio-group/UnstyledRadioGroupIntroduction/system/index.tsx.preview b/docs/data/base/components/radio-group/UnstyledRadioGroupIntroduction/system/index.tsx.preview
new file mode 100644
index 0000000000..0547decebc
--- /dev/null
+++ b/docs/data/base/components/radio-group/UnstyledRadioGroupIntroduction/system/index.tsx.preview
@@ -0,0 +1,14 @@
+
+
+
+ Light
+
+
+
+ Medium
+
+
+
+ Heavy
+
+
\ No newline at end of file
diff --git a/docs/data/base/components/radio-group/radio-group.md b/docs/data/base/components/radio-group/radio-group.md
new file mode 100644
index 0000000000..097667ba8a
--- /dev/null
+++ b/docs/data/base/components/radio-group/radio-group.md
@@ -0,0 +1,135 @@
+---
+productId: base-ui
+title: React Radio Group component
+components: RadioGroupRoot, RadioRoot, RadioIndicator
+githubLabel: 'component: radio'
+waiAria: https://www.w3.org/WAI/ARIA/apg/patterns/radio/
+---
+
+# Radio Group
+
+
Radio Groups contain a set of checkable buttons where only one of the buttons can be checked at a time.
+
+{{"component": "@mui/docs/ComponentLinkHeader", "design": false}}
+
+{{"component": "modules/components/ComponentPageTabs.js"}}
+
+## Introduction
+
+{{"demo": "UnstyledRadioGroupIntroduction", "defaultCodeOpen": false, "bg": "gradient"}}
+
+## Installation
+
+Base UI components are all available as a single package.
+
+
+
+```bash npm
+npm install @base_ui/react
+```
+
+```bash yarn
+yarn add @base_ui/react
+```
+
+```bash pnpm
+pnpm add @base_ui/react
+```
+
+
+
+Once you have the package installed, import the components.
+
+```ts
+import * as RadioGroup from '@base_ui/react/RadioGroup';
+import * as Radio from '@base_ui/react/Radio';
+```
+
+## Anatomy
+
+Radio Group is composed of a `Root` and `Radio` components:
+
+- ` ` is a top-level element that wraps the other components.
+- ` ` renders an individual `` radio item.
+- ` ` renders a `` for providing a visual indicator. You can style this itself and/or place an icon inside.
+
+```jsx
+
+
+
+
+
+```
+
+## Identifying items
+
+The `value` prop is required on `Radio.Root` to identify it in the Radio Group:
+
+```jsx
+
+
+
+
+
+
+
+
+```
+
+## Default value
+
+The `defaultValue` prop determines the initial value of the component when uncontrolled, linked to the `value` prop on an individual Radio item:
+
+```jsx
+
+
+
+
+```
+
+## Controlled
+
+The `value` and `onValueChange` props contain the `value` string of the currently selected Radio item in the Radio Group:
+
+```jsx
+const [value, setValue] = React.useState('a');
+
+return (
+
+
+
+
+);
+```
+
+## Styling
+
+The `Radio` components have a `[data-radio]` attribute with values `"checked"` or `"unchecked"` to style based on the checked state:
+
+```jsx
+
+
+
+```
+
+```css
+.Radio {
+ border: 1px solid black;
+}
+
+.RadioIndicator {
+ width: 20px;
+ height: 20px;
+ border-radius: 50%;
+ border: 1px solid black;
+}
+
+.Radio[data-radio='checked'] {
+ background: black;
+ color: white;
+}
+
+.RadioIndicator[data-radio='checked'] {
+ background: white;
+}
+```
diff --git a/docs/data/base/pages.ts b/docs/data/base/pages.ts
index 3bd7165246..6c5205f657 100644
--- a/docs/data/base/pages.ts
+++ b/docs/data/base/pages.ts
@@ -25,7 +25,7 @@ const pages: readonly MuiPage[] = [
{ pathname: '/base-ui/react-checkbox', title: 'Checkbox' },
{ pathname: '/base-ui/react-checkbox-group', title: 'Checkbox Group' },
{ pathname: '/base-ui/react-number-field', title: 'Number Field' },
- // { pathname: '/base-ui/react-radio-group', title: 'Radio Group', planned: true },
+ { pathname: '/base-ui/react-radio-group', title: 'Radio Group' },
// { pathname: '/base-ui/react-select', title: 'Select' },
{ pathname: '/base-ui/react-slider', title: 'Slider' },
{ pathname: '/base-ui/react-switch', title: 'Switch' },
diff --git a/docs/data/base/pagesApi.js b/docs/data/base/pagesApi.js
index a5762d8dca..ed858e14e2 100644
--- a/docs/data/base/pagesApi.js
+++ b/docs/data/base/pagesApi.js
@@ -245,6 +245,18 @@ module.exports = [
pathname: '/base-ui/react-progress/components-api/#progress-track',
title: 'ProgressTrack',
},
+ {
+ pathname: '/base-ui/react-radio-group/components-api/#radio-group-root',
+ title: 'RadioGroupRoot',
+ },
+ {
+ pathname: '/base-ui/react-radio-group/components-api/#radio-indicator',
+ title: 'RadioIndicator',
+ },
+ {
+ pathname: '/base-ui/react-radio-group/components-api/#radio-root',
+ title: 'RadioRoot',
+ },
{ pathname: '/base-ui/react-select/components-api/#select', title: 'Select' },
{
pathname: '/base-ui/react-slider/components-api/#slider-control',
diff --git a/docs/pages/base-ui/api/radio-group-root.json b/docs/pages/base-ui/api/radio-group-root.json
new file mode 100644
index 0000000000..c6fa6bb283
--- /dev/null
+++ b/docs/pages/base-ui/api/radio-group-root.json
@@ -0,0 +1,26 @@
+{
+ "props": {
+ "className": { "type": { "name": "union", "description": "func | string" } },
+ "defaultValue": { "type": { "name": "any" } },
+ "disabled": { "type": { "name": "bool" }, "default": "false" },
+ "name": { "type": { "name": "string" } },
+ "onValueChange": { "type": { "name": "func" } },
+ "readOnly": { "type": { "name": "bool" }, "default": "false" },
+ "render": { "type": { "name": "union", "description": "element | func" } },
+ "required": { "type": { "name": "bool" }, "default": "false" },
+ "value": { "type": { "name": "any" } }
+ },
+ "name": "RadioGroupRoot",
+ "imports": [
+ "import * as RadioGroup from '@base_ui/react/RadioGroup';\nconst RadioGroupRoot = RadioGroup.Root;"
+ ],
+ "classes": [],
+ "spread": true,
+ "themeDefaultProps": true,
+ "muiName": "RadioGroupRoot",
+ "forwardsRefTo": "HTMLDivElement",
+ "filename": "/packages/mui-base/src/RadioGroup/Root/RadioGroupRoot.tsx",
+ "inheritance": null,
+ "demos": "",
+ "cssComponent": false
+}
diff --git a/docs/pages/base-ui/api/radio-indicator.json b/docs/pages/base-ui/api/radio-indicator.json
new file mode 100644
index 0000000000..64d48ae20c
--- /dev/null
+++ b/docs/pages/base-ui/api/radio-indicator.json
@@ -0,0 +1,20 @@
+{
+ "props": {
+ "className": { "type": { "name": "union", "description": "func | string" } },
+ "keepMounted": { "type": { "name": "bool" }, "default": "true" },
+ "render": { "type": { "name": "union", "description": "element | func" } }
+ },
+ "name": "RadioIndicator",
+ "imports": [
+ "import * as Radio from '@base_ui/react/Radio';\nconst RadioIndicator = Radio.Indicator;"
+ ],
+ "classes": [],
+ "spread": true,
+ "themeDefaultProps": true,
+ "muiName": "RadioIndicator",
+ "forwardsRefTo": "HTMLSpanElement",
+ "filename": "/packages/mui-base/src/Radio/Indicator/RadioIndicator.tsx",
+ "inheritance": null,
+ "demos": "",
+ "cssComponent": false
+}
diff --git a/docs/pages/base-ui/api/radio-root.json b/docs/pages/base-ui/api/radio-root.json
new file mode 100644
index 0000000000..5e1ad484c2
--- /dev/null
+++ b/docs/pages/base-ui/api/radio-root.json
@@ -0,0 +1,21 @@
+{
+ "props": {
+ "value": { "type": { "name": "any" }, "required": true },
+ "className": { "type": { "name": "union", "description": "func | string" } },
+ "disabled": { "type": { "name": "bool" }, "default": "false" },
+ "readOnly": { "type": { "name": "bool" }, "default": "false" },
+ "render": { "type": { "name": "union", "description": "element | func" } },
+ "required": { "type": { "name": "bool" }, "default": "false" }
+ },
+ "name": "RadioRoot",
+ "imports": ["import * as Radio from '@base_ui/react/Radio';\nconst RadioRoot = Radio.Root;"],
+ "classes": [],
+ "spread": true,
+ "themeDefaultProps": true,
+ "muiName": "RadioRoot",
+ "forwardsRefTo": "HTMLButtonElement",
+ "filename": "/packages/mui-base/src/Radio/Root/RadioRoot.tsx",
+ "inheritance": null,
+ "demos": "",
+ "cssComponent": false
+}
diff --git a/docs/pages/base-ui/api/use-composite-item.json b/docs/pages/base-ui/api/use-composite-item.json
new file mode 100644
index 0000000000..240b1d950c
--- /dev/null
+++ b/docs/pages/base-ui/api/use-composite-item.json
@@ -0,0 +1,8 @@
+{
+ "parameters": {},
+ "returnValue": {},
+ "name": "useCompositeItem",
+ "filename": "/packages/mui-base/src/Composite/Item/useCompositeItem.ts",
+ "imports": ["import { useCompositeItem } from '@base_ui/react/Composite';"],
+ "demos": ""
+}
diff --git a/docs/pages/base-ui/api/use-composite-list-item.json b/docs/pages/base-ui/api/use-composite-list-item.json
new file mode 100644
index 0000000000..497920ca9d
--- /dev/null
+++ b/docs/pages/base-ui/api/use-composite-list-item.json
@@ -0,0 +1,8 @@
+{
+ "parameters": {},
+ "returnValue": {},
+ "name": "useCompositeListItem",
+ "filename": "/packages/mui-base/src/Composite/List/useCompositeListItem.ts",
+ "imports": ["import { useCompositeListItem } from '@base_ui/react/Composite';"],
+ "demos": ""
+}
diff --git a/docs/pages/base-ui/api/use-radio-group-root.json b/docs/pages/base-ui/api/use-radio-group-root.json
new file mode 100644
index 0000000000..30c3f00e5a
--- /dev/null
+++ b/docs/pages/base-ui/api/use-radio-group-root.json
@@ -0,0 +1,8 @@
+{
+ "parameters": {},
+ "returnValue": {},
+ "name": "useRadioGroupRoot",
+ "filename": "/packages/mui-base/src/RadioGroup/Root/useRadioGroupRoot.ts",
+ "imports": ["import { useRadioGroupRoot } from '@base_ui/react/RadioGroup';"],
+ "demos": ""
+}
diff --git a/docs/pages/base-ui/api/use-radio-root.json b/docs/pages/base-ui/api/use-radio-root.json
new file mode 100644
index 0000000000..7aa4f419d2
--- /dev/null
+++ b/docs/pages/base-ui/api/use-radio-root.json
@@ -0,0 +1,8 @@
+{
+ "parameters": {},
+ "returnValue": {},
+ "name": "useRadioRoot",
+ "filename": "/packages/mui-base/src/Radio/Root/useRadioRoot.tsx",
+ "imports": ["import { useRadioRoot } from '@base_ui/react/Radio';"],
+ "demos": ""
+}
diff --git a/docs/pages/base-ui/react-radio-group/[docsTab]/index.js b/docs/pages/base-ui/react-radio-group/[docsTab]/index.js
new file mode 100644
index 0000000000..f36a36d7b5
--- /dev/null
+++ b/docs/pages/base-ui/react-radio-group/[docsTab]/index.js
@@ -0,0 +1,64 @@
+import * as React from 'react';
+import MarkdownDocs from 'docs/src/modules/components/MarkdownDocsV2';
+import AppFrame from 'docs/src/modules/components/AppFrame';
+import * as pageProps from 'docs-base/data/base/components/radio-group/radio-group.md?@mui/markdown';
+import mapApiPageTranslations from 'docs/src/modules/utils/mapApiPageTranslations';
+import RadioGroupRootApiJsonPageContent from '../../api/radio-group-root.json';
+import RadioIndicatorApiJsonPageContent from '../../api/radio-indicator.json';
+import RadioRootApiJsonPageContent from '../../api/radio-root.json';
+
+export default function Page(props) {
+ const { userLanguage, ...other } = props;
+ return ;
+}
+
+Page.getLayout = (page) => {
+ return {page} ;
+};
+
+export const getStaticPaths = () => {
+ return {
+ paths: [{ params: { docsTab: 'components-api' } }, { params: { docsTab: 'hooks-api' } }],
+ fallback: false, // can also be true or 'blocking'
+ };
+};
+
+export const getStaticProps = () => {
+ const RadioGroupRootApiReq = require.context(
+ 'docs-base/translations/api-docs/radio-group-root',
+ false,
+ /\.\/radio-group-root.*.json$/,
+ );
+ const RadioGroupRootApiDescriptions = mapApiPageTranslations(RadioGroupRootApiReq);
+
+ const RadioIndicatorApiReq = require.context(
+ 'docs-base/translations/api-docs/radio-indicator',
+ false,
+ /\.\/radio-indicator.*.json$/,
+ );
+ const RadioIndicatorApiDescriptions = mapApiPageTranslations(RadioIndicatorApiReq);
+
+ const RadioRootApiReq = require.context(
+ 'docs-base/translations/api-docs/radio-root',
+ false,
+ /\.\/radio-root.*.json$/,
+ );
+ const RadioRootApiDescriptions = mapApiPageTranslations(RadioRootApiReq);
+
+ return {
+ props: {
+ componentsApiDescriptions: {
+ RadioGroupRoot: RadioGroupRootApiDescriptions,
+ RadioIndicator: RadioIndicatorApiDescriptions,
+ RadioRoot: RadioRootApiDescriptions,
+ },
+ componentsApiPageContents: {
+ RadioGroupRoot: RadioGroupRootApiJsonPageContent,
+ RadioIndicator: RadioIndicatorApiJsonPageContent,
+ RadioRoot: RadioRootApiJsonPageContent,
+ },
+ hooksApiDescriptions: {},
+ hooksApiPageContents: {},
+ },
+ };
+};
diff --git a/docs/pages/base-ui/react-radio-group/index.js b/docs/pages/base-ui/react-radio-group/index.js
new file mode 100644
index 0000000000..2a4a766bb0
--- /dev/null
+++ b/docs/pages/base-ui/react-radio-group/index.js
@@ -0,0 +1,13 @@
+import * as React from 'react';
+import MarkdownDocs from 'docs/src/modules/components/MarkdownDocsV2';
+import AppFrame from 'docs/src/modules/components/AppFrame';
+import * as pageProps from 'docs-base/data/base/components/radio-group/radio-group.md?@mui/markdown';
+
+export default function Page(props) {
+ const { userLanguage, ...other } = props;
+ return ;
+}
+
+Page.getLayout = (page) => {
+ return {page} ;
+};
diff --git a/docs/translations/api-docs/radio-group-root/radio-group-root.json b/docs/translations/api-docs/radio-group-root/radio-group-root.json
new file mode 100644
index 0000000000..bbf977c55b
--- /dev/null
+++ b/docs/translations/api-docs/radio-group-root/radio-group-root.json
@@ -0,0 +1,19 @@
+{
+ "componentDescription": "",
+ "propDescriptions": {
+ "className": {
+ "description": "Class names applied to the element or a function that returns them based on the component's state."
+ },
+ "defaultValue": {
+ "description": "The default value of the selected radio button. Use when uncontrolled."
+ },
+ "disabled": { "description": "Determines if the radio group is disabled." },
+ "name": { "description": "The name of the radio group submitted with the form data." },
+ "onValueChange": { "description": "Callback fired when the value changes." },
+ "readOnly": { "description": "Determines if the radio group is readonly." },
+ "render": { "description": "A function to customize rendering of the component." },
+ "required": { "description": "Determines if the radio group is required." },
+ "value": { "description": "The value of the selected radio button. Use when controlled." }
+ },
+ "classDescriptions": {}
+}
diff --git a/docs/translations/api-docs/radio-indicator/radio-indicator.json b/docs/translations/api-docs/radio-indicator/radio-indicator.json
new file mode 100644
index 0000000000..35cfbe6061
--- /dev/null
+++ b/docs/translations/api-docs/radio-indicator/radio-indicator.json
@@ -0,0 +1,13 @@
+{
+ "componentDescription": "",
+ "propDescriptions": {
+ "className": {
+ "description": "Class names applied to the element or a function that returns them based on the component's state."
+ },
+ "keepMounted": {
+ "description": "Whether the component should be kept mounted when not checked."
+ },
+ "render": { "description": "A function to customize rendering of the component." }
+ },
+ "classDescriptions": {}
+}
diff --git a/docs/translations/api-docs/radio-root/radio-root.json b/docs/translations/api-docs/radio-root/radio-root.json
new file mode 100644
index 0000000000..04b7711e73
--- /dev/null
+++ b/docs/translations/api-docs/radio-root/radio-root.json
@@ -0,0 +1,14 @@
+{
+ "componentDescription": "",
+ "propDescriptions": {
+ "className": {
+ "description": "Class names applied to the element or a function that returns them based on the component's state."
+ },
+ "disabled": { "description": "Determines if the radio is disabled." },
+ "readOnly": { "description": "Determines if the radio is readonly." },
+ "render": { "description": "A function to customize rendering of the component." },
+ "required": { "description": "Determines if the radio is required." },
+ "value": { "description": "The unique identifying value of the radio in a group." }
+ },
+ "classDescriptions": {}
+}
diff --git a/docs/translations/api-docs/use-composite-item/use-composite-item.json b/docs/translations/api-docs/use-composite-item/use-composite-item.json
new file mode 100644
index 0000000000..e3eb65c6e4
--- /dev/null
+++ b/docs/translations/api-docs/use-composite-item/use-composite-item.json
@@ -0,0 +1 @@
+{ "hookDescription": "", "parametersDescriptions": {}, "returnValueDescriptions": {} }
diff --git a/docs/translations/api-docs/use-composite-list-item/use-composite-list-item.json b/docs/translations/api-docs/use-composite-list-item/use-composite-list-item.json
new file mode 100644
index 0000000000..6b04319799
--- /dev/null
+++ b/docs/translations/api-docs/use-composite-list-item/use-composite-list-item.json
@@ -0,0 +1,5 @@
+{
+ "hookDescription": "Used to register a list item and its index (DOM position) in the\n`CompositeList`.",
+ "parametersDescriptions": {},
+ "returnValueDescriptions": {}
+}
diff --git a/docs/translations/api-docs/use-radio-group-root/use-radio-group-root.json b/docs/translations/api-docs/use-radio-group-root/use-radio-group-root.json
new file mode 100644
index 0000000000..e3eb65c6e4
--- /dev/null
+++ b/docs/translations/api-docs/use-radio-group-root/use-radio-group-root.json
@@ -0,0 +1 @@
+{ "hookDescription": "", "parametersDescriptions": {}, "returnValueDescriptions": {} }
diff --git a/docs/translations/api-docs/use-radio-root/use-radio-root.json b/docs/translations/api-docs/use-radio-root/use-radio-root.json
new file mode 100644
index 0000000000..e3eb65c6e4
--- /dev/null
+++ b/docs/translations/api-docs/use-radio-root/use-radio-root.json
@@ -0,0 +1 @@
+{ "hookDescription": "", "parametersDescriptions": {}, "returnValueDescriptions": {} }
diff --git a/docs/translations/translations.json b/docs/translations/translations.json
index a2ad3378ad..c39225a852 100644
--- a/docs/translations/translations.json
+++ b/docs/translations/translations.json
@@ -223,6 +223,7 @@
"/base-ui/react-checkbox": "Checkbox",
"/base-ui/react-checkbox-group": "Checkbox Group",
"/base-ui/react-number-field": "Number Field",
+ "/base-ui/react-radio-group": "Radio Group",
"/base-ui/react-slider": "Slider",
"/base-ui/react-switch": "Switch",
"data-display": "Data display",
diff --git a/packages/mui-base/src/Composite/Item/CompositeItem.tsx b/packages/mui-base/src/Composite/Item/CompositeItem.tsx
new file mode 100644
index 0000000000..8b87646048
--- /dev/null
+++ b/packages/mui-base/src/Composite/Item/CompositeItem.tsx
@@ -0,0 +1,70 @@
+'use client';
+import * as React from 'react';
+import PropTypes from 'prop-types';
+import { useComponentRenderer } from '../../utils/useComponentRenderer';
+import { useForkRef } from '../../utils/useForkRef';
+import { useCompositeRootContext } from '../Root/CompositeRootContext';
+import { useCompositeItem } from './useCompositeItem';
+import type { BaseUIComponentProps } from '../../utils/types';
+
+/**
+ * @ignore - internal component.
+ */
+const CompositeItem = React.forwardRef(function CompositeItem(
+ props: CompositeItem.Props,
+ forwardedRef: React.ForwardedRef,
+) {
+ const { render, className, ...otherProps } = props;
+
+ const { activeIndex } = useCompositeRootContext();
+ const { getItemProps, ref, index } = useCompositeItem();
+
+ const ownerState: CompositeItem.OwnerState = React.useMemo(
+ () => ({
+ active: index === activeIndex,
+ }),
+ [index, activeIndex],
+ );
+
+ const mergedRef = useForkRef(forwardedRef, ref);
+
+ const { renderElement } = useComponentRenderer({
+ propGetter: getItemProps,
+ ref: mergedRef,
+ render: render ?? 'div',
+ ownerState,
+ className,
+ extraProps: otherProps,
+ });
+
+ return renderElement();
+});
+
+namespace CompositeItem {
+ export interface OwnerState {
+ active: boolean;
+ }
+
+ export interface Props extends BaseUIComponentProps<'div', OwnerState> {}
+}
+
+CompositeItem.propTypes /* remove-proptypes */ = {
+ // ┌────────────────────────────── Warning ──────────────────────────────┐
+ // │ These PropTypes are generated from the TypeScript type definitions. │
+ // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
+ // └─────────────────────────────────────────────────────────────────────┘
+ /**
+ * @ignore
+ */
+ children: PropTypes.node,
+ /**
+ * Class names applied to the element or a function that returns them based on the component's state.
+ */
+ className: PropTypes.oneOfType([PropTypes.func, PropTypes.string]),
+ /**
+ * A function to customize rendering of the component.
+ */
+ render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]),
+} as any;
+
+export { CompositeItem };
diff --git a/packages/mui-base/src/Composite/Item/useCompositeItem.ts b/packages/mui-base/src/Composite/Item/useCompositeItem.ts
new file mode 100644
index 0000000000..70ab405323
--- /dev/null
+++ b/packages/mui-base/src/Composite/Item/useCompositeItem.ts
@@ -0,0 +1,37 @@
+'use client';
+import * as React from 'react';
+import { useCompositeRootContext } from '../Root/CompositeRootContext';
+import { useCompositeListItem } from '../List/useCompositeListItem';
+import { mergeReactProps } from '../../utils/mergeReactProps';
+
+/**
+ *
+ * API:
+ *
+ * - [useCompositeItem API](https://mui.com/base-ui/api/use-composite-item/)
+ */
+export function useCompositeItem() {
+ const { activeIndex, onActiveIndexChange } = useCompositeRootContext();
+ const { ref, index } = useCompositeListItem();
+ const isActive = activeIndex === index;
+
+ const getItemProps = React.useCallback(
+ (externalProps = {}) =>
+ mergeReactProps<'div'>(externalProps, {
+ tabIndex: isActive ? 0 : -1,
+ onFocus() {
+ onActiveIndexChange(index);
+ },
+ }),
+ [isActive, index, onActiveIndexChange],
+ );
+
+ return React.useMemo(
+ () => ({
+ getItemProps,
+ ref,
+ index,
+ }),
+ [getItemProps, ref, index],
+ );
+}
diff --git a/packages/mui-base/src/Composite/List/CompositeList.tsx b/packages/mui-base/src/Composite/List/CompositeList.tsx
new file mode 100644
index 0000000000..09815eb7e6
--- /dev/null
+++ b/packages/mui-base/src/Composite/List/CompositeList.tsx
@@ -0,0 +1,120 @@
+/* eslint-disable no-bitwise */
+'use client';
+import * as React from 'react';
+import PropTypes from 'prop-types';
+import { useEnhancedEffect } from '../../utils/useEnhancedEffect';
+import { CompositeListContext } from './CompositeListContext';
+
+function sortByDocumentPosition(a: Node, b: Node) {
+ const position = a.compareDocumentPosition(b);
+
+ if (
+ position & Node.DOCUMENT_POSITION_FOLLOWING ||
+ position & Node.DOCUMENT_POSITION_CONTAINED_BY
+ ) {
+ return -1;
+ }
+
+ if (position & Node.DOCUMENT_POSITION_PRECEDING || position & Node.DOCUMENT_POSITION_CONTAINS) {
+ return 1;
+ }
+
+ return 0;
+}
+
+function areMapsEqual(map1: Map, map2: Map) {
+ if (map1.size !== map2.size) {
+ return false;
+ }
+ for (const [key, value] of map1.entries()) {
+ if (value !== map2.get(key)) {
+ return false;
+ }
+ }
+ return true;
+}
+
+/**
+ * Provides context for a list of items in a composite component.
+ * @ignore - internal component.
+ */
+function CompositeList(props: CompositeList.Props) {
+ const { children, elementsRef, labelsRef } = props;
+
+ const [map, setMap] = React.useState(() => new Map());
+
+ const register = React.useCallback((node: Node) => {
+ setMap((prevMap) => new Map(prevMap).set(node, null));
+ }, []);
+
+ const unregister = React.useCallback((node: Node) => {
+ setMap((prevMap) => {
+ const nextMap = new Map(prevMap);
+ nextMap.delete(node);
+ return nextMap;
+ });
+ }, []);
+
+ useEnhancedEffect(() => {
+ const newMap = new Map(map);
+ const nodes = Array.from(newMap.keys()).sort(sortByDocumentPosition);
+
+ nodes.forEach((node, index) => {
+ newMap.set(node, index);
+ });
+
+ if (!areMapsEqual(map, newMap)) {
+ setMap(newMap);
+ }
+ }, [map]);
+
+ const contextValue = React.useMemo(
+ () => ({ register, unregister, map, elementsRef, labelsRef }),
+ [register, unregister, map, elementsRef, labelsRef],
+ );
+
+ return (
+ {children}
+ );
+}
+
+namespace CompositeList {
+ export interface Props {
+ children: React.ReactNode;
+ /**
+ * A ref to the list of HTML elements, ordered by their index.
+ * `useListNavigation`'s `listRef` prop.
+ */
+ elementsRef: React.MutableRefObject>;
+ /**
+ * A ref to the list of element labels, ordered by their index.
+ * `useTypeahead`'s `listRef` prop.
+ */
+ labelsRef?: React.MutableRefObject>;
+ }
+}
+
+CompositeList.propTypes /* remove-proptypes */ = {
+ // ┌────────────────────────────── Warning ──────────────────────────────┐
+ // │ These PropTypes are generated from the TypeScript type definitions. │
+ // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
+ // └─────────────────────────────────────────────────────────────────────┘
+ /**
+ * @ignore
+ */
+ children: PropTypes.node,
+ /**
+ * A ref to the list of HTML elements, ordered by their index.
+ * `useListNavigation`'s `listRef` prop.
+ */
+ elementsRef: PropTypes /* @typescript-to-proptypes-ignore */.any,
+ /**
+ * A ref to the list of element labels, ordered by their index.
+ * `useTypeahead`'s `listRef` prop.
+ */
+ labelsRef: PropTypes.shape({
+ current: PropTypes.arrayOf(PropTypes.string).isRequired,
+ }),
+} as any;
+
+export { CompositeList };
diff --git a/packages/mui-base/src/Composite/List/CompositeListContext.ts b/packages/mui-base/src/Composite/List/CompositeListContext.ts
new file mode 100644
index 0000000000..0d8cd305c2
--- /dev/null
+++ b/packages/mui-base/src/Composite/List/CompositeListContext.ts
@@ -0,0 +1,20 @@
+import * as React from 'react';
+
+export interface CompositeListContextValue {
+ register: (node: Node) => void;
+ unregister: (node: Node) => void;
+ map: Map;
+ elementsRef: React.MutableRefObject>;
+ labelsRef?: React.MutableRefObject>;
+}
+
+export const CompositeListContext = React.createContext({
+ register: () => {},
+ unregister: () => {},
+ map: new Map(),
+ elementsRef: { current: [] },
+});
+
+export function useCompositeListContext() {
+ return React.useContext(CompositeListContext);
+}
diff --git a/packages/mui-base/src/Composite/List/useCompositeListItem.ts b/packages/mui-base/src/Composite/List/useCompositeListItem.ts
new file mode 100644
index 0000000000..7a93d56217
--- /dev/null
+++ b/packages/mui-base/src/Composite/List/useCompositeListItem.ts
@@ -0,0 +1,74 @@
+'use client';
+import * as React from 'react';
+import { useEnhancedEffect } from '../../utils/useEnhancedEffect';
+import { useCompositeListContext } from './CompositeListContext';
+
+export interface UseCompositeListItemParameters {
+ label?: string | null;
+}
+
+interface UseCompositeListItemReturnValue {
+ ref: (node: HTMLElement | null) => void;
+ index: number;
+}
+
+/**
+ * Used to register a list item and its index (DOM position) in the
+ * `CompositeList`.
+ *
+ * API:
+ *
+ * - [useCompositeListItem API](https://mui.com/base-ui/api/use-composite-list-item/)
+ */
+export function useCompositeListItem(
+ params: UseCompositeListItemParameters = {},
+): UseCompositeListItemReturnValue {
+ const { label } = params;
+
+ const { register, unregister, map, elementsRef, labelsRef } = useCompositeListContext();
+
+ const [index, setIndex] = React.useState(null);
+
+ const componentRef = React.useRef(null);
+
+ const ref = React.useCallback(
+ (node: HTMLElement | null) => {
+ componentRef.current = node;
+
+ if (index !== null) {
+ elementsRef.current[index] = node;
+ if (labelsRef) {
+ const isLabelDefined = label !== undefined;
+ labelsRef.current[index] = isLabelDefined ? label : node?.textContent ?? null;
+ }
+ }
+ },
+ [index, elementsRef, labelsRef, label],
+ );
+
+ useEnhancedEffect(() => {
+ const node = componentRef.current;
+ if (node) {
+ register(node);
+ return () => {
+ unregister(node);
+ };
+ }
+ return undefined;
+ }, [register, unregister]);
+
+ useEnhancedEffect(() => {
+ const i = componentRef.current ? map.get(componentRef.current) : null;
+ if (i != null) {
+ setIndex(i);
+ }
+ }, [map]);
+
+ return React.useMemo(
+ () => ({
+ ref,
+ index: index == null ? -1 : index,
+ }),
+ [index, ref],
+ );
+}
diff --git a/packages/mui-base/src/Composite/Root/CompositeRoot.tsx b/packages/mui-base/src/Composite/Root/CompositeRoot.tsx
new file mode 100644
index 0000000000..9876177ee7
--- /dev/null
+++ b/packages/mui-base/src/Composite/Root/CompositeRoot.tsx
@@ -0,0 +1,120 @@
+'use client';
+import * as React from 'react';
+import PropTypes from 'prop-types';
+import { useComponentRenderer } from '../../utils/useComponentRenderer';
+import { CompositeList } from '../List/CompositeList';
+import { useCompositeRoot } from './useCompositeRoot';
+import { CompositeRootContext } from './CompositeRootContext';
+import type { BaseUIComponentProps } from '../../utils/types';
+import type { Dimensions } from '../composite';
+
+/**
+ * @ignore - internal component.
+ */
+const CompositeRoot = React.forwardRef(function CompositeRoot(
+ props: CompositeRoot.Props,
+ forwardedRef: React.ForwardedRef,
+) {
+ const {
+ render,
+ className,
+ activeIndex: activeIndexProp,
+ onActiveIndexChange: onActiveIndexChangeProp,
+ orientation,
+ dense,
+ itemSizes,
+ loop,
+ cols,
+ ...otherProps
+ } = props;
+
+ const { getRootProps, activeIndex, onActiveIndexChange, elementsRef } = useCompositeRoot(props);
+
+ const { renderElement } = useComponentRenderer({
+ propGetter: getRootProps,
+ ref: forwardedRef,
+ render: render ?? 'div',
+ ownerState: {},
+ className,
+ extraProps: otherProps,
+ });
+
+ const contextValue: CompositeRootContext.Value = React.useMemo(
+ () => ({ activeIndex, onActiveIndexChange }),
+ [activeIndex, onActiveIndexChange],
+ );
+
+ return (
+
+ {renderElement()}
+
+ );
+});
+
+namespace CompositeRoot {
+ export interface OwnerState {}
+
+ export interface Props extends BaseUIComponentProps<'div', OwnerState> {
+ orientation?: 'horizontal' | 'vertical' | 'both';
+ cols?: number;
+ loop?: boolean;
+ activeIndex?: number;
+ onActiveIndexChange?: (index: number) => void;
+ itemSizes?: Dimensions[];
+ dense?: boolean;
+ }
+}
+
+CompositeRoot.propTypes /* remove-proptypes */ = {
+ // ┌────────────────────────────── Warning ──────────────────────────────┐
+ // │ These PropTypes are generated from the TypeScript type definitions. │
+ // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
+ // └─────────────────────────────────────────────────────────────────────┘
+ /**
+ * @ignore
+ */
+ activeIndex: PropTypes.number,
+ /**
+ * @ignore
+ */
+ children: PropTypes.node,
+ /**
+ * Class names applied to the element or a function that returns them based on the component's state.
+ */
+ className: PropTypes.oneOfType([PropTypes.func, PropTypes.string]),
+ /**
+ * @ignore
+ */
+ cols: PropTypes.number,
+ /**
+ * @ignore
+ */
+ dense: PropTypes.bool,
+ /**
+ * @ignore
+ */
+ itemSizes: PropTypes.arrayOf(
+ PropTypes.shape({
+ height: PropTypes.number.isRequired,
+ width: PropTypes.number.isRequired,
+ }),
+ ),
+ /**
+ * @ignore
+ */
+ loop: PropTypes.bool,
+ /**
+ * @ignore
+ */
+ onActiveIndexChange: PropTypes.func,
+ /**
+ * @ignore
+ */
+ orientation: PropTypes.oneOf(['both', 'horizontal', 'vertical']),
+ /**
+ * A function to customize rendering of the component.
+ */
+ render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]),
+} as any;
+
+export { CompositeRoot };
diff --git a/packages/mui-base/src/Composite/Root/CompositeRootContext.ts b/packages/mui-base/src/Composite/Root/CompositeRootContext.ts
new file mode 100644
index 0000000000..f8b119b05c
--- /dev/null
+++ b/packages/mui-base/src/Composite/Root/CompositeRootContext.ts
@@ -0,0 +1,18 @@
+import * as React from 'react';
+
+export const CompositeRootContext = React.createContext(null);
+
+export function useCompositeRootContext() {
+ const context = React.useContext(CompositeRootContext);
+ if (context === null) {
+ throw new Error(' must be used within ');
+ }
+ return context;
+}
+
+export namespace CompositeRootContext {
+ export interface Value {
+ activeIndex: number;
+ onActiveIndexChange: (index: number) => void;
+ }
+}
diff --git a/packages/mui-base/src/Composite/Root/useCompositeRoot.ts b/packages/mui-base/src/Composite/Root/useCompositeRoot.ts
new file mode 100644
index 0000000000..3a42f6f8dd
--- /dev/null
+++ b/packages/mui-base/src/Composite/Root/useCompositeRoot.ts
@@ -0,0 +1,210 @@
+'use client';
+import * as React from 'react';
+import { useEventCallback } from '../../utils/useEventCallback';
+import { mergeReactProps } from '../../utils/mergeReactProps';
+import {
+ ALL_KEYS,
+ ARROW_DOWN,
+ ARROW_LEFT,
+ ARROW_RIGHT,
+ ARROW_UP,
+ buildCellMap,
+ findNonDisabledIndex,
+ getCellIndexOfCorner,
+ getCellIndices,
+ getGridNavigatedIndex,
+ getMaxIndex,
+ getMinIndex,
+ HORIZONTAL_KEYS,
+ isDisabled,
+ isIndexOutOfBounds,
+ VERTICAL_KEYS,
+ type Dimensions,
+} from '../composite';
+
+export interface UseCompositeRootParameters {
+ orientation?: 'horizontal' | 'vertical' | 'both';
+ cols?: number;
+ loop?: boolean;
+ activeIndex?: number;
+ onActiveIndexChange?: (index: number) => void;
+ dense?: boolean;
+ itemSizes?: Array;
+}
+
+// Advanced options of Composite, to be implemented later if needed.
+const disabledIndices = undefined;
+
+/**
+ * @ignore - internal hook.
+ */
+export function useCompositeRoot(params: UseCompositeRootParameters) {
+ const {
+ itemSizes,
+ cols = 1,
+ loop = true,
+ dense = false,
+ orientation = 'both',
+ activeIndex: externalActiveIndex,
+ onActiveIndexChange: externalSetActiveIndex,
+ } = params;
+
+ const [internalActiveIndex, internalSetActiveIndex] = React.useState(0);
+
+ const isGrid = cols > 1;
+
+ const activeIndex = externalActiveIndex ?? internalActiveIndex;
+ const onActiveIndexChange = useEventCallback(externalSetActiveIndex ?? internalSetActiveIndex);
+
+ const elementsRef = React.useRef>([]);
+
+ const getRootProps = React.useCallback(
+ (externalProps = {}) =>
+ mergeReactProps<'div'>(externalProps, {
+ 'aria-orientation': orientation === 'both' ? undefined : orientation,
+ onKeyDown(event) {
+ if (!ALL_KEYS.includes(event.key)) {
+ return;
+ }
+
+ let nextIndex = activeIndex;
+ const minIndex = getMinIndex(elementsRef, disabledIndices);
+ const maxIndex = getMaxIndex(elementsRef, disabledIndices);
+
+ if (isGrid) {
+ const sizes =
+ itemSizes ||
+ Array.from({ length: elementsRef.current.length }, () => ({
+ width: 1,
+ height: 1,
+ }));
+ // To calculate movements on the grid, we use hypothetical cell indices
+ // as if every item was 1x1, then convert back to real indices.
+ const cellMap = buildCellMap(sizes, cols, dense);
+ const minGridIndex = cellMap.findIndex(
+ (index) => index != null && !isDisabled(elementsRef.current, index, disabledIndices),
+ );
+ // last enabled index
+ const maxGridIndex = cellMap.reduce(
+ (foundIndex: number, index, cellIndex) =>
+ index != null && !isDisabled(elementsRef.current, index, disabledIndices)
+ ? cellIndex
+ : foundIndex,
+ -1,
+ );
+
+ nextIndex = cellMap[
+ getGridNavigatedIndex(
+ {
+ current: cellMap.map((itemIndex) =>
+ itemIndex ? elementsRef.current[itemIndex] : null,
+ ),
+ },
+ {
+ event,
+ orientation,
+ loop,
+ cols,
+ // treat undefined (empty grid spaces) as disabled indices so we
+ // don't end up in them
+ disabledIndices: getCellIndices(
+ [
+ ...(disabledIndices ||
+ elementsRef.current.map((_, index) =>
+ isDisabled(elementsRef.current, index) ? index : undefined,
+ )),
+ undefined,
+ ],
+ cellMap,
+ ),
+ minIndex: minGridIndex,
+ maxIndex: maxGridIndex,
+ prevIndex: getCellIndexOfCorner(
+ activeIndex > maxIndex ? minIndex : activeIndex,
+ sizes,
+ cellMap,
+ cols,
+ // use a corner matching the edge closest to the direction we're
+ // moving in so we don't end up in the same item. Prefer
+ // top/left over bottom/right.
+ // eslint-disable-next-line no-nested-ternary
+ event.key === ARROW_DOWN ? 'bl' : event.key === ARROW_RIGHT ? 'tr' : 'tl',
+ ),
+ },
+ )
+ ] as number; // navigated cell will never be nullish
+ }
+
+ const toEndKeys = {
+ horizontal: [ARROW_RIGHT],
+ vertical: [ARROW_DOWN],
+ both: [ARROW_RIGHT, ARROW_DOWN],
+ }[orientation];
+
+ const toStartKeys = {
+ horizontal: [ARROW_LEFT],
+ vertical: [ARROW_UP],
+ both: [ARROW_LEFT, ARROW_UP],
+ }[orientation];
+
+ const preventedKeys = isGrid
+ ? ALL_KEYS
+ : {
+ horizontal: HORIZONTAL_KEYS,
+ vertical: VERTICAL_KEYS,
+ both: ALL_KEYS,
+ }[orientation];
+
+ if (nextIndex === activeIndex && [...toEndKeys, ...toStartKeys].includes(event.key)) {
+ if (loop && nextIndex === maxIndex && toEndKeys.includes(event.key)) {
+ nextIndex = minIndex;
+ } else if (loop && nextIndex === minIndex && toStartKeys.includes(event.key)) {
+ nextIndex = maxIndex;
+ } else {
+ nextIndex = findNonDisabledIndex(elementsRef, {
+ startingIndex: nextIndex,
+ decrement: toStartKeys.includes(event.key),
+ disabledIndices,
+ });
+ }
+ }
+
+ if (nextIndex !== activeIndex && !isIndexOutOfBounds(elementsRef, nextIndex)) {
+ event.stopPropagation();
+
+ if (preventedKeys.includes(event.key)) {
+ event.preventDefault();
+ }
+
+ onActiveIndexChange(nextIndex);
+
+ // Wait for FocusManager `returnFocus` to execute.
+ queueMicrotask(() => {
+ elementsRef.current[nextIndex]?.focus();
+ });
+ }
+ },
+ }),
+ [
+ activeIndex,
+ cols,
+ dense,
+ elementsRef,
+ isGrid,
+ itemSizes,
+ loop,
+ onActiveIndexChange,
+ orientation,
+ ],
+ );
+
+ return React.useMemo(
+ () => ({
+ getRootProps,
+ activeIndex,
+ onActiveIndexChange,
+ elementsRef,
+ }),
+ [getRootProps, activeIndex, onActiveIndexChange, elementsRef],
+ );
+}
diff --git a/packages/mui-base/src/Composite/composite.ts b/packages/mui-base/src/Composite/composite.ts
new file mode 100644
index 0000000000..402c329c9a
--- /dev/null
+++ b/packages/mui-base/src/Composite/composite.ts
@@ -0,0 +1,349 @@
+import * as React from 'react';
+
+export interface Dimensions {
+ width: number;
+ height: number;
+}
+
+export const ARROW_UP = 'ArrowUp';
+export const ARROW_DOWN = 'ArrowDown';
+export const ARROW_LEFT = 'ArrowLeft';
+export const ARROW_RIGHT = 'ArrowRight';
+
+export const HORIZONTAL_KEYS = [ARROW_LEFT, ARROW_RIGHT];
+export const VERTICAL_KEYS = [ARROW_UP, ARROW_DOWN];
+export const ALL_KEYS = [...HORIZONTAL_KEYS, ...VERTICAL_KEYS];
+
+function stopEvent(event: Event | React.SyntheticEvent) {
+ event.preventDefault();
+ event.stopPropagation();
+}
+
+export function isDifferentRow(index: number, cols: number, prevRow: number) {
+ return Math.floor(index / cols) !== prevRow;
+}
+
+export function isIndexOutOfBounds(
+ listRef: React.MutableRefObject>,
+ index: number,
+) {
+ return index < 0 || index >= listRef.current.length;
+}
+
+export function getMinIndex(
+ listRef: React.MutableRefObject>,
+ disabledIndices: Array | undefined,
+) {
+ return findNonDisabledIndex(listRef, { disabledIndices });
+}
+
+export function getMaxIndex(
+ listRef: React.MutableRefObject>,
+ disabledIndices: Array | undefined,
+) {
+ return findNonDisabledIndex(listRef, {
+ decrement: true,
+ startingIndex: listRef.current.length,
+ disabledIndices,
+ });
+}
+
+export function findNonDisabledIndex(
+ listRef: React.MutableRefObject>,
+ {
+ startingIndex = -1,
+ decrement = false,
+ disabledIndices,
+ amount = 1,
+ }: {
+ startingIndex?: number;
+ decrement?: boolean;
+ disabledIndices?: Array;
+ amount?: number;
+ } = {},
+): number {
+ const list = listRef.current;
+
+ let index = startingIndex;
+ do {
+ index += decrement ? -amount : amount;
+ } while (index >= 0 && index <= list.length - 1 && isDisabled(list, index, disabledIndices));
+
+ return index;
+}
+
+export function getGridNavigatedIndex(
+ elementsRef: React.MutableRefObject>,
+ {
+ event,
+ orientation,
+ loop,
+ cols,
+ disabledIndices,
+ minIndex,
+ maxIndex,
+ prevIndex,
+ stopEvent: stop = false,
+ }: {
+ event: React.KeyboardEvent;
+ orientation: 'horizontal' | 'vertical' | 'both';
+ loop: boolean;
+ cols: number;
+ disabledIndices: Array | undefined;
+ minIndex: number;
+ maxIndex: number;
+ prevIndex: number;
+ stopEvent?: boolean;
+ },
+) {
+ let nextIndex = prevIndex;
+
+ if (event.key === ARROW_UP) {
+ if (stop) {
+ stopEvent(event);
+ }
+
+ if (prevIndex === -1) {
+ nextIndex = maxIndex;
+ } else {
+ nextIndex = findNonDisabledIndex(elementsRef, {
+ startingIndex: nextIndex,
+ amount: cols,
+ decrement: true,
+ disabledIndices,
+ });
+
+ if (loop && (prevIndex - cols < minIndex || nextIndex < 0)) {
+ const col = prevIndex % cols;
+ const maxCol = maxIndex % cols;
+ const offset = maxIndex - (maxCol - col);
+
+ if (maxCol === col) {
+ nextIndex = maxIndex;
+ } else {
+ nextIndex = maxCol > col ? offset : offset - cols;
+ }
+ }
+ }
+
+ if (isIndexOutOfBounds(elementsRef, nextIndex)) {
+ nextIndex = prevIndex;
+ }
+ }
+
+ if (event.key === ARROW_DOWN) {
+ if (stop) {
+ stopEvent(event);
+ }
+
+ if (prevIndex === -1) {
+ nextIndex = minIndex;
+ } else {
+ nextIndex = findNonDisabledIndex(elementsRef, {
+ startingIndex: prevIndex,
+ amount: cols,
+ disabledIndices,
+ });
+
+ if (loop && prevIndex + cols > maxIndex) {
+ nextIndex = findNonDisabledIndex(elementsRef, {
+ startingIndex: (prevIndex % cols) - cols,
+ amount: cols,
+ disabledIndices,
+ });
+ }
+ }
+
+ if (isIndexOutOfBounds(elementsRef, nextIndex)) {
+ nextIndex = prevIndex;
+ }
+ }
+
+ // Remains on the same row/column.
+ if (orientation === 'both') {
+ const prevRow = Math.floor(prevIndex / cols);
+
+ if (event.key === ARROW_RIGHT) {
+ if (stop) {
+ stopEvent(event);
+ }
+
+ if (prevIndex % cols !== cols - 1) {
+ nextIndex = findNonDisabledIndex(elementsRef, {
+ startingIndex: prevIndex,
+ disabledIndices,
+ });
+
+ if (loop && isDifferentRow(nextIndex, cols, prevRow)) {
+ nextIndex = findNonDisabledIndex(elementsRef, {
+ startingIndex: prevIndex - (prevIndex % cols) - 1,
+ disabledIndices,
+ });
+ }
+ } else if (loop) {
+ nextIndex = findNonDisabledIndex(elementsRef, {
+ startingIndex: prevIndex - (prevIndex % cols) - 1,
+ disabledIndices,
+ });
+ }
+
+ if (isDifferentRow(nextIndex, cols, prevRow)) {
+ nextIndex = prevIndex;
+ }
+ }
+
+ if (event.key === ARROW_LEFT) {
+ if (stop) {
+ stopEvent(event);
+ }
+
+ if (prevIndex % cols !== 0) {
+ nextIndex = findNonDisabledIndex(elementsRef, {
+ startingIndex: prevIndex,
+ decrement: true,
+ disabledIndices,
+ });
+
+ if (loop && isDifferentRow(nextIndex, cols, prevRow)) {
+ nextIndex = findNonDisabledIndex(elementsRef, {
+ startingIndex: prevIndex + (cols - (prevIndex % cols)),
+ decrement: true,
+ disabledIndices,
+ });
+ }
+ } else if (loop) {
+ nextIndex = findNonDisabledIndex(elementsRef, {
+ startingIndex: prevIndex + (cols - (prevIndex % cols)),
+ decrement: true,
+ disabledIndices,
+ });
+ }
+
+ if (isDifferentRow(nextIndex, cols, prevRow)) {
+ nextIndex = prevIndex;
+ }
+ }
+
+ const lastRow = Math.floor(maxIndex / cols) === prevRow;
+
+ if (isIndexOutOfBounds(elementsRef, nextIndex)) {
+ if (loop && lastRow) {
+ nextIndex =
+ event.key === ARROW_LEFT
+ ? maxIndex
+ : findNonDisabledIndex(elementsRef, {
+ startingIndex: prevIndex - (prevIndex % cols) - 1,
+ disabledIndices,
+ });
+ } else {
+ nextIndex = prevIndex;
+ }
+ }
+ }
+
+ return nextIndex;
+}
+
+/** For each cell index, gets the item index that occupies that cell */
+export function buildCellMap(
+ sizes: Array<{ width: number; height: number }>,
+ cols: number,
+ dense: boolean,
+) {
+ const cellMap: (number | undefined)[] = [];
+ let startIndex = 0;
+
+ sizes.forEach(({ width, height }, index) => {
+ if (width > cols) {
+ if (process.env.NODE_ENV !== 'production') {
+ throw new Error(
+ `[Base UI]: Invalid grid - item width at index ${index} is greater than grid columns`,
+ );
+ }
+ }
+
+ let itemPlaced = false;
+ if (dense) {
+ startIndex = 0;
+ }
+ while (!itemPlaced) {
+ const targetCells: number[] = [];
+ for (let i = 0; i < width; i += 1) {
+ for (let j = 0; j < height; j += 1) {
+ targetCells.push(startIndex + i + j * cols);
+ }
+ }
+ if (
+ (startIndex % cols) + width <= cols &&
+ targetCells.every((cell) => cellMap[cell] == null)
+ ) {
+ targetCells.forEach((cell) => {
+ cellMap[cell] = index;
+ });
+ itemPlaced = true;
+ } else {
+ startIndex += 1;
+ }
+ }
+ });
+
+ // convert into a non-sparse array
+ return [...cellMap];
+}
+
+/** Gets cell index of an item's corner or -1 when index is -1. */
+export function getCellIndexOfCorner(
+ index: number,
+ sizes: Dimensions[],
+ cellMap: (number | undefined)[],
+ cols: number,
+ corner: 'tl' | 'tr' | 'bl' | 'br',
+) {
+ if (index === -1) {
+ return -1;
+ }
+
+ const firstCellIndex = cellMap.indexOf(index);
+ const sizeItem = sizes[index];
+
+ switch (corner) {
+ case 'tl':
+ return firstCellIndex;
+ case 'tr':
+ if (!sizeItem) {
+ return firstCellIndex;
+ }
+ return firstCellIndex + sizeItem.width - 1;
+ case 'bl':
+ if (!sizeItem) {
+ return firstCellIndex;
+ }
+ return firstCellIndex + (sizeItem.height - 1) * cols;
+ case 'br':
+ return cellMap.lastIndexOf(index);
+ default:
+ return -1;
+ }
+}
+
+/** Gets all cell indices that correspond to the specified indices */
+export function getCellIndices(indices: (number | undefined)[], cellMap: (number | undefined)[]) {
+ return cellMap.flatMap((index, cellIndex) => (indices.includes(index) ? [cellIndex] : []));
+}
+
+export function isDisabled(
+ list: Array,
+ index: number,
+ disabledIndices?: Array,
+) {
+ if (disabledIndices) {
+ return disabledIndices.includes(index);
+ }
+
+ const element = list[index];
+ return (
+ element == null ||
+ element.hasAttribute('disabled') ||
+ element.getAttribute('aria-disabled') === 'true'
+ );
+}
diff --git a/packages/mui-base/src/Field/Description/FieldDescription.test.tsx b/packages/mui-base/src/Field/Description/FieldDescription.test.tsx
index ae3e43f2a6..56ade9fbfe 100644
--- a/packages/mui-base/src/Field/Description/FieldDescription.test.tsx
+++ b/packages/mui-base/src/Field/Description/FieldDescription.test.tsx
@@ -4,6 +4,8 @@ import * as Checkbox from '@base_ui/react/Checkbox';
import * as Switch from '@base_ui/react/Switch';
import * as NumberField from '@base_ui/react/NumberField';
import * as Slider from '@base_ui/react/Slider';
+import * as RadioGroup from '@base_ui/react/RadioGroup';
+import * as Radio from '@base_ui/react/Radio';
import { createRenderer, screen } from '@mui/internal-test-utils';
import { expect } from 'chai';
import { describeConformance } from '../../../test/describeConformance';
@@ -104,5 +106,23 @@ describe(' ', () => {
);
});
});
+
+ describe('RadioGroup', () => {
+ it('supports RadioGroup', () => {
+ render(
+
+
+
+
+
+ ,
+ );
+
+ expect(screen.getByTestId('description')).to.have.attribute(
+ 'id',
+ screen.getByRole('radiogroup').getAttribute('aria-describedby')!,
+ );
+ });
+ });
});
});
diff --git a/packages/mui-base/src/Field/Label/FieldLabel.test.tsx b/packages/mui-base/src/Field/Label/FieldLabel.test.tsx
index 16a8c2bceb..c1cf8d6453 100644
--- a/packages/mui-base/src/Field/Label/FieldLabel.test.tsx
+++ b/packages/mui-base/src/Field/Label/FieldLabel.test.tsx
@@ -4,6 +4,8 @@ import * as Checkbox from '@base_ui/react/Checkbox';
import * as Switch from '@base_ui/react/Switch';
import * as NumberField from '@base_ui/react/NumberField';
import * as Slider from '@base_ui/react/Slider';
+import * as RadioGroup from '@base_ui/react/RadioGroup';
+import * as Radio from '@base_ui/react/Radio';
import { createRenderer, screen } from '@mui/internal-test-utils';
import { expect } from 'chai';
import { describeConformance } from '../../../test/describeConformance';
@@ -95,5 +97,23 @@ describe(' ', () => {
);
});
});
+
+ describe('RadioGroup', () => {
+ it('supports RadioGroup', () => {
+ render(
+
+
+
+
+
+ ,
+ );
+
+ expect(screen.getByTestId('radio-group')).to.have.attribute(
+ 'aria-labelledby',
+ screen.getByTestId('label').id,
+ );
+ });
+ });
});
});
diff --git a/packages/mui-base/src/Field/Root/FieldRoot.test.tsx b/packages/mui-base/src/Field/Root/FieldRoot.test.tsx
index e293e8e46e..355eafa8a5 100644
--- a/packages/mui-base/src/Field/Root/FieldRoot.test.tsx
+++ b/packages/mui-base/src/Field/Root/FieldRoot.test.tsx
@@ -4,6 +4,9 @@ import * as Checkbox from '@base_ui/react/Checkbox';
import * as Switch from '@base_ui/react/Switch';
import * as NumberField from '@base_ui/react/NumberField';
import * as Slider from '@base_ui/react/Slider';
+import * as RadioGroup from '@base_ui/react/RadioGroup';
+import * as Radio from '@base_ui/react/Radio';
+import userEvent from '@testing-library/user-event';
import {
act,
createRenderer,
@@ -221,6 +224,7 @@ describe(' ', () => {
,
);
+ // eslint-disable-next-line testing-library/no-node-access
const input = container.querySelector('input')!;
const thumb = screen.getByTestId('thumb');
@@ -231,6 +235,27 @@ describe(' ', () => {
expect(input).to.have.attribute('aria-invalid', 'true');
});
+
+ it('supports RadioGroup', () => {
+ render(
+ 'error'}>
+
+ One
+ Two
+
+
+ ,
+ );
+
+ const group = screen.getByTestId('group');
+
+ expect(group).not.to.have.attribute('aria-invalid');
+
+ fireEvent.focus(group);
+ fireEvent.blur(group);
+
+ expect(group).to.have.attribute('aria-invalid', 'true');
+ });
});
});
@@ -405,6 +430,50 @@ describe(' ', () => {
expect(root).to.have.attribute('data-touched', 'true');
});
+
+ it('supports RadioGroup (click)', () => {
+ render(
+
+
+
+ One
+
+ Two
+
+ ,
+ );
+
+ const group = screen.getByTestId('group');
+ const control = screen.getByTestId('control');
+
+ fireEvent.click(control);
+
+ expect(group).to.have.attribute('data-touched', 'true');
+ expect(control).to.have.attribute('data-touched', 'true');
+ });
+
+ it('supports RadioGroup (blur)', async () => {
+ render(
+
+
+
+ One
+
+ Two
+
+
+ ,
+ );
+
+ const group = screen.getByTestId('group');
+ const control = screen.getByTestId('control');
+
+ await userEvent.tab(); // onto control
+ await userEvent.tab(); // onto last button
+
+ expect(group).to.have.attribute('data-touched', 'true');
+ expect(control).to.have.attribute('data-touched', 'true');
+ });
});
describe('dirty', () => {
@@ -505,6 +574,7 @@ describe(' ', () => {
);
const root = screen.getByTestId('root');
+ // eslint-disable-next-line testing-library/no-node-access
const input = container.querySelector('input')!;
expect(root).not.to.have.attribute('data-dirty');
@@ -513,6 +583,25 @@ describe(' ', () => {
expect(root).to.have.attribute('data-dirty', 'true');
});
+
+ it('supports RadioGroup', () => {
+ render(
+
+
+ One
+ Two
+
+ ,
+ );
+
+ const group = screen.getByTestId('group');
+
+ expect(group).not.to.have.attribute('data-dirty');
+
+ fireEvent.click(screen.getByText('One'));
+
+ expect(group).to.have.attribute('data-dirty', 'true');
+ });
});
});
});
diff --git a/packages/mui-base/src/Radio/Indicator/RadioIndicator.test.tsx b/packages/mui-base/src/Radio/Indicator/RadioIndicator.test.tsx
new file mode 100644
index 0000000000..d2d03649d1
--- /dev/null
+++ b/packages/mui-base/src/Radio/Indicator/RadioIndicator.test.tsx
@@ -0,0 +1,15 @@
+import * as React from 'react';
+import { createRenderer } from '@mui/internal-test-utils';
+import * as Radio from '@base_ui/react/Radio';
+import { describeConformance } from '../../../test/describeConformance';
+
+describe(' ', () => {
+ const { render } = createRenderer();
+
+ describeConformance( , () => ({
+ refInstanceof: window.HTMLSpanElement,
+ render(node) {
+ return render({node} );
+ },
+ }));
+});
diff --git a/packages/mui-base/src/Radio/Indicator/RadioIndicator.tsx b/packages/mui-base/src/Radio/Indicator/RadioIndicator.tsx
new file mode 100644
index 0000000000..5a1e3190ff
--- /dev/null
+++ b/packages/mui-base/src/Radio/Indicator/RadioIndicator.tsx
@@ -0,0 +1,80 @@
+'use client';
+import * as React from 'react';
+import PropTypes from 'prop-types';
+import type { BaseUIComponentProps } from '../../utils/types';
+import type { CustomStyleHookMapping } from '../../utils/getStyleHookProps';
+import { useComponentRenderer } from '../../utils/useComponentRenderer';
+import { useRadioRootContext } from '../Root/RadioRootContext';
+
+const customStyleHookMapping: CustomStyleHookMapping = {
+ checked(value) {
+ return {
+ 'data-radio': value ? 'checked' : 'unchecked',
+ };
+ },
+};
+
+const RadioIndicator = React.forwardRef(function RadioIndicator(
+ props: RadioIndicator.Props,
+ forwardedRef: React.ForwardedRef,
+) {
+ const { render, className, keepMounted = true, ...otherProps } = props;
+
+ const ownerState = useRadioRootContext();
+
+ const { renderElement } = useComponentRenderer({
+ render: render ?? 'span',
+ ref: forwardedRef,
+ className,
+ ownerState,
+ extraProps: otherProps,
+ customStyleHookMapping,
+ });
+
+ const shouldRender = keepMounted || ownerState.checked;
+ if (!shouldRender) {
+ return null;
+ }
+
+ return renderElement();
+});
+
+namespace RadioIndicator {
+ export interface Props extends BaseUIComponentProps<'span', OwnerState> {
+ /**
+ * Whether the component should be kept mounted when not checked.
+ * @default true
+ */
+ keepMounted?: boolean;
+ }
+
+ export interface OwnerState {
+ checked: boolean;
+ }
+}
+
+RadioIndicator.propTypes /* remove-proptypes */ = {
+ // ┌────────────────────────────── Warning ──────────────────────────────┐
+ // │ These PropTypes are generated from the TypeScript type definitions. │
+ // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
+ // └─────────────────────────────────────────────────────────────────────┘
+ /**
+ * @ignore
+ */
+ children: PropTypes.node,
+ /**
+ * Class names applied to the element or a function that returns them based on the component's state.
+ */
+ className: PropTypes.oneOfType([PropTypes.func, PropTypes.string]),
+ /**
+ * Whether the component should be kept mounted when not checked.
+ * @default true
+ */
+ keepMounted: PropTypes.bool,
+ /**
+ * A function to customize rendering of the component.
+ */
+ render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]),
+} as any;
+
+export { RadioIndicator };
diff --git a/packages/mui-base/src/Radio/Root/RadioRoot.test.tsx b/packages/mui-base/src/Radio/Root/RadioRoot.test.tsx
new file mode 100644
index 0000000000..cc117d3580
--- /dev/null
+++ b/packages/mui-base/src/Radio/Root/RadioRoot.test.tsx
@@ -0,0 +1,13 @@
+import * as React from 'react';
+import { createRenderer } from '@mui/internal-test-utils';
+import * as Radio from '@base_ui/react/Radio';
+import { describeConformance } from '../../../test/describeConformance';
+
+describe(' ', () => {
+ const { render } = createRenderer();
+
+ describeConformance( , () => ({
+ refInstanceof: window.HTMLButtonElement,
+ render,
+ }));
+});
diff --git a/packages/mui-base/src/Radio/Root/RadioRoot.tsx b/packages/mui-base/src/Radio/Root/RadioRoot.tsx
new file mode 100644
index 0000000000..0cabffef11
--- /dev/null
+++ b/packages/mui-base/src/Radio/Root/RadioRoot.tsx
@@ -0,0 +1,154 @@
+'use client';
+import * as React from 'react';
+import PropTypes from 'prop-types';
+import type { BaseUIComponentProps } from '../../utils/types';
+import type { CustomStyleHookMapping } from '../../utils/getStyleHookProps';
+import { useComponentRenderer } from '../../utils/useComponentRenderer';
+import { useRadioGroupRootContext } from '../../RadioGroup/Root/RadioGroupRootContext';
+import { useRadioRoot } from './useRadioRoot';
+import { RadioRootContext } from './RadioRootContext';
+import { CompositeItem } from '../../Composite/Item/CompositeItem';
+import { NOOP } from '../../utils/noop';
+import { useFieldRootContext } from '../../Field/Root/FieldRootContext';
+
+const customStyleHookMapping: CustomStyleHookMapping = {
+ checked(value) {
+ return {
+ 'data-radio': value ? 'checked' : 'unchecked',
+ };
+ },
+};
+
+const RadioRoot = React.forwardRef(function RadioRoot(
+ props: RadioRoot.Props,
+ forwardedRef: React.ForwardedRef,
+) {
+ const {
+ render,
+ className,
+ disabled: disabledProp = false,
+ readOnly: readOnlyProp = false,
+ required: requiredProp = false,
+ ...otherProps
+ } = props;
+
+ const {
+ disabled: disabledRoot,
+ readOnly: readOnlyRoot,
+ required: requiredRoot,
+ setCheckedValue,
+ } = useRadioGroupRootContext();
+
+ const { ownerState: fieldOwnerState, disabled: fieldDisabled } = useFieldRootContext();
+
+ const disabled = fieldDisabled || disabledRoot || disabledProp;
+ const readOnly = readOnlyRoot || readOnlyProp;
+ const required = requiredRoot || requiredProp;
+
+ const { getRootProps, getInputProps, checked } = useRadioRoot({
+ ...props,
+ disabled,
+ readOnly,
+ });
+
+ const ownerState: RadioRoot.OwnerState = React.useMemo(
+ () => ({
+ ...fieldOwnerState,
+ required,
+ disabled,
+ readOnly,
+ checked,
+ }),
+ [fieldOwnerState, disabled, readOnly, checked, required],
+ );
+
+ const contextValue: RadioRootContext = React.useMemo(() => ownerState, [ownerState]);
+
+ const { renderElement } = useComponentRenderer({
+ propGetter: getRootProps,
+ render: render ?? 'button',
+ ref: forwardedRef,
+ className,
+ ownerState,
+ extraProps: otherProps,
+ customStyleHookMapping,
+ });
+
+ return (
+
+ {setCheckedValue === NOOP ? renderElement() : }
+
+
+ );
+});
+
+namespace RadioRoot {
+ export interface Props extends Omit, 'value'> {
+ /**
+ * The unique identifying value of the radio in a group.
+ */
+ value: unknown;
+ /**
+ * Determines if the radio is disabled.
+ * @default false
+ */
+ disabled?: boolean;
+ /**
+ * Determines if the radio is required.
+ * @default false
+ */
+ required?: boolean;
+ /**
+ * Determines if the radio is readonly.
+ * @default false
+ */
+ readOnly?: boolean;
+ }
+
+ export interface OwnerState {
+ checked: boolean;
+ disabled: boolean;
+ readOnly: boolean;
+ required: boolean;
+ }
+}
+
+RadioRoot.propTypes /* remove-proptypes */ = {
+ // ┌────────────────────────────── Warning ──────────────────────────────┐
+ // │ These PropTypes are generated from the TypeScript type definitions. │
+ // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
+ // └─────────────────────────────────────────────────────────────────────┘
+ /**
+ * @ignore
+ */
+ children: PropTypes.node,
+ /**
+ * Class names applied to the element or a function that returns them based on the component's state.
+ */
+ className: PropTypes.oneOfType([PropTypes.func, PropTypes.string]),
+ /**
+ * Determines if the radio is disabled.
+ * @default false
+ */
+ disabled: PropTypes.bool,
+ /**
+ * Determines if the radio is readonly.
+ * @default false
+ */
+ readOnly: PropTypes.bool,
+ /**
+ * A function to customize rendering of the component.
+ */
+ render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]),
+ /**
+ * Determines if the radio is required.
+ * @default false
+ */
+ required: PropTypes.bool,
+ /**
+ * The unique identifying value of the radio in a group.
+ */
+ value: PropTypes.any.isRequired,
+} as any;
+
+export { RadioRoot };
diff --git a/packages/mui-base/src/Radio/Root/RadioRootContext.ts b/packages/mui-base/src/Radio/Root/RadioRootContext.ts
new file mode 100644
index 0000000000..660bbc371a
--- /dev/null
+++ b/packages/mui-base/src/Radio/Root/RadioRootContext.ts
@@ -0,0 +1,18 @@
+import * as React from 'react';
+
+export interface RadioRootContext {
+ disabled: boolean;
+ readOnly: boolean;
+ checked: boolean;
+ required: boolean;
+}
+
+export const RadioRootContext = React.createContext(null);
+
+export function useRadioRootContext() {
+ const value = React.useContext(RadioRootContext);
+ if (value === null) {
+ throw new Error('Base UI: must be used within ');
+ }
+ return value;
+}
diff --git a/packages/mui-base/src/Radio/Root/useRadioRoot.tsx b/packages/mui-base/src/Radio/Root/useRadioRoot.tsx
new file mode 100644
index 0000000000..87dda643b5
--- /dev/null
+++ b/packages/mui-base/src/Radio/Root/useRadioRoot.tsx
@@ -0,0 +1,131 @@
+'use client';
+import * as React from 'react';
+import { mergeReactProps } from '../../utils/mergeReactProps';
+import { visuallyHidden } from '../../utils/visuallyHidden';
+import { useRadioGroupRootContext } from '../../RadioGroup/Root/RadioGroupRootContext';
+import { useFieldRootContext } from '../../Field/Root/FieldRootContext';
+
+/**
+ *
+ * API:
+ *
+ * - [useRadioRoot API](https://mui.com/base-ui/api/use-radio-root/)
+ */
+export function useRadioRoot(params: useRadioRoot.Parameters) {
+ const { disabled, readOnly, value, required } = params;
+
+ const { checkedValue, setCheckedValue, onValueChange, touched, setTouched } =
+ useRadioGroupRootContext();
+
+ const { setDirty, validityData, setTouched: setFieldTouched } = useFieldRootContext();
+
+ const checked = checkedValue === value;
+
+ const inputRef = React.useRef(null);
+
+ const getRootProps: useRadioRoot.ReturnValue['getRootProps'] = React.useCallback(
+ (externalProps = {}) =>
+ mergeReactProps<'button'>(externalProps, {
+ role: 'radio',
+ type: 'button',
+ 'aria-checked': checked,
+ 'aria-required': required || undefined,
+ 'aria-disabled': disabled || undefined,
+ 'aria-readonly': readOnly || undefined,
+ onKeyDown(event) {
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ }
+ },
+ onClick(event) {
+ if (event.defaultPrevented || disabled || readOnly) {
+ return;
+ }
+
+ event.preventDefault();
+
+ inputRef.current?.click();
+ },
+ onFocus(event) {
+ if (event.defaultPrevented || disabled || readOnly || !touched) {
+ return;
+ }
+
+ inputRef.current?.click();
+
+ setTouched(false);
+ },
+ }),
+ [checked, disabled, readOnly, required, touched, setTouched],
+ );
+
+ const getInputProps: useRadioRoot.ReturnValue['getInputProps'] = React.useCallback(
+ (externalProps = {}) =>
+ mergeReactProps<'input'>(externalProps, {
+ type: 'radio',
+ ref: inputRef,
+ tabIndex: -1,
+ style: visuallyHidden,
+ 'aria-hidden': true,
+ disabled,
+ checked,
+ required,
+ readOnly,
+ onChange(event) {
+ // Workaround for https://github.com/facebook/react/issues/9023
+ if (event.nativeEvent.defaultPrevented) {
+ return;
+ }
+
+ if (disabled || readOnly || value == null) {
+ return;
+ }
+
+ setFieldTouched(true);
+ setDirty(value !== validityData.initialValue);
+ setCheckedValue(value);
+ onValueChange?.(value, event);
+ },
+ }),
+ [
+ disabled,
+ checked,
+ required,
+ readOnly,
+ value,
+ setFieldTouched,
+ setDirty,
+ validityData.initialValue,
+ setCheckedValue,
+ onValueChange,
+ ],
+ );
+
+ return React.useMemo(
+ () => ({
+ checked,
+ getRootProps,
+ getInputProps,
+ }),
+ [checked, getRootProps, getInputProps],
+ );
+}
+
+namespace useRadioRoot {
+ export interface Parameters {
+ value: unknown;
+ disabled?: boolean;
+ readOnly?: boolean;
+ required?: boolean;
+ }
+
+ export interface ReturnValue {
+ checked: boolean;
+ getRootProps: (
+ externalProps?: React.ComponentPropsWithRef<'button'>,
+ ) => React.ComponentPropsWithRef<'button'>;
+ getInputProps: (
+ externalProps?: React.ComponentPropsWithRef<'input'>,
+ ) => React.ComponentPropsWithRef<'input'>;
+ }
+}
diff --git a/packages/mui-base/src/Radio/index.barrel.ts b/packages/mui-base/src/Radio/index.barrel.ts
new file mode 100644
index 0000000000..dc4fbc653a
--- /dev/null
+++ b/packages/mui-base/src/Radio/index.barrel.ts
@@ -0,0 +1,2 @@
+export { RadioRoot } from './Root/RadioRoot';
+export { RadioIndicator } from './Indicator/RadioIndicator';
diff --git a/packages/mui-base/src/Radio/index.ts b/packages/mui-base/src/Radio/index.ts
new file mode 100644
index 0000000000..e5baf0747d
--- /dev/null
+++ b/packages/mui-base/src/Radio/index.ts
@@ -0,0 +1,2 @@
+export { RadioRoot as Root } from './Root/RadioRoot';
+export { RadioIndicator as Indicator } from './Indicator/RadioIndicator';
diff --git a/packages/mui-base/src/RadioGroup/Root/RadioGroupRoot.test.tsx b/packages/mui-base/src/RadioGroup/Root/RadioGroupRoot.test.tsx
new file mode 100644
index 0000000000..a1cafaf64e
--- /dev/null
+++ b/packages/mui-base/src/RadioGroup/Root/RadioGroupRoot.test.tsx
@@ -0,0 +1,296 @@
+import * as React from 'react';
+import * as RadioGroup from '@base_ui/react/RadioGroup';
+import * as Radio from '@base_ui/react/Radio';
+import { expect } from 'chai';
+import { spy } from 'sinon';
+import { createRenderer, act, screen, fireEvent } from '@mui/internal-test-utils';
+import userEvent from '@testing-library/user-event';
+import { describeConformance } from '../../../test/describeConformance';
+
+const isJSDOM = /jsdom/.test(window.navigator.userAgent);
+
+const user = userEvent.setup();
+
+describe(' ', () => {
+ const { render } = createRenderer();
+
+ describeConformance( , () => ({
+ refInstanceof: window.HTMLDivElement,
+ render,
+ }));
+
+ describe('extra props', () => {
+ it('can override the built-in attributes', () => {
+ const { container } = render( );
+ // eslint-disable-next-line testing-library/no-node-access
+ expect(container.firstElementChild as HTMLElement).to.have.attribute('role', 'switch');
+ });
+ });
+
+ it('should call onValueChange when an item is clicked', () => {
+ const handleChange = spy();
+ render(
+
+
+ ,
+ );
+
+ const item = screen.getByTestId('item');
+
+ fireEvent.click(item);
+
+ expect(handleChange.callCount).to.equal(1);
+ expect(handleChange.firstCall.args[0]).to.equal('a');
+ });
+
+ describe('prop: disabled', () => {
+ it('should have the `aria-disabled` attribute', () => {
+ render( );
+ expect(screen.getByRole('radiogroup')).to.have.attribute('aria-disabled', 'true');
+ });
+
+ it('should not have the aria attribute when `disabled` is not set', () => {
+ render( );
+ expect(screen.getByRole('radiogroup')).not.to.have.attribute('aria-disabled');
+ });
+
+ it('should not change its state when clicked', () => {
+ render(
+
+
+ ,
+ );
+
+ const item = screen.getByTestId('item');
+
+ expect(item).to.have.attribute('aria-checked', 'false');
+
+ fireEvent.click(item);
+
+ expect(item).to.have.attribute('aria-checked', 'false');
+ });
+ });
+
+ describe('prop: readOnly', () => {
+ it('should have the `aria-readonly` attribute', () => {
+ render( );
+ const group = screen.getByRole('radiogroup');
+ expect(group).to.have.attribute('aria-readonly', 'true');
+ });
+
+ it('should not have the aria attribute when `readOnly` is not set', () => {
+ render( );
+ const group = screen.getByRole('radiogroup');
+ expect(group).not.to.have.attribute('aria-readonly');
+ });
+
+ it('should not change its state when clicked', () => {
+ render(
+
+
+ ,
+ );
+
+ const item = screen.getByTestId('item');
+
+ expect(item).to.have.attribute('aria-checked', 'false');
+
+ fireEvent.click(item);
+
+ expect(item).to.have.attribute('aria-checked', 'false');
+ });
+ });
+
+ it('should update its state if the underlying input is toggled', () => {
+ render(
+
+
+ ,
+ );
+
+ const group = screen.getByTestId('root');
+ const item = screen.getByTestId('item');
+
+ // eslint-disable-next-line testing-library/no-node-access
+ const input = group.querySelector('input')!;
+
+ fireEvent.click(input);
+
+ expect(item).to.have.attribute('aria-checked', 'true');
+ });
+
+ it('should place the style hooks on the root and subcomponents', () => {
+ render(
+
+
+
+
+ ,
+ );
+
+ const root = screen.getByRole('radiogroup');
+ const item = screen.getByTestId('item');
+ const indicator = screen.getByTestId('indicator');
+
+ expect(root).to.have.attribute('data-disabled', 'true');
+ expect(root).to.have.attribute('data-readonly', 'true');
+ expect(root).to.have.attribute('data-required', 'true');
+
+ expect(item).to.have.attribute('data-radio', 'checked');
+ expect(item).to.have.attribute('data-disabled', 'true');
+ expect(item).to.have.attribute('data-readonly', 'true');
+ expect(item).to.have.attribute('data-required', 'true');
+
+ expect(indicator).to.have.attribute('data-radio', 'checked');
+ expect(indicator).to.have.attribute('data-disabled', 'true');
+ expect(indicator).to.have.attribute('data-readonly', 'true');
+ expect(indicator).to.have.attribute('data-required', 'true');
+ });
+
+ it('should set the name attribute on the input', () => {
+ render( );
+ const group = screen.getByRole('radiogroup');
+ // eslint-disable-next-line testing-library/no-node-access
+ expect(group.nextElementSibling).to.have.attribute('name', 'radio-group');
+ });
+
+ it('should include the radio value in the form submission', async function test() {
+ if (isJSDOM) {
+ // FormData is not available in JSDOM
+ this.skip();
+ }
+
+ let stringifiedFormData = '';
+
+ render(
+ ,
+ );
+
+ const [radioA] = screen.getAllByRole('radio');
+ const submitButton = screen.getByRole('button');
+
+ submitButton.click();
+
+ expect(stringifiedFormData).to.equal('group=');
+
+ await act(() => {
+ radioA.click();
+ });
+
+ submitButton.click();
+
+ expect(stringifiedFormData).to.equal('group=a');
+ });
+
+ it('should automatically select radio upon navigation', async () => {
+ render(
+
+
+
+ ,
+ );
+
+ const a = screen.getByTestId('a');
+ const b = screen.getByTestId('b');
+
+ act(() => {
+ a.focus();
+ });
+
+ expect(a).to.have.attribute('aria-checked', 'false');
+
+ await user.keyboard('{ArrowDown}');
+
+ expect(a).to.have.attribute('aria-checked', 'false');
+
+ expect(b).toHaveFocus();
+ expect(b).to.have.attribute('aria-checked', 'true');
+ });
+
+ it('should manage arrow key navigation', async () => {
+ render(
+
+
+
+
+
+
+
+
+
,
+ );
+
+ const a = screen.getByTestId('a');
+ const b = screen.getByTestId('b');
+ const c = screen.getByTestId('c');
+ const after = screen.getByTestId('after');
+
+ act(() => {
+ a.focus();
+ });
+
+ expect(a).toHaveFocus();
+
+ await user.keyboard('{ArrowDown}');
+
+ expect(b).toHaveFocus();
+
+ await user.keyboard('{ArrowDown}');
+
+ expect(c).toHaveFocus();
+
+ await user.keyboard('{ArrowDown}');
+
+ expect(a).toHaveFocus();
+
+ await user.keyboard('{ArrowUp}');
+
+ expect(c).toHaveFocus();
+
+ await user.keyboard('{ArrowUp}');
+
+ expect(b).toHaveFocus();
+
+ await user.keyboard('{ArrowUp}');
+
+ expect(a).toHaveFocus();
+
+ await user.keyboard('{ArrowLeft}');
+
+ expect(c).toHaveFocus();
+
+ await user.keyboard('{ArrowRight}');
+
+ expect(a).toHaveFocus();
+
+ await user.tab();
+
+ expect(after).toHaveFocus();
+
+ await user.tab({ shift: true });
+
+ expect(a).toHaveFocus();
+
+ await user.keyboard('{ArrowLeft}');
+
+ expect(c).toHaveFocus();
+
+ await user.tab({ shift: true });
+ await user.tab();
+
+ expect(c).toHaveFocus();
+ });
+});
diff --git a/packages/mui-base/src/RadioGroup/Root/RadioGroupRoot.tsx b/packages/mui-base/src/RadioGroup/Root/RadioGroupRoot.tsx
new file mode 100644
index 0000000000..2fc5100a3d
--- /dev/null
+++ b/packages/mui-base/src/RadioGroup/Root/RadioGroupRoot.tsx
@@ -0,0 +1,178 @@
+'use client';
+import * as React from 'react';
+import PropTypes from 'prop-types';
+import type { BaseUIComponentProps } from '../../utils/types';
+import { CompositeRoot } from '../../Composite/Root/CompositeRoot';
+import { useComponentRenderer } from '../../utils/useComponentRenderer';
+import { useEventCallback } from '../../utils/useEventCallback';
+import { useRadioGroupRoot } from './useRadioGroupRoot';
+import { RadioGroupRootContext } from './RadioGroupRootContext';
+import { useFieldRootContext } from '../../Field/Root/FieldRootContext';
+
+const RadioGroupRoot = React.forwardRef(function RadioGroupRoot(
+ props: RadioGroupRoot.Props,
+ forwardedRef: React.ForwardedRef,
+) {
+ const {
+ render,
+ className,
+ disabled: disabledProp,
+ readOnly,
+ required,
+ onValueChange: onValueChangeProp,
+ name,
+ ...otherProps
+ } = props;
+
+ const { getRootProps, getInputProps, checkedValue, setCheckedValue, touched, setTouched } =
+ useRadioGroupRoot(props);
+
+ const { ownerState: fieldOwnerState, disabled: fieldDisabled } = useFieldRootContext();
+
+ const disabled = fieldDisabled || disabledProp;
+
+ const onValueChange = useEventCallback(onValueChangeProp ?? (() => {}));
+
+ const ownerState: RadioGroupRoot.OwnerState = React.useMemo(
+ () => ({
+ ...fieldOwnerState,
+ disabled: disabled ?? false,
+ required: required ?? false,
+ readOnly: readOnly ?? false,
+ }),
+ [fieldOwnerState, disabled, readOnly, required],
+ );
+
+ const contextValue: RadioGroupRootContext = React.useMemo(
+ () => ({
+ checkedValue,
+ setCheckedValue,
+ onValueChange,
+ disabled,
+ readOnly,
+ required,
+ touched,
+ setTouched,
+ }),
+ [
+ checkedValue,
+ setCheckedValue,
+ onValueChange,
+ disabled,
+ readOnly,
+ required,
+ touched,
+ setTouched,
+ ],
+ );
+
+ const { renderElement } = useComponentRenderer({
+ propGetter: getRootProps,
+ render: render ?? 'div',
+ ref: forwardedRef,
+ className,
+ ownerState,
+ extraProps: otherProps,
+ });
+
+ return (
+
+
+
+
+ );
+});
+
+namespace RadioGroupRoot {
+ export interface OwnerState {
+ disabled: boolean | undefined;
+ readOnly: boolean | undefined;
+ }
+
+ export interface Props
+ extends Omit, 'value' | 'defaultValue'> {
+ /**
+ * Determines if the radio group is disabled.
+ * @default false
+ */
+ disabled?: boolean;
+ /**
+ * Determines if the radio group is readonly.
+ * @default false
+ */
+ readOnly?: boolean;
+ /**
+ * Determines if the radio group is required.
+ * @default false
+ */
+ required?: boolean;
+ /**
+ * The name of the radio group submitted with the form data.
+ */
+ name?: string;
+ /**
+ * The value of the selected radio button. Use when controlled.
+ */
+ value?: unknown;
+ /**
+ * The default value of the selected radio button. Use when uncontrolled.
+ */
+ defaultValue?: unknown;
+ /**
+ * Callback fired when the value changes.
+ */
+ onValueChange?: (value: unknown, event: React.ChangeEvent) => void;
+ }
+}
+
+RadioGroupRoot.propTypes /* remove-proptypes */ = {
+ // ┌────────────────────────────── Warning ──────────────────────────────┐
+ // │ These PropTypes are generated from the TypeScript type definitions. │
+ // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
+ // └─────────────────────────────────────────────────────────────────────┘
+ /**
+ * @ignore
+ */
+ children: PropTypes.node,
+ /**
+ * Class names applied to the element or a function that returns them based on the component's state.
+ */
+ className: PropTypes.oneOfType([PropTypes.func, PropTypes.string]),
+ /**
+ * The default value of the selected radio button. Use when uncontrolled.
+ */
+ defaultValue: PropTypes.any,
+ /**
+ * Determines if the radio group is disabled.
+ * @default false
+ */
+ disabled: PropTypes.bool,
+ /**
+ * The name of the radio group submitted with the form data.
+ */
+ name: PropTypes.string,
+ /**
+ * Callback fired when the value changes.
+ */
+ onValueChange: PropTypes.func,
+ /**
+ * Determines if the radio group is readonly.
+ * @default false
+ */
+ readOnly: PropTypes.bool,
+ /**
+ * A function to customize rendering of the component.
+ */
+ render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]),
+ /**
+ * Determines if the radio group is required.
+ * @default false
+ */
+ required: PropTypes.bool,
+ /**
+ * The value of the selected radio button. Use when controlled.
+ */
+ value: PropTypes.any,
+} as any;
+
+export { RadioGroupRoot };
diff --git a/packages/mui-base/src/RadioGroup/Root/RadioGroupRootContext.ts b/packages/mui-base/src/RadioGroup/Root/RadioGroupRootContext.ts
new file mode 100644
index 0000000000..2aef2556ed
--- /dev/null
+++ b/packages/mui-base/src/RadioGroup/Root/RadioGroupRootContext.ts
@@ -0,0 +1,29 @@
+'use client';
+import * as React from 'react';
+import { NOOP } from '../../utils/noop';
+
+export interface RadioGroupRootContext {
+ disabled: boolean | undefined;
+ readOnly: boolean | undefined;
+ required: boolean | undefined;
+ checkedValue: unknown;
+ setCheckedValue: React.Dispatch>;
+ onValueChange: (value: unknown, event: React.ChangeEvent) => void;
+ touched: boolean;
+ setTouched: React.Dispatch>;
+}
+
+export const RadioGroupRootContext = React.createContext({
+ disabled: undefined,
+ readOnly: undefined,
+ required: undefined,
+ checkedValue: '',
+ setCheckedValue: NOOP,
+ onValueChange: NOOP,
+ touched: false,
+ setTouched: NOOP,
+});
+
+export function useRadioGroupRootContext() {
+ return React.useContext(RadioGroupRootContext);
+}
diff --git a/packages/mui-base/src/RadioGroup/Root/useRadioGroupRoot.ts b/packages/mui-base/src/RadioGroup/Root/useRadioGroupRoot.ts
new file mode 100644
index 0000000000..667da5542c
--- /dev/null
+++ b/packages/mui-base/src/RadioGroup/Root/useRadioGroupRoot.ts
@@ -0,0 +1,147 @@
+import * as React from 'react';
+import { contains } from '@floating-ui/react/utils';
+import { mergeReactProps } from '../../utils/mergeReactProps';
+import { useControlled } from '../../utils/useControlled';
+import { useFieldRootContext } from '../../Field/Root/FieldRootContext';
+import { useEnhancedEffect } from '../../utils/useEnhancedEffect';
+import { useId } from '../../utils/useId';
+import { useFieldControlValidation } from '../../Field/Control/useFieldControlValidation';
+
+/**
+ *
+ * API:
+ *
+ * - [useRadioGroupRoot API](https://mui.com/base-ui/api/use-radio-group-root/)
+ */
+export function useRadioGroupRoot(params: useRadioGroupRoot.Parameters) {
+ const { disabled = false, name, defaultValue, readOnly, value: externalValue } = params;
+
+ const {
+ labelId,
+ setDisabled,
+ setControlId,
+ setTouched: setFieldTouched,
+ validityData,
+ setValidityData,
+ } = useFieldRootContext();
+
+ const {
+ getValidationProps,
+ getInputValidationProps,
+ inputRef: inputValidationRef,
+ commitValidation,
+ } = useFieldControlValidation();
+
+ useEnhancedEffect(() => {
+ setDisabled(disabled);
+ }, [disabled, setDisabled]);
+
+ const id = useId();
+
+ useEnhancedEffect(() => {
+ setControlId(id);
+ return () => {
+ setControlId(undefined);
+ };
+ }, [id, setControlId]);
+
+ const [checkedValue, setCheckedValue] = useControlled({
+ controlled: externalValue,
+ default: defaultValue,
+ name: 'RadioGroup',
+ state: 'value',
+ });
+
+ useEnhancedEffect(() => {
+ if (validityData.initialValue === null && checkedValue !== validityData.initialValue) {
+ setValidityData((prev) => ({ ...prev, initialValue: checkedValue }));
+ }
+ }, [checkedValue, setValidityData, validityData.initialValue]);
+
+ const [touched, setTouched] = React.useState(false);
+
+ const getRootProps = React.useCallback(
+ (externalProps = {}) =>
+ mergeReactProps<'div'>(getValidationProps(externalProps), {
+ role: 'radiogroup',
+ 'aria-disabled': disabled || undefined,
+ 'aria-readonly': readOnly || undefined,
+ 'aria-labelledby': labelId,
+ onBlur(event) {
+ if (!contains(event.currentTarget, event.relatedTarget)) {
+ setFieldTouched(true);
+ commitValidation(checkedValue);
+ }
+ },
+ onKeyDownCapture(event) {
+ if (event.key.startsWith('Arrow')) {
+ setFieldTouched(true);
+ setTouched(true);
+ }
+ },
+ }),
+ [
+ checkedValue,
+ commitValidation,
+ disabled,
+ getValidationProps,
+ labelId,
+ readOnly,
+ setFieldTouched,
+ ],
+ );
+
+ const serializedCheckedValue = React.useMemo(() => {
+ if (checkedValue == null) {
+ return ''; // avoid uncontrolled -> controlled error
+ }
+ if (typeof checkedValue === 'string') {
+ return checkedValue;
+ }
+ return JSON.stringify(checkedValue);
+ }, [checkedValue]);
+
+ const getInputProps = React.useCallback(
+ (externalProps = {}) =>
+ mergeReactProps(getInputValidationProps(externalProps), {
+ type: 'hidden',
+ value: serializedCheckedValue,
+ ref: inputValidationRef,
+ id,
+ name,
+ disabled,
+ readOnly,
+ }),
+ [
+ getInputValidationProps,
+ serializedCheckedValue,
+ inputValidationRef,
+ id,
+ name,
+ disabled,
+ readOnly,
+ ],
+ );
+
+ return React.useMemo(
+ () => ({
+ getRootProps,
+ getInputProps,
+ checkedValue,
+ setCheckedValue,
+ touched,
+ setTouched,
+ }),
+ [getRootProps, getInputProps, checkedValue, setCheckedValue, touched],
+ );
+}
+
+namespace useRadioGroupRoot {
+ export interface Parameters {
+ name?: string;
+ disabled?: boolean;
+ readOnly?: boolean;
+ defaultValue?: unknown;
+ value?: unknown;
+ }
+}
diff --git a/packages/mui-base/src/RadioGroup/index.barrel.ts b/packages/mui-base/src/RadioGroup/index.barrel.ts
new file mode 100644
index 0000000000..3d7b2c9369
--- /dev/null
+++ b/packages/mui-base/src/RadioGroup/index.barrel.ts
@@ -0,0 +1 @@
+export * from './Root/RadioGroupRoot';
diff --git a/packages/mui-base/src/RadioGroup/index.ts b/packages/mui-base/src/RadioGroup/index.ts
new file mode 100644
index 0000000000..f78d654570
--- /dev/null
+++ b/packages/mui-base/src/RadioGroup/index.ts
@@ -0,0 +1 @@
+export { RadioGroupRoot as Root } from './Root/RadioGroupRoot';
diff --git a/packages/mui-base/src/utils/noop.ts b/packages/mui-base/src/utils/noop.ts
new file mode 100644
index 0000000000..7da8f11910
--- /dev/null
+++ b/packages/mui-base/src/utils/noop.ts
@@ -0,0 +1 @@
+export const NOOP = () => {};