diff --git a/docs/data/api/toggle-button-group-item.json b/docs/data/api/toggle-button-group-item.json
new file mode 100644
index 000000000..c51f3924c
--- /dev/null
+++ b/docs/data/api/toggle-button-group-item.json
@@ -0,0 +1,22 @@
+{
+ "props": {
+ "value": { "type": { "name": "any" }, "required": true },
+ "aria-label": { "type": { "name": "string" } },
+ "aria-labelledby": { "type": { "name": "string" } },
+ "className": { "type": { "name": "union", "description": "func | string" } },
+ "render": { "type": { "name": "union", "description": "element | func" } }
+ },
+ "name": "ToggleButtonGroupItem",
+ "imports": [
+ "import { ToggleButtonGroup } from '@base_ui/react/ToggleButtonGroup';\nconst ToggleButtonGroupItem = ToggleButtonGroup.Item;"
+ ],
+ "classes": [],
+ "spread": true,
+ "themeDefaultProps": true,
+ "muiName": "ToggleButtonGroupItem",
+ "forwardsRefTo": "HTMLButtonElement",
+ "filename": "/packages/mui-base/src/ToggleButtonGroup/Item/ToggleButtonGroupItem.tsx",
+ "inheritance": null,
+ "demos": "
",
+ "cssComponent": false
+}
diff --git a/docs/data/api/toggle-button-group-root.json b/docs/data/api/toggle-button-group-root.json
new file mode 100644
index 000000000..a3b4e1779
--- /dev/null
+++ b/docs/data/api/toggle-button-group-root.json
@@ -0,0 +1,20 @@
+{
+ "props": {
+ "className": { "type": { "name": "union", "description": "func | string" } },
+ "render": { "type": { "name": "union", "description": "element | func" } },
+ "toggleMultiple": { "type": { "name": "bool" }, "default": "false" }
+ },
+ "name": "ToggleButtonGroupRoot",
+ "imports": [
+ "import { ToggleButtonGroup } from '@base_ui/react/ToggleButtonGroup';\nconst ToggleButtonGroupRoot = ToggleButtonGroup.Root;"
+ ],
+ "classes": [],
+ "spread": true,
+ "themeDefaultProps": true,
+ "muiName": "ToggleButtonGroupRoot",
+ "forwardsRefTo": "HTMLDivElement",
+ "filename": "/packages/mui-base/src/ToggleButtonGroup/Root/ToggleButtonGroupRoot.tsx",
+ "inheritance": null,
+ "demos": "",
+ "cssComponent": false
+}
diff --git a/docs/data/components/toggle-button-group/toggle-button-group.mdx b/docs/data/components/toggle-button-group/toggle-button-group.mdx
new file mode 100644
index 000000000..4ac20f224
--- /dev/null
+++ b/docs/data/components/toggle-button-group/toggle-button-group.mdx
@@ -0,0 +1,19 @@
+---
+productId: base-ui
+title: React ToggleButtonGroup component
+description: ToggleButtonGroup provides a set of two-state buttons that can either be off (not pressed) or on (pressed).
+components: ToggleButtonGroupRoot, ToggleButtonGroupItem
+githubLabel: 'component: toggle button'
+waiAria: https://www.w3.org/WAI/ARIA/apg/patterns/radio-group/
+packageName: '@base_ui/react'
+---
+
+# ToggleButtonGroup
+
+
+
+
+
+## Installation
+
+
diff --git a/docs/data/translations/api-docs/toggle-button-group-item/toggle-button-group-item.json b/docs/data/translations/api-docs/toggle-button-group-item/toggle-button-group-item.json
new file mode 100644
index 000000000..06337967b
--- /dev/null
+++ b/docs/data/translations/api-docs/toggle-button-group-item/toggle-button-group-item.json
@@ -0,0 +1,17 @@
+{
+ "componentDescription": "",
+ "propDescriptions": {
+ "aria-label": { "description": "The label for the toggle button." },
+ "aria-labelledby": {
+ "description": "An id or space-separated list of ids of elements that label the toggle button."
+ },
+ "className": {
+ "description": "Class names applied to the element or a function that returns them based on the component's state."
+ },
+ "render": { "description": "A function to customize rendering of the component." },
+ "value": {
+ "description": "A unique value that identifies the component when used inside a ToggleButtonGroup"
+ }
+ },
+ "classDescriptions": {}
+}
diff --git a/docs/data/translations/api-docs/toggle-button-group-root/toggle-button-group-root.json b/docs/data/translations/api-docs/toggle-button-group-root/toggle-button-group-root.json
new file mode 100644
index 000000000..c94c564c7
--- /dev/null
+++ b/docs/data/translations/api-docs/toggle-button-group-root/toggle-button-group-root.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."
+ },
+ "render": { "description": "A function to customize rendering of the component." },
+ "toggleMultiple": {
+ "description": "When false
only one ToggleButton in the group can be pressed. When a ToggleButton is pressed, the others in the group will become unpressed"
+ }
+ },
+ "classDescriptions": {}
+}
diff --git a/docs/src/app/experiments/toggle-group.tsx b/docs/src/app/experiments/toggle-group.tsx
new file mode 100644
index 000000000..666736b7f
--- /dev/null
+++ b/docs/src/app/experiments/toggle-group.tsx
@@ -0,0 +1,132 @@
+'use client';
+import * as React from 'react';
+import { ToggleButtonGroup } from '@base_ui/react/ToggleButtonGroup';
+import classes from './toggle.module.css';
+
+export default function ToggleButtonGroupDemo() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/docs/src/app/experiments/toggle.module.css b/docs/src/app/experiments/toggle.module.css
new file mode 100644
index 000000000..2f0795dea
--- /dev/null
+++ b/docs/src/app/experiments/toggle.module.css
@@ -0,0 +1,61 @@
+.grid {
+ display: grid;
+ grid-gap: 2rem;
+}
+
+.group {
+ display: flex;
+}
+
+.button {
+ --size: 2.5rem;
+ --corner: 0.4rem;
+ --border-color: var(--gray-outline-2);
+
+ display: flex;
+ flex-flow: row nowrap;
+ justify-content: center;
+ align-items: center;
+ height: var(--size);
+ width: var(--size);
+ border: 1px solid var(--border-color);
+ background-color: var(--gray-container-2);
+ color: var(--gray-400);
+}
+
+.button:first-child {
+ border-radius: var(--corner) 0 0 var(--corner);
+ border-right-color: transparent;
+}
+
+.button:last-child {
+ border-radius: 0 var(--corner) var(--corner) 0;
+ border-left-color: transparent;
+}
+
+.button:hover {
+ background-color: var(--gray-surface-1);
+ outline: 1px solid var(--gray-500);
+ outline-offset: -1px;
+ color: var(--gray-text-2);
+ cursor: pointer;
+ z-index: 1;
+}
+
+.button:focus-visible {
+ outline: 2px solid var(--gray-900);
+ z-index: 1;
+}
+
+.icon {
+ fill: none;
+ stroke: currentColor;
+ stroke-width: 2;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+}
+
+.button[data-pressed] {
+ background-color: #fefefe;
+ color: var(--gray-text-2);
+}
diff --git a/docs/src/app/experiments/toggle.tsx b/docs/src/app/experiments/toggle.tsx
deleted file mode 100644
index 08ff006ac..000000000
--- a/docs/src/app/experiments/toggle.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-'use client';
-import * as React from 'react';
-import { ToggleButton } from '@base_ui/react/ToggleButton';
-
-export default function ToggleButtonDemo() {
- const [pressed, setPressed] = React.useState(true);
- return (
-
- );
-}
diff --git a/packages/mui-base/src/ToggleButton/Root/ToggleButtonRoot.tsx b/packages/mui-base/src/ToggleButton/Root/ToggleButtonRoot.tsx
index 5a8a37800..26b3638d5 100644
--- a/packages/mui-base/src/ToggleButton/Root/ToggleButtonRoot.tsx
+++ b/packages/mui-base/src/ToggleButton/Root/ToggleButtonRoot.tsx
@@ -30,6 +30,8 @@ const ToggleButtonRoot = React.forwardRef(function ToggleButtonRoot(
onPressedChange,
className,
render,
+ type,
+ form,
...otherProps
} = props;
@@ -62,31 +64,6 @@ const ToggleButtonRoot = React.forwardRef(function ToggleButtonRoot(
return renderElement();
});
-export { ToggleButtonRoot };
-
-export namespace ToggleButtonRoot {
- export interface OwnerState {
- pressed: boolean;
- disabled: boolean;
- }
-
- export interface Props
- extends Pick<
- useToggleButtonRoot.Parameters,
- 'pressed' | 'defaultPressed' | 'disabled' | 'onPressedChange'
- >,
- BaseUIComponentProps<'button', OwnerState> {
- /**
- * The label for the ToggleButton.
- */
- 'aria-label'?: React.AriaAttributes['aria-label'];
- /**
- * An id or space-separated list of ids of elements that label the ToggleButton.
- */
- 'aria-labelledby'?: React.AriaAttributes['aria-labelledby'];
- }
-}
-
ToggleButtonRoot.propTypes /* remove-proptypes */ = {
// ┌────────────────────────────── Warning ──────────────────────────────┐
// │ These PropTypes are generated from the TypeScript type definitions. │
@@ -120,6 +97,10 @@ ToggleButtonRoot.propTypes /* remove-proptypes */ = {
* @default false
*/
disabled: PropTypes.bool,
+ /**
+ * @ignore
+ */
+ form: PropTypes.string,
/**
* Callback fired when the pressed state is changed.
*
@@ -137,4 +118,33 @@ ToggleButtonRoot.propTypes /* remove-proptypes */ = {
* A function to customize rendering of the component.
*/
render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]),
+ /**
+ * @ignore
+ */
+ type: PropTypes.oneOf(['button', 'reset', 'submit']),
} as any;
+
+export { ToggleButtonRoot };
+
+export namespace ToggleButtonRoot {
+ export interface OwnerState {
+ pressed: boolean;
+ disabled: boolean;
+ }
+
+ export interface Props
+ extends Pick<
+ useToggleButtonRoot.Parameters,
+ 'pressed' | 'defaultPressed' | 'disabled' | 'onPressedChange'
+ >,
+ BaseUIComponentProps<'button', OwnerState> {
+ /**
+ * The label for the ToggleButton.
+ */
+ 'aria-label'?: React.AriaAttributes['aria-label'];
+ /**
+ * An id or space-separated list of ids of elements that label the ToggleButton.
+ */
+ 'aria-labelledby'?: React.AriaAttributes['aria-labelledby'];
+ }
+}
diff --git a/packages/mui-base/src/ToggleButtonGroup/Item/ToggleButtonGroupItem.test.tsx b/packages/mui-base/src/ToggleButtonGroup/Item/ToggleButtonGroupItem.test.tsx
new file mode 100644
index 000000000..963d379bc
--- /dev/null
+++ b/packages/mui-base/src/ToggleButtonGroup/Item/ToggleButtonGroupItem.test.tsx
@@ -0,0 +1,27 @@
+import * as React from 'react';
+// import { expect } from 'chai';
+// import { spy } from 'sinon';
+// import { act } from '@mui/internal-test-utils';
+import { ToggleButtonGroup } from '@base_ui/react/ToggleButtonGroup';
+import { createRenderer, describeConformance } from '#test-utils';
+import { NOOP } from '../../utils/noop';
+import { ToggleButtonGroupRootContext } from '../Root/ToggleButtonGroupRootContext';
+
+const contextValue: ToggleButtonGroupRootContext = {
+ value: [],
+ setGroupValue: NOOP,
+};
+
+describe(' ', () => {
+ const { render } = createRenderer();
+
+ describeConformance( , () => ({
+ refInstanceof: window.HTMLButtonElement,
+ render: (node) =>
+ render(
+
+ {node}
+ ,
+ ),
+ }));
+});
diff --git a/packages/mui-base/src/ToggleButtonGroup/Item/ToggleButtonGroupItem.tsx b/packages/mui-base/src/ToggleButtonGroup/Item/ToggleButtonGroupItem.tsx
new file mode 100644
index 000000000..cb834dc25
--- /dev/null
+++ b/packages/mui-base/src/ToggleButtonGroup/Item/ToggleButtonGroupItem.tsx
@@ -0,0 +1,131 @@
+'use client';
+import * as React from 'react';
+import PropTypes from 'prop-types';
+import { useComponentRenderer } from '../../utils/useComponentRenderer';
+import type { BaseUIComponentProps } from '../../utils/types';
+import { useToggleButtonGroupRootContext } from '../Root/ToggleButtonGroupRootContext';
+import { useToggleButtonGroupItem } from './useToggleButtonGroupItem';
+
+const customStyleHookMapping = {
+ disabled: () => null,
+};
+/**
+ *
+ * Demos:
+ *
+ * - [ToggleButtonGroup](https://base-ui.netlify.app/components/react-toggle-button-group/)
+ *
+ * API:
+ *
+ * - [ToggleButtonGroupItem API](https://base-ui.netlify.app/components/react-toggle-button-group/#api-reference-ToggleButtonGroupItem)
+ */
+const ToggleButtonGroupItem = React.forwardRef(function ToggleButtonGroupItem(
+ props: ToggleButtonGroupItem.Props,
+ forwardedRef: React.ForwardedRef,
+) {
+ const {
+ value,
+ disabled: disabledProp,
+ className,
+ render,
+ type, // cannot change button type
+ form, // never participates in form validation
+ ...otherProps
+ } = props;
+
+ const { value: groupValue, setGroupValue } = useToggleButtonGroupRootContext();
+
+ const { disabled, pressed, getRootProps } = useToggleButtonGroupItem({
+ value,
+ groupValue,
+ setGroupValue,
+ disabled: disabledProp,
+ itemRef: forwardedRef,
+ });
+
+ const ownerState: ToggleButtonGroupItem.OwnerState = React.useMemo(
+ () => ({
+ disabled,
+ pressed,
+ }),
+ [disabled, pressed],
+ );
+
+ const { renderElement } = useComponentRenderer({
+ propGetter: getRootProps,
+ render: render ?? 'button',
+ ref: forwardedRef,
+ ownerState,
+ className,
+ customStyleHookMapping,
+ extraProps: otherProps,
+ });
+
+ return renderElement();
+});
+
+export { ToggleButtonGroupItem };
+
+export namespace ToggleButtonGroupItem {
+ export interface OwnerState {
+ pressed: boolean;
+ disabled: boolean;
+ }
+
+ export interface Props
+ extends Pick,
+ Omit, 'value'> {
+ /**
+ * The label for the toggle button.
+ */
+ 'aria-label'?: React.AriaAttributes['aria-label'];
+ /**
+ * An id or space-separated list of ids of elements that label the toggle button.
+ */
+ 'aria-labelledby'?: React.AriaAttributes['aria-labelledby'];
+ }
+}
+
+ToggleButtonGroupItem.propTypes /* remove-proptypes */ = {
+ // ┌────────────────────────────── Warning ──────────────────────────────┐
+ // │ These PropTypes are generated from the TypeScript type definitions. │
+ // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
+ // └─────────────────────────────────────────────────────────────────────┘
+ /**
+ * The label for the toggle button.
+ */
+ 'aria-label': PropTypes.string,
+ /**
+ * An id or space-separated list of ids of elements that label the toggle button.
+ */
+ 'aria-labelledby': PropTypes.string,
+ /**
+ * @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
+ */
+ disabled: PropTypes.bool,
+ /**
+ * @ignore
+ */
+ form: PropTypes.string,
+ /**
+ * A function to customize rendering of the component.
+ */
+ render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]),
+ /**
+ * @ignore
+ */
+ type: PropTypes.oneOf(['button', 'reset', 'submit']),
+ /**
+ * A unique value that identifies the component when used
+ * inside a ToggleButtonGroup
+ */
+ value: PropTypes.any.isRequired,
+} as any;
diff --git a/packages/mui-base/src/ToggleButtonGroup/Item/useToggleButtonGroupItem.ts b/packages/mui-base/src/ToggleButtonGroup/Item/useToggleButtonGroupItem.ts
new file mode 100644
index 000000000..46c6413d3
--- /dev/null
+++ b/packages/mui-base/src/ToggleButtonGroup/Item/useToggleButtonGroupItem.ts
@@ -0,0 +1,73 @@
+'use client';
+import * as React from 'react';
+import { mergeReactProps } from '../../utils/mergeReactProps';
+import { GenericHTMLProps } from '../../utils/types';
+import { useEventCallback } from '../../utils/useEventCallback';
+import { useToggleButtonRoot } from '../../ToggleButton/Root/useToggleButtonRoot';
+
+export function useToggleButtonGroupItem(
+ parameters: useToggleButtonGroupItem.Parameters,
+): useToggleButtonGroupItem.ReturnValue {
+ const {
+ value,
+ groupValue,
+ setGroupValue,
+ disabled: disabledParam,
+ itemRef: externalRef,
+ } = parameters;
+
+ const isPressed = groupValue?.indexOf(value) > -1;
+
+ const handlePressedChange = useEventCallback((nextPressed: boolean, event: Event) => {
+ setGroupValue(value, nextPressed, event);
+ });
+
+ const {
+ pressed,
+ disabled,
+ getRootProps: getToggleButtonProps,
+ } = useToggleButtonRoot({
+ pressed: isPressed,
+ disabled: disabledParam,
+ onPressedChange: handlePressedChange,
+ buttonRef: externalRef,
+ });
+
+ const getRootProps = React.useCallback(
+ (externalProps?: GenericHTMLProps): GenericHTMLProps => {
+ return mergeReactProps(externalProps, {}, getToggleButtonProps());
+ },
+ [getToggleButtonProps],
+ );
+
+ return React.useMemo(
+ () => ({
+ getRootProps,
+ disabled,
+ pressed,
+ }),
+ [getRootProps, disabled, pressed],
+ );
+}
+
+export namespace useToggleButtonGroupItem {
+ export interface Parameters extends Pick {
+ itemRef?: React.Ref;
+ /**
+ * A unique value that identifies the component when used
+ * inside a ToggleButtonGroup
+ */
+ value: unknown;
+ /**
+ * The value of the ToggleButtonGroup represented by an array of values
+ * of the items that are pressed
+ */
+ groupValue: unknown[];
+ /**
+ *
+ */
+ setGroupValue: (newValue: unknown, nextPressed: boolean, event: Event) => void;
+ }
+
+ export interface ReturnValue extends useToggleButtonRoot.ReturnValue {}
+}
diff --git a/packages/mui-base/src/ToggleButtonGroup/Root/ToggleButtonGroupRoot.test.tsx b/packages/mui-base/src/ToggleButtonGroup/Root/ToggleButtonGroupRoot.test.tsx
new file mode 100644
index 000000000..82270a18a
--- /dev/null
+++ b/packages/mui-base/src/ToggleButtonGroup/Root/ToggleButtonGroupRoot.test.tsx
@@ -0,0 +1,129 @@
+import * as React from 'react';
+import { expect } from 'chai';
+// import { spy } from 'sinon';
+import { flushMicrotasks } from '@mui/internal-test-utils';
+import { ToggleButtonGroup } from '@base_ui/react/ToggleButtonGroup';
+import { createRenderer, describeConformance } from '#test-utils';
+
+describe(' ', () => {
+ const { render } = createRenderer();
+
+ describeConformance( , () => ({
+ refInstanceof: window.HTMLDivElement,
+ render,
+ }));
+
+ it('renders a `group`', async () => {
+ const { queryByRole } = await render( );
+
+ expect(queryByRole('group', { name: 'My Toggle Group' })).not.to.equal(null);
+ });
+
+ describe('uncontrolled', () => {
+ it('pressed state', async function test(t = {}) {
+ if (/jsdom/.test(window.navigator.userAgent)) {
+ // @ts-expect-error to support mocha and vitest
+ // eslint-disable-next-line @typescript-eslint/no-unused-expressions
+ this?.skip?.() || t?.skip();
+ }
+
+ const { getAllByRole, user } = await render(
+
+
+
+ ,
+ );
+
+ const [button1, button2] = getAllByRole('button');
+
+ expect(button1).to.have.attribute('aria-pressed', 'false');
+ expect(button2).to.have.attribute('aria-pressed', 'false');
+
+ await user.pointer({ keys: '[MouseLeft]', target: button1 });
+
+ expect(button1).to.have.attribute('aria-pressed', 'true');
+ expect(button1).to.have.attribute('data-pressed');
+ expect(button2).to.have.attribute('aria-pressed', 'false');
+
+ await user.pointer({ keys: '[MouseLeft]', target: button2 });
+
+ expect(button2).to.have.attribute('aria-pressed', 'true');
+ expect(button2).to.have.attribute('data-pressed');
+ expect(button1).to.have.attribute('aria-pressed', 'false');
+ });
+
+ it('prop: defaultValue', async () => {
+ const { getAllByRole, user } = await render(
+
+
+
+ ,
+ );
+
+ const [button1, button2] = getAllByRole('button');
+
+ expect(button2).to.have.attribute('aria-pressed', 'true');
+ expect(button2).to.have.attribute('data-pressed');
+ expect(button1).to.have.attribute('aria-pressed', 'false');
+
+ await user.pointer({ keys: '[MouseLeft]', target: button1 });
+
+ expect(button1).to.have.attribute('aria-pressed', 'true');
+ expect(button1).to.have.attribute('data-pressed');
+ expect(button2).to.have.attribute('aria-pressed', 'false');
+ });
+ });
+
+ describe('controlled', () => {
+ it('pressed state', async () => {
+ const { getAllByRole, setProps } = await render(
+
+
+
+ ,
+ );
+
+ const [button1, button2] = getAllByRole('button');
+
+ expect(button1).to.have.attribute('aria-pressed', 'false');
+ expect(button2).to.have.attribute('aria-pressed', 'true');
+ expect(button2).to.have.attribute('data-pressed');
+
+ setProps({ value: ['one'] });
+ await flushMicrotasks();
+
+ expect(button1).to.have.attribute('aria-pressed', 'true');
+ expect(button1).to.have.attribute('data-pressed');
+ expect(button2).to.have.attribute('aria-pressed', 'false');
+
+ setProps({ value: ['two'] });
+ await flushMicrotasks();
+
+ expect(button2).to.have.attribute('aria-pressed', 'true');
+ expect(button2).to.have.attribute('data-pressed');
+ expect(button1).to.have.attribute('aria-pressed', 'false');
+ });
+
+ it('prop: value', async () => {
+ const { getAllByRole, setProps } = await render(
+
+
+
+ ,
+ );
+
+ const [button1, button2] = getAllByRole('button');
+
+ expect(button2).to.have.attribute('aria-pressed', 'true');
+ expect(button2).to.have.attribute('data-pressed');
+ expect(button1).to.have.attribute('aria-pressed', 'false');
+
+ setProps({ value: ['one'] });
+ await flushMicrotasks();
+
+ expect(button1).to.have.attribute('aria-pressed', 'true');
+ expect(button1).to.have.attribute('data-pressed');
+ expect(button2).to.have.attribute('aria-pressed', 'false');
+ });
+ });
+});
diff --git a/packages/mui-base/src/ToggleButtonGroup/Root/ToggleButtonGroupRoot.tsx b/packages/mui-base/src/ToggleButtonGroup/Root/ToggleButtonGroupRoot.tsx
new file mode 100644
index 000000000..c0ed06c74
--- /dev/null
+++ b/packages/mui-base/src/ToggleButtonGroup/Root/ToggleButtonGroupRoot.tsx
@@ -0,0 +1,145 @@
+'use client';
+import * as React from 'react';
+import PropTypes from 'prop-types';
+import { useComponentRenderer } from '../../utils/useComponentRenderer';
+import type { BaseUIComponentProps } from '../../utils/types';
+import {
+ useToggleButtonGroupRoot,
+ type UseToggleButtonGroupRoot,
+} from './useToggleButtonGroupRoot';
+import { ToggleButtonGroupRootContext } from './ToggleButtonGroupRootContext';
+
+const customStyleHookMapping = {
+ multiple(value: boolean) {
+ if (value) {
+ return { 'data-multiple': '' } as Record;
+ }
+ return null;
+ },
+};
+/**
+ *
+ * Demos:
+ *
+ * - [ToggleButtonGroup](https://base-ui.netlify.app/components/react-toggle-button-group/)
+ *
+ * API:
+ *
+ * - [ToggleButtonGroupRoot API](https://base-ui.netlify.app/components/react-toggle-button-group/#api-reference-ToggleButtonGroupRoot)
+ */
+const ToggleButtonGroupRoot = React.forwardRef(function ToggleButtonGroupRoot(
+ props: ToggleButtonGroupRoot.Props,
+ forwardedRef: React.ForwardedRef,
+) {
+ const {
+ defaultValue: defaultValueProp,
+ value: valueProp,
+ disabled: disabledProp,
+ toggleMultiple = false,
+ className,
+ render,
+ ...otherProps
+ } = props;
+
+ const defaultValue = React.useMemo(() => {
+ if (valueProp === undefined) {
+ return defaultValueProp ?? [];
+ }
+
+ return undefined;
+ }, [valueProp, defaultValueProp]);
+
+ const { getRootProps, disabled, setGroupValue, value } = useToggleButtonGroupRoot({
+ value: valueProp,
+ defaultValue,
+ disabled: disabledProp,
+ toggleMultiple,
+ });
+
+ const ownerState: ToggleButtonGroupRoot.OwnerState = React.useMemo(
+ () => ({ disabled, multiple: toggleMultiple }),
+ [disabled, toggleMultiple],
+ );
+
+ const contextValue: ToggleButtonGroupRootContext = React.useMemo(
+ () => ({
+ setGroupValue,
+ value,
+ }),
+ [value, setGroupValue],
+ );
+
+ const { renderElement } = useComponentRenderer({
+ propGetter: getRootProps,
+ render: render ?? 'div',
+ ref: forwardedRef,
+ ownerState,
+ className,
+ customStyleHookMapping,
+ extraProps: otherProps,
+ });
+
+ return (
+
+ {renderElement()}
+
+ );
+});
+
+ToggleButtonGroupRoot.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]),
+ /**
+ * @ignore
+ */
+ defaultValue: PropTypes.array,
+ /**
+ * @default false
+ */
+ disabled: PropTypes.bool,
+ /**
+ * A function to customize rendering of the component.
+ */
+ render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]),
+ /**
+ * When `false` only one ToggleButton in the group can be pressed.
+ * When a ToggleButton is pressed, the others in the group will become unpressed
+ * @default false
+ */
+ toggleMultiple: PropTypes.bool,
+ /**
+ * @ignore
+ */
+ value: PropTypes.array,
+} as any;
+
+export { ToggleButtonGroupRoot };
+
+export namespace ToggleButtonGroupRoot {
+ export interface OwnerState {
+ disabled: boolean;
+ multiple: boolean;
+ }
+
+ export interface Props
+ extends Pick<
+ UseToggleButtonGroupRoot.Parameters,
+ 'value' | 'defaultValue' | 'onValueChange' | 'disabled' | 'toggleMultiple'
+ >,
+ Omit, 'defaultValue'> {
+ /**
+ * @default false
+ */
+ disabled?: boolean;
+ }
+}
diff --git a/packages/mui-base/src/ToggleButtonGroup/Root/ToggleButtonGroupRootContext.ts b/packages/mui-base/src/ToggleButtonGroup/Root/ToggleButtonGroupRootContext.ts
new file mode 100644
index 000000000..203a5e0fb
--- /dev/null
+++ b/packages/mui-base/src/ToggleButtonGroup/Root/ToggleButtonGroupRootContext.ts
@@ -0,0 +1,26 @@
+'use client';
+import * as React from 'react';
+
+export interface ToggleButtonGroupRootContext {
+ value: unknown[];
+ setGroupValue: (newValue: unknown, nextPressed: boolean, event: Event) => void;
+}
+
+export const ToggleButtonGroupRootContext = React.createContext<
+ ToggleButtonGroupRootContext | undefined
+>(undefined);
+
+if (process.env.NODE_ENV !== 'production') {
+ ToggleButtonGroupRootContext.displayName = 'ToggleButtonGroupRootContext';
+}
+
+export function useToggleButtonGroupRootContext() {
+ const context = React.useContext(ToggleButtonGroupRootContext);
+ if (context === undefined) {
+ throw new Error(
+ 'Base UI: ToggleButtonGroupRootContext is missing. ToggleButtonGroup parts must be placed within .',
+ );
+ }
+
+ return context;
+}
diff --git a/packages/mui-base/src/ToggleButtonGroup/Root/useToggleButtonGroupRoot.ts b/packages/mui-base/src/ToggleButtonGroup/Root/useToggleButtonGroupRoot.ts
new file mode 100644
index 000000000..903bcea6b
--- /dev/null
+++ b/packages/mui-base/src/ToggleButtonGroup/Root/useToggleButtonGroupRoot.ts
@@ -0,0 +1,100 @@
+'use client';
+import * as React from 'react';
+import { mergeReactProps } from '../../utils/mergeReactProps';
+import { useControlled } from '../../utils/useControlled';
+import { useEventCallback } from '../../utils/useEventCallback';
+import type { GenericHTMLProps } from '../../utils/types';
+
+export function useToggleButtonGroupRoot(
+ parameters: UseToggleButtonGroupRoot.Parameters,
+): UseToggleButtonGroupRoot.ReturnValue {
+ const {
+ disabled = false,
+ value,
+ defaultValue,
+ onValueChange,
+ toggleMultiple = false,
+ } = parameters;
+
+ const [groupValue, setValueState] = useControlled({
+ controlled: value,
+ default: defaultValue,
+ name: 'ToggleButtonGroup',
+ state: 'value',
+ });
+
+ const setGroupValue = useEventCallback(
+ (newValue: unknown, nextPressed: boolean, event: Event) => {
+ let newGroupValue: unknown[] | undefined;
+ if (toggleMultiple) {
+ newGroupValue = groupValue.slice();
+ if (nextPressed) {
+ newGroupValue.push(newValue);
+ } else {
+ newGroupValue.splice(groupValue.indexOf(newValue), 1);
+ }
+ } else {
+ newGroupValue = nextPressed ? [newValue] : [];
+ }
+ if (Array.isArray(newGroupValue)) {
+ setValueState(newGroupValue);
+ onValueChange?.(newGroupValue, event);
+ }
+ },
+ );
+
+ const getRootProps = React.useCallback(
+ (externalProps = {}) =>
+ mergeReactProps<'div'>(externalProps, {
+ role: 'group',
+ }),
+ [],
+ );
+
+ return React.useMemo(
+ () => ({
+ getRootProps,
+ disabled,
+ setGroupValue,
+ value: groupValue,
+ }),
+ [getRootProps, disabled, groupValue, setGroupValue],
+ );
+}
+
+export namespace UseToggleButtonGroupRoot {
+ export interface Parameters {
+ value?: unknown[];
+ defaultValue?: unknown[];
+ onValueChange?: (groupValue: unknown[], event: Event) => void;
+ /**
+ * When `true` the component is disabled
+ * @false
+ */
+ disabled?: boolean;
+ /**
+ * When `false` only one ToggleButton in the group can be pressed.
+ * When a ToggleButton is pressed, the others in the group will become unpressed
+ * @default false
+ */
+ toggleMultiple?: boolean;
+ }
+
+ export interface ReturnValue {
+ getRootProps: (externalProps?: GenericHTMLProps) => GenericHTMLProps;
+ /**
+ * When `true` the component is disabled
+ * @false
+ */
+ disabled: boolean;
+ /**
+ *
+ */
+ setGroupValue: (newValue: unknown, nextPressed: boolean, event: Event) => void;
+ /**
+ * The value of the ToggleButtonGroup represented by an array of values
+ * of the items that are pressed
+ */
+ value: unknown[];
+ }
+}
diff --git a/packages/mui-base/src/ToggleButtonGroup/index.parts.ts b/packages/mui-base/src/ToggleButtonGroup/index.parts.ts
new file mode 100644
index 000000000..5c0e559a3
--- /dev/null
+++ b/packages/mui-base/src/ToggleButtonGroup/index.parts.ts
@@ -0,0 +1,2 @@
+export { ToggleButtonGroupRoot as Root } from './Root/ToggleButtonGroupRoot';
+export { ToggleButtonGroupItem as Item } from './Item/ToggleButtonGroupItem';
diff --git a/packages/mui-base/src/ToggleButtonGroup/index.ts b/packages/mui-base/src/ToggleButtonGroup/index.ts
new file mode 100644
index 000000000..6466ce449
--- /dev/null
+++ b/packages/mui-base/src/ToggleButtonGroup/index.ts
@@ -0,0 +1 @@
+export * as ToggleButtonGroup from './index.parts';
diff --git a/packages/mui-base/src/index.ts b/packages/mui-base/src/index.ts
index bb0c9c1ff..770f1d944 100644
--- a/packages/mui-base/src/index.ts
+++ b/packages/mui-base/src/index.ts
@@ -19,4 +19,5 @@ export * from './Switch';
export * from './Tabs';
export * from './TextInput';
export * from './ToggleButton';
+export * from './ToggleButtonGroup';
export * from './Tooltip';