diff --git a/docs/src/app/experiments/toggle.module.css b/docs/src/app/experiments/toggle.module.css
new file mode 100644
index 0000000000..fd7ecf3489
--- /dev/null
+++ b/docs/src/app/experiments/toggle.module.css
@@ -0,0 +1,38 @@
+.button {
+ --size: 2.5rem;
+ --corner: 0.4rem;
+
+ display: flex;
+ flex-flow: row nowrap;
+ justify-content: center;
+ align-items: center;
+ height: var(--size);
+ width: var(--size);
+ border: 1px solid var(--gray-outline-2);
+ border-radius: var(--corner);
+ background-color: var(--gray-container-1);
+ color: var(--gray-text-1);
+}
+
+.button:hover {
+ background-color: var(--gray-surface-1);
+ color: var(--gray-text-2);
+}
+
+.button:focus-visible {
+ outline: 2px solid var(--gray-900);
+}
+
+.icon {
+ fill: none;
+ stroke: currentColor;
+ stroke-width: 2;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+}
+
+.button[data-pressed] .icon {
+ color: var(--gray-text-2);
+ fill: currentColor;
+ /* stroke-width: 0;*/
+}
diff --git a/docs/src/app/experiments/toggle.tsx b/docs/src/app/experiments/toggle.tsx
index 08ff006ac4..8131eecefe 100644
--- a/docs/src/app/experiments/toggle.tsx
+++ b/docs/src/app/experiments/toggle.tsx
@@ -1,30 +1,72 @@
'use client';
import * as React from 'react';
import { ToggleButton } from '@base_ui/react/ToggleButton';
+import { ToggleButtonGroup } from '@base_ui/react/ToggleButtonGroup';
+import classes from './toggle.module.css';
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 5a8a37800e..5cbd4a6c3b 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;
diff --git a/packages/mui-base/src/ToggleButton/Root/useToggleButtonRoot.ts b/packages/mui-base/src/ToggleButton/Root/useToggleButtonRoot.ts
index e944cd0902..bf2abee1cd 100644
--- a/packages/mui-base/src/ToggleButton/Root/useToggleButtonRoot.ts
+++ b/packages/mui-base/src/ToggleButton/Root/useToggleButtonRoot.ts
@@ -16,6 +16,7 @@ export function useToggleButtonRoot(
defaultPressed = false,
disabled = false,
buttonRef: externalRef,
+ value = undefined,
} = parameters;
const [pressed, setPressedState] = useControlled({
@@ -90,6 +91,11 @@ export namespace useToggleButtonRoot {
* @param {Event} event The event source of the callback.
*/
onPressedChange?: (pressed: boolean, event: Event) => void;
+ /**
+ * A unique value that identifies the component when used
+ * inside a ToggleButtonGroup
+ */
+ value?: unknown;
// TODO: a prop to indicate `aria-pressed='mixed'` is supported
}
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 0000000000..377a0657ae
--- /dev/null
+++ b/packages/mui-base/src/ToggleButtonGroup/Root/ToggleButtonGroupRoot.test.tsx
@@ -0,0 +1,15 @@
+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';
+
+describe('', () => {
+ const { render } = createRenderer();
+
+ describeConformance(, () => ({
+ refInstanceof: window.HTMLDivElement,
+ render,
+ }));
+});
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 0000000000..f24951a05d
--- /dev/null
+++ b/packages/mui-base/src/ToggleButtonGroup/Root/ToggleButtonGroupRoot.tsx
@@ -0,0 +1,84 @@
+'use client';
+import * as React from 'react';
+import { useComponentRenderer } from '../../utils/useComponentRenderer';
+import type { BaseUIComponentProps } from '../../utils/types';
+import {
+ useToggleButtonGroupRoot,
+ type UseToggleButtonGroupRoot,
+} from './useToggleButtonGroupRoot';
+import { ToggleButtonGroupRootContext } from './ToggleButtonGroupRootContext';
+
+const customStyleHookMapping = {
+ disabled: () => null,
+ multiple(value: boolean) {
+ if (value) {
+ return { 'data-multiple': '' } as Record;
+ }
+ return null;
+ },
+};
+
+const ToggleButtonGroupRoot = React.forwardRef(function ToggleButtonGroupRoot(
+ props: ToggleButtonGroupRoot.Props,
+ forwardedRef: React.ForwardedRef,
+) {
+ const {
+ disabled: disabledProp,
+ toggleMultiple = false,
+ className,
+ render,
+ ...otherProps
+ } = props;
+
+ const { getRootProps, disabled, setValue, value } = useToggleButtonGroupRoot({
+ disabled: disabledProp,
+ toggleMultiple,
+ });
+
+ const ownerState: ToggleButtonGroupRoot.OwnerState = React.useMemo(
+ () => ({ disabled, multiple: toggleMultiple }),
+ [disabled, toggleMultiple],
+ );
+
+ const contextValue: ToggleButtonGroupRootContext = React.useMemo(
+ () => ({
+ setValue,
+ value,
+ }),
+ [value, setValue],
+ );
+
+ const { renderElement } = useComponentRenderer({
+ propGetter: getRootProps,
+ render: render ?? 'div',
+ ref: forwardedRef,
+ ownerState,
+ className,
+ customStyleHookMapping,
+ extraProps: otherProps,
+ });
+
+ return (
+
+ {renderElement()}
+
+ );
+});
+
+export { ToggleButtonGroupRoot };
+
+export namespace ToggleButtonGroupRoot {
+ export interface OwnerState {
+ disabled: boolean;
+ multiple: boolean;
+ }
+
+ export interface Props
+ extends Pick,
+ BaseUIComponentProps<'div', OwnerState> {
+ /**
+ * @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 0000000000..cb2d7728d5
--- /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[];
+ setValue: (value: unknown[], event: Event) => void;
+}
+
+export const ToggleButtonGroupRootContext = React.createContext<
+ ToggleButtonGroupRootContext | undefined
+>(undefined);
+
+if (process.env.NODE_ENV !== 'production') {
+ ToggleButtonGroupRootContext.displayName = 'ToggleButtonGroupRootContext';
+}
+
+export function useToggleButtonGroupRootContext(optional = true) {
+ const context = React.useContext(ToggleButtonGroupRootContext);
+ if (context === undefined && !optional) {
+ 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 0000000000..8854c5c281
--- /dev/null
+++ b/packages/mui-base/src/ToggleButtonGroup/Root/useToggleButtonGroupRoot.ts
@@ -0,0 +1,96 @@
+'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: valueParam,
+ defaultValue,
+ onValueChange,
+ toggleMultiple = false,
+ } = parameters;
+
+ const [groupValue, setValueState] = useControlled({
+ controlled: valueParam,
+ default: defaultValue,
+ name: 'ToggleButtonGroup',
+ state: 'value',
+ });
+
+ const setValue = useEventCallback((toggledValue: unknown, event: Event) => {
+ let newGroupValue;
+ if (toggleMultiple) {
+ const toggledValueIndex = groupValue.indexOf(toggledValue);
+ newGroupValue =
+ toggledValueIndex === -1
+ ? groupValue.push(toggledValue)
+ : groupValue.splice(toggledValueIndex, 1);
+ } else {
+ newGroupValue = groupValue.length === 0 ? [toggledValue] : [];
+ }
+ if (Array.isArray(newGroupValue)) {
+ setValueState(newGroupValue);
+ onValueChange?.(newGroupValue, event);
+ }
+ });
+
+ const getRootProps = React.useCallback(
+ (externalProps = {}) =>
+ mergeReactProps<'div'>(externalProps, {
+ role: 'group',
+ }),
+ [],
+ );
+
+ return React.useMemo(
+ () => ({
+ getRootProps,
+ disabled,
+ setValue,
+ value: groupValue,
+ }),
+ [getRootProps, disabled, groupValue, setValue],
+ );
+}
+
+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;
+ /**
+ *
+ */
+ setValue: (toggledValue: unknown[], event: Event) => void;
+ /**
+ *
+ */
+ 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 0000000000..b0fc12c64f
--- /dev/null
+++ b/packages/mui-base/src/ToggleButtonGroup/index.parts.ts
@@ -0,0 +1 @@
+export { ToggleButtonGroupRoot as Root } from './Root/ToggleButtonGroupRoot';
diff --git a/packages/mui-base/src/ToggleButtonGroup/index.ts b/packages/mui-base/src/ToggleButtonGroup/index.ts
new file mode 100644
index 0000000000..4ac6a6b047
--- /dev/null
+++ b/packages/mui-base/src/ToggleButtonGroup/index.ts
@@ -0,0 +1 @@
+export { Root as ToggleButtonGroup } from './index.parts';