diff --git a/docs/5.x/docs/components/ConnectedButtonGroup/ConnectedButtonGroup.mdx b/docs/5.x/docs/components/ConnectedButtonGroup/ConnectedButtonGroup.mdx
new file mode 100644
index 0000000000..0e14ef0adb
--- /dev/null
+++ b/docs/5.x/docs/components/ConnectedButtonGroup/ConnectedButtonGroup.mdx
@@ -0,0 +1,122 @@
+---
+title: ConnectedButtonGroup
+---
+
+import PropTable from '@docs/components/PropTable.tsx';
+import ExtendsLink from '@docs/components/ExtendsLink.tsx';
+import ThemeColorsTable from '@docs/components/ThemeColorsTable.tsx';
+import ScreenshotTabs from '@docs/components/ScreenshotTabs.tsx';
+import ExtendedExample from '@docs/components/ExtendedExample.tsx';
+
+Connected button groups let people select from a set of related options, switch views or sort elements. They are the Material Design 3 successor to `SegmentedButtons`: selected buttons morph to a fully-rounded shape and the group supports single- and multi-select.
+
+
+
+## Usage
+
+```js
+import * as React from 'react';
+import { SafeAreaView, StyleSheet } from 'react-native';
+import { ConnectedButtonGroup } from 'react-native-paper';
+
+const MyComponent = () => {
+ const [value, setValue] = React.useState('walk');
+
+ return (
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ alignItems: 'center',
+ },
+});
+
+export default MyComponent;
+```
+
+## Props
+
+
+
+
+
+### buttons (required)
+
+
+
+
+
+
+
+### size
+
+
+
+
+
+
+
+### style
+
+
+
+
+
+
+
+### testID
+
+
+
+
+
+
+
+### theme
+
+
+
+
+
+
+
+## Theme colors
+
+
+
+
diff --git a/docs/5.x/docs/components/ConnectedButtonGroup/_meta.json b/docs/5.x/docs/components/ConnectedButtonGroup/_meta.json
new file mode 100644
index 0000000000..b9591f8c2e
--- /dev/null
+++ b/docs/5.x/docs/components/ConnectedButtonGroup/_meta.json
@@ -0,0 +1,3 @@
+[
+ "ConnectedButtonGroup"
+]
diff --git a/docs/5.x/docs/components/_meta.json b/docs/5.x/docs/components/_meta.json
index 548d39075a..246ebe03b0 100644
--- a/docs/5.x/docs/components/_meta.json
+++ b/docs/5.x/docs/components/_meta.json
@@ -51,6 +51,13 @@
"collapsible": true,
"collapsed": false
},
+ {
+ "type": "dir",
+ "name": "ConnectedButtonGroup",
+ "label": "ConnectedButtonGroup",
+ "collapsible": true,
+ "collapsed": false
+ },
{
"type": "dir",
"name": "DataTable",
diff --git a/docs/6.x/docs/components/ConnectedButtonGroup/ConnectedButtonGroup.mdx b/docs/6.x/docs/components/ConnectedButtonGroup/ConnectedButtonGroup.mdx
new file mode 100644
index 0000000000..a7e32b069c
--- /dev/null
+++ b/docs/6.x/docs/components/ConnectedButtonGroup/ConnectedButtonGroup.mdx
@@ -0,0 +1,112 @@
+---
+title: ConnectedButtonGroup
+---
+
+import PropTable from '@docs/components/PropTable.tsx';
+import ExtendsLink from '@docs/components/ExtendsLink.tsx';
+import ThemeColorsTable from '@docs/components/ThemeColorsTable.tsx';
+import ScreenshotTabs from '@docs/components/ScreenshotTabs.tsx';
+import ExtendedExample from '@docs/components/ExtendedExample.tsx';
+
+Connected button groups let people select from a set of related options,
+switch views or sort elements. They are the Material Design 3 successor to
+`SegmentedButtons`: selected buttons morph to a fully-rounded shape and the
+group supports single- and multi-select.
+
+
+
+
+
+
+
+## Usage
+```js
+import * as React from 'react';
+import { SafeAreaView, StyleSheet } from 'react-native';
+import { ConnectedButtonGroup } from 'react-native-paper';
+
+const MyComponent = () => {
+ const [value, setValue] = React.useState('walk');
+
+ return (
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ alignItems: 'center',
+ },
+});
+
+export default MyComponent;
+```
+
+
+ ## Props
+
+
+
+
+
+
+### buttons (required)
+
+
+
+
+
+
+
+### size
+
+
+
+
+
+
+
+### style
+
+
+
+
+
+
+
+### testID
+
+
+
+
+
+
+
+### theme
+
+
+
+
+
+
+
+
+
+
+ ## Theme colors
+
+
+
+
+
diff --git a/docs/6.x/docs/components/ConnectedButtonGroup/_meta.json b/docs/6.x/docs/components/ConnectedButtonGroup/_meta.json
new file mode 100644
index 0000000000..b9591f8c2e
--- /dev/null
+++ b/docs/6.x/docs/components/ConnectedButtonGroup/_meta.json
@@ -0,0 +1,3 @@
+[
+ "ConnectedButtonGroup"
+]
diff --git a/docs/6.x/docs/components/SegmentedButtons/SegmentedButtons.mdx b/docs/6.x/docs/components/SegmentedButtons/SegmentedButtons.mdx
index fbd734c612..c73f28b204 100644
--- a/docs/6.x/docs/components/SegmentedButtons/SegmentedButtons.mdx
+++ b/docs/6.x/docs/components/SegmentedButtons/SegmentedButtons.mdx
@@ -8,6 +8,11 @@ import ThemeColorsTable from '@docs/components/ThemeColorsTable.tsx';
import ScreenshotTabs from '@docs/components/ScreenshotTabs.tsx';
import ExtendedExample from '@docs/components/ExtendedExample.tsx';
+@deprecated Segmented buttons are deprecated in the Material Design 3 spec
+and replaced by the Connected Button Group. Use
+[`ConnectedButtonGroup`](../ConnectedButtonGroup/ConnectedButtonGroup) instead — it exposes the
+same single/multi-select API with `buttons`, `value` and `onValueChange`.
+
Segmented buttons can be used to select options, switch views or sort elements.
diff --git a/docs/6.x/docs/components/_meta.json b/docs/6.x/docs/components/_meta.json
index 6f793d41a3..9360746d96 100644
--- a/docs/6.x/docs/components/_meta.json
+++ b/docs/6.x/docs/components/_meta.json
@@ -51,6 +51,13 @@
"collapsible": true,
"collapsed": false
},
+ {
+ "type": "dir",
+ "name": "ConnectedButtonGroup",
+ "label": "ConnectedButtonGroup",
+ "collapsible": true,
+ "collapsed": false
+ },
{
"type": "dir",
"name": "DataTable",
diff --git a/docs/component-docs.config.ts b/docs/component-docs.config.ts
index bad25d4eec..d5ea917743 100644
--- a/docs/component-docs.config.ts
+++ b/docs/component-docs.config.ts
@@ -59,6 +59,9 @@ const pages = {
Chip: {
Chip: 'Chip/Chip',
},
+ ConnectedButtonGroup: {
+ ConnectedButtonGroup: 'ConnectedButtonGroup/ConnectedButtonGroup',
+ },
DataTable: {
DataTable: 'DataTable/DataTable',
DataTableCell: 'DataTable/DataTableCell',
diff --git a/docs/public/screenshots/connected-button-group.png b/docs/public/screenshots/connected-button-group.png
new file mode 100644
index 0000000000..2239c4ceef
Binary files /dev/null and b/docs/public/screenshots/connected-button-group.png differ
diff --git a/docs/src/data/componentDocs6x.json b/docs/src/data/componentDocs6x.json
index 56b356912b..b3e54b628e 100644
--- a/docs/src/data/componentDocs6x.json
+++ b/docs/src/data/componentDocs6x.json
@@ -4652,6 +4652,214 @@
"src/components/Chip/Chip.tsx"
]
},
+ "ConnectedButtonGroup/ConnectedButtonGroup": {
+ "filepath": "ConnectedButtonGroup/ConnectedButtonGroup.tsx",
+ "title": "ConnectedButtonGroup",
+ "description": "Connected button groups let people select from a set of related options,\nswitch views or sort elements. They are the Material Design 3 successor to\n`SegmentedButtons`: selected buttons morph to a fully-rounded shape and the\ngroup supports single- and multi-select.\n\n## Usage\n```js\nimport * as React from 'react';\nimport { SafeAreaView, StyleSheet } from 'react-native';\nimport { ConnectedButtonGroup } from 'react-native-paper';\n\nconst MyComponent = () => {\n const [value, setValue] = React.useState('walk');\n\n return (\n \n \n \n );\n};\n\nconst styles = StyleSheet.create({\n container: {\n flex: 1,\n alignItems: 'center',\n },\n});\n\nexport default MyComponent;\n```",
+ "link": "connected-button-group",
+ "data": {
+ "description": "Connected button groups let people select from a set of related options,\nswitch views or sort elements. They are the Material Design 3 successor to\n`SegmentedButtons`: selected buttons morph to a fully-rounded shape and the\ngroup supports single- and multi-select.\n\n## Usage\n```js\nimport * as React from 'react';\nimport { SafeAreaView, StyleSheet } from 'react-native';\nimport { ConnectedButtonGroup } from 'react-native-paper';\n\nconst MyComponent = () => {\n const [value, setValue] = React.useState('walk');\n\n return (\n \n \n \n );\n};\n\nconst styles = StyleSheet.create({\n container: {\n flex: 1,\n alignItems: 'center',\n },\n});\n\nexport default MyComponent;\n```",
+ "displayName": "ConnectedButtonGroup",
+ "methods": [],
+ "statics": [],
+ "props": {
+ "buttons": {
+ "required": true,
+ "tsType": {
+ "name": "Array",
+ "elements": [
+ {
+ "name": "signature",
+ "type": "object",
+ "raw": "{\n /**\n * Value of the button (required).\n */\n value: T;\n /**\n * Icon to display for the button.\n */\n icon?: IconSource;\n /**\n * Label text of the button.\n */\n label?: string;\n /**\n * Whether the button is disabled.\n */\n disabled?: boolean;\n /**\n * Accessibility label for the button. Read by the screen reader when the\n * user taps the button.\n */\n 'aria-label'?: string;\n /**\n * Custom color for the selected label and icon.\n */\n checkedColor?: string;\n /**\n * Custom color for the unselected label and icon.\n */\n uncheckedColor?: string;\n /**\n * Show an optional check icon to indicate the selected state.\n */\n showSelectedCheck?: boolean;\n /**\n * Type of background drawable to display the feedback (Android).\n * https://reactnative.dev/docs/pressable#rippleconfig\n */\n background?: PressableAndroidRippleConfig;\n /**\n * Sets additional distance outside of the button in which a press can be\n * detected.\n */\n hitSlop?: TouchableRippleProps['hitSlop'];\n /**\n * Callback that is called when the button is pressed, in addition to the\n * group's `onValueChange`.\n */\n onPress?: (event: GestureResponderEvent) => void;\n /**\n * Specifies the largest possible scale a label font can reach.\n */\n labelMaxFontSizeMultiplier?: number;\n /**\n * Pass additional styles for the button container.\n */\n style?: StyleProp;\n /**\n * Style for the button label.\n */\n labelStyle?: StyleProp;\n /**\n * testID to be used on tests.\n */\n testID?: string;\n}",
+ "signature": {
+ "properties": [
+ {
+ "key": "value",
+ "value": {
+ "name": "T",
+ "required": true
+ }
+ },
+ {
+ "key": "icon",
+ "value": {
+ "name": "IconSource",
+ "required": false
+ }
+ },
+ {
+ "key": "label",
+ "value": {
+ "name": "string",
+ "required": false
+ }
+ },
+ {
+ "key": "disabled",
+ "value": {
+ "name": "boolean",
+ "required": false
+ }
+ },
+ {
+ "key": "aria-label",
+ "value": {
+ "name": "string",
+ "required": false
+ }
+ },
+ {
+ "key": "checkedColor",
+ "value": {
+ "name": "string",
+ "required": false
+ }
+ },
+ {
+ "key": "uncheckedColor",
+ "value": {
+ "name": "string",
+ "required": false
+ }
+ },
+ {
+ "key": "showSelectedCheck",
+ "value": {
+ "name": "boolean",
+ "required": false
+ }
+ },
+ {
+ "key": "background",
+ "value": {
+ "name": "PressableAndroidRippleConfig",
+ "required": false
+ }
+ },
+ {
+ "key": "hitSlop",
+ "value": {
+ "name": "TouchableRippleProps['hitSlop']",
+ "raw": "TouchableRippleProps['hitSlop']",
+ "required": false
+ }
+ },
+ {
+ "key": "onPress",
+ "value": {
+ "name": "signature",
+ "type": "function",
+ "raw": "(event: GestureResponderEvent) => void",
+ "signature": {
+ "arguments": [
+ {
+ "name": "event",
+ "type": {
+ "name": "GestureResponderEvent"
+ }
+ }
+ ],
+ "return": {
+ "name": "void"
+ }
+ },
+ "required": false
+ }
+ },
+ {
+ "key": "labelMaxFontSizeMultiplier",
+ "value": {
+ "name": "number",
+ "required": false
+ }
+ },
+ {
+ "key": "style",
+ "value": {
+ "name": "StyleProp",
+ "elements": [
+ {
+ "name": "ViewStyle"
+ }
+ ],
+ "raw": "StyleProp",
+ "required": false
+ }
+ },
+ {
+ "key": "labelStyle",
+ "value": {
+ "name": "StyleProp",
+ "elements": [
+ {
+ "name": "TextStyle"
+ }
+ ],
+ "raw": "StyleProp",
+ "required": false
+ }
+ },
+ {
+ "key": "testID",
+ "value": {
+ "name": "string",
+ "required": false
+ }
+ }
+ ]
+ }
+ }
+ ],
+ "raw": "ConnectedButtonConfig[]"
+ },
+ "description": "Buttons to display as options in the group. Each button should contain the\nfollowing properties:\n- `value`: value of the button (required)\n- `icon`: icon to display for the button\n- `label`: label text of the button\n- `disabled`: whether the button is disabled\n- `aria-label`: accessibility label for the button\n- `checkedColor`: custom color for the selected label and icon\n- `uncheckedColor`: custom color for the unselected label and icon\n- `showSelectedCheck`: show an optional check icon to indicate the selected state\n- `onPress`: callback that is called when the button is pressed\n- `style`: pass additional styles for the button\n- `labelStyle`: style for the button label\n- `testID`: testID to be used on tests"
+ },
+ "size": {
+ "required": false,
+ "tsType": {
+ "name": "ConnectedButtonGroupSize"
+ },
+ "description": "Size of the buttons, following the Material Design 3 button-group scale.",
+ "defaultValue": {
+ "value": "'small'",
+ "computed": false
+ }
+ },
+ "style": {
+ "required": false,
+ "tsType": {
+ "name": "StyleProp",
+ "elements": [
+ {
+ "name": "ViewStyle"
+ }
+ ],
+ "raw": "StyleProp"
+ },
+ "description": ""
+ },
+ "testID": {
+ "required": false,
+ "tsType": {
+ "name": "string"
+ },
+ "description": "testID to be used on tests."
+ },
+ "theme": {
+ "required": false,
+ "tsType": {
+ "name": "ThemeProp"
+ },
+ "description": ""
+ }
+ }
+ },
+ "type": "component",
+ "dependencies": [
+ "src/components/ConnectedButtonGroup/ConnectedButtonGroup.tsx"
+ ]
+ },
"DataTable/DataTable": {
"filepath": "DataTable/DataTable.tsx",
"title": "DataTable",
@@ -10695,10 +10903,10 @@
"SegmentedButtons/SegmentedButtons": {
"filepath": "SegmentedButtons/SegmentedButtons.tsx",
"title": "SegmentedButtons",
- "description": "Segmented buttons can be used to select options, switch views or sort elements.\n\n## Usage\n```js\nimport * as React from 'react';\nimport { SafeAreaView, StyleSheet } from 'react-native';\nimport { SegmentedButtons } from 'react-native-paper';\n\nconst MyComponent = () => {\n const [value, setValue] = React.useState('');\n\n return (\n \n \n \n );\n};\n\nconst styles = StyleSheet.create({\n container: {\n flex: 1,\n alignItems: 'center',\n },\n});\n\nexport default MyComponent;\n```",
+ "description": "@deprecated Segmented buttons are deprecated in the Material Design 3 spec\nand replaced by the Connected Button Group. Use\n[`ConnectedButtonGroup`](../ConnectedButtonGroup/ConnectedButtonGroup) instead — it exposes the\nsame single/multi-select API with `buttons`, `value` and `onValueChange`.\n\nSegmented buttons can be used to select options, switch views or sort elements.\n\n## Usage\n```js\nimport * as React from 'react';\nimport { SafeAreaView, StyleSheet } from 'react-native';\nimport { SegmentedButtons } from 'react-native-paper';\n\nconst MyComponent = () => {\n const [value, setValue] = React.useState('');\n\n return (\n \n \n \n );\n};\n\nconst styles = StyleSheet.create({\n container: {\n flex: 1,\n alignItems: 'center',\n },\n});\n\nexport default MyComponent;\n```",
"link": "segmented-buttons",
"data": {
- "description": "Segmented buttons can be used to select options, switch views or sort elements.\n\n## Usage\n```js\nimport * as React from 'react';\nimport { SafeAreaView, StyleSheet } from 'react-native';\nimport { SegmentedButtons } from 'react-native-paper';\n\nconst MyComponent = () => {\n const [value, setValue] = React.useState('');\n\n return (\n \n \n \n );\n};\n\nconst styles = StyleSheet.create({\n container: {\n flex: 1,\n alignItems: 'center',\n },\n});\n\nexport default MyComponent;\n```",
+ "description": "@deprecated Segmented buttons are deprecated in the Material Design 3 spec\nand replaced by the Connected Button Group. Use\n[`ConnectedButtonGroup`](../ConnectedButtonGroup/ConnectedButtonGroup) instead — it exposes the\nsame single/multi-select API with `buttons`, `value` and `onValueChange`.\n\nSegmented buttons can be used to select options, switch views or sort elements.\n\n## Usage\n```js\nimport * as React from 'react';\nimport { SafeAreaView, StyleSheet } from 'react-native';\nimport { SegmentedButtons } from 'react-native-paper';\n\nconst MyComponent = () => {\n const [value, setValue] = React.useState('');\n\n return (\n \n \n \n );\n};\n\nconst styles = StyleSheet.create({\n container: {\n flex: 1,\n alignItems: 'center',\n },\n});\n\nexport default MyComponent;\n```",
"displayName": "SegmentedButtons",
"methods": [],
"statics": [],
diff --git a/docs/src/data/screenshots.ts b/docs/src/data/screenshots.ts
index 92bf8f2788..e98532e0be 100644
--- a/docs/src/data/screenshots.ts
+++ b/docs/src/data/screenshots.ts
@@ -55,6 +55,7 @@ export const screenshots = {
flat: 'screenshots/chip-1.png',
outlined: 'screenshots/chip-2.png',
},
+ ConnectedButtonGroup: 'screenshots/connected-button-group.png',
DataTable: 'screenshots/data-table-full-width.png',
'DataTable.Cell': 'screenshots/data-table-row-cell.png',
'DataTable.Header': 'screenshots/data-table-header.png',
diff --git a/docs/src/data/themeColors.ts b/docs/src/data/themeColors.ts
index 20f16962f7..b23e3a5dbb 100644
--- a/docs/src/data/themeColors.ts
+++ b/docs/src/data/themeColors.ts
@@ -101,6 +101,26 @@ export const themeColors = {
borderColor: 'theme.colors.outline',
},
},
+ ConnectedButtonGroup: {
+ checked: {
+ '-': {
+ backgroundColor: 'theme.colors.secondaryContainer',
+ textColor: 'theme.colors.onSecondaryContainer',
+ },
+ },
+ unchecked: {
+ '-': {
+ backgroundColor: 'theme.colors.surfaceContainer',
+ textColor: 'theme.colors.onSurfaceVariant',
+ },
+ },
+ disabled: {
+ '-': {
+ backgroundColor: 'theme.colors.onSurface',
+ textColor: 'theme.colors.onSurface',
+ },
+ },
+ },
Dialog: {
'-': {
backgroundColor: 'theme.colors.elevation.level3',
diff --git a/example/src/ExampleList.tsx b/example/src/ExampleList.tsx
index 53132e299f..e8d0edaa49 100644
--- a/example/src/ExampleList.tsx
+++ b/example/src/ExampleList.tsx
@@ -16,6 +16,7 @@ import CardExample from './Examples/CardExample';
import CheckboxExample from './Examples/CheckboxExample';
import CheckboxItemExample from './Examples/CheckboxItemExample';
import ChipExample from './Examples/ChipExample';
+import ConnectedButtonGroupExample from './Examples/ConnectedButtonGroupExample';
import DataTableExample from './Examples/DataTableExample';
import DialogExample from './Examples/DialogExample';
import DividerExample from './Examples/DividerExample';
@@ -61,6 +62,7 @@ export const mainExamples = {
Checkbox: CheckboxExample,
CheckboxItem: CheckboxItemExample,
Chip: ChipExample,
+ ConnectedButtonGroup: ConnectedButtonGroupExample,
DataTable: DataTableExample,
Dialog: DialogExample,
Divider: DividerExample,
diff --git a/example/src/Examples/ConnectedButtonGroupExample.tsx b/example/src/Examples/ConnectedButtonGroupExample.tsx
new file mode 100644
index 0000000000..6f00cc3e8d
--- /dev/null
+++ b/example/src/Examples/ConnectedButtonGroupExample.tsx
@@ -0,0 +1,127 @@
+import * as React from 'react';
+import { StyleSheet, View } from 'react-native';
+
+import { ConnectedButtonGroup, List } from 'react-native-paper';
+
+import ScreenWrapper from '../ScreenWrapper';
+
+const sizes = ['extra-small', 'small', 'medium'] as const;
+
+const ConnectedButtonGroupExample = () => {
+ const [transport, setTransport] = React.useState('walk');
+ const [align, setAlign] = React.useState('center');
+ const [formatting, setFormatting] = React.useState(['bold']);
+ const [view, setView] = React.useState('week');
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {sizes.map((size) => (
+
+
+
+ ))}
+
+
+
+
+ {}}
+ buttons={[
+ { value: 'day', label: 'Day', disabled: true },
+ { value: 'week', label: 'Week', disabled: true },
+ { value: 'month', label: 'Month', disabled: true },
+ ]}
+ />
+
+
+
+ );
+};
+
+ConnectedButtonGroupExample.title = 'Connected Button Group';
+
+const styles = StyleSheet.create({
+ row: {
+ paddingHorizontal: 16,
+ paddingVertical: 8,
+ },
+});
+
+export default ConnectedButtonGroupExample;
diff --git a/src/components/ConnectedButtonGroup/ConnectedButton.tsx b/src/components/ConnectedButtonGroup/ConnectedButton.tsx
new file mode 100644
index 0000000000..d6c119f3ad
--- /dev/null
+++ b/src/components/ConnectedButtonGroup/ConnectedButton.tsx
@@ -0,0 +1,334 @@
+import * as React from 'react';
+import { StyleSheet, View } from 'react-native';
+import type {
+ GestureResponderEvent,
+ PressableAndroidRippleConfig,
+ StyleProp,
+ TextStyle,
+ ViewStyle,
+} from 'react-native';
+
+import Animated, {
+ ReduceMotion,
+ useAnimatedStyle,
+ useSharedValue,
+ withSpring,
+} from 'react-native-reanimated';
+
+import type { ConnectedButtonGroupSize } from './tokens';
+import {
+ getConnectedButtonColors,
+ getConnectedButtonSizeStyle,
+ type ConnectedButtonPosition,
+} from './utils';
+import { useInternalTheme } from '../../core/theming';
+import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext';
+import { toRawSpring } from '../../theme/tokens/sys/motion';
+import type { ThemeProp } from '../../types';
+import Icon, { type IconSource } from '../Icon';
+import TouchableRipple, {
+ type Props as TouchableRippleProps,
+} from '../TouchableRipple/TouchableRipple';
+import Text from '../Typography/Text';
+
+export type Props = {
+ /**
+ * Whether the button is currently selected.
+ */
+ checked: boolean;
+ /**
+ * Whether the parent group allows multiple selections. Controls the
+ * accessibility role (checkbox vs radio).
+ */
+ multiSelect?: boolean;
+ /**
+ * Position of the button inside the connected group. Controls which corners
+ * stay pinned to the group's outer radius and which morph on selection/press.
+ */
+ position: ConnectedButtonPosition;
+ /**
+ * Size of the button, matching the parent group.
+ */
+ size: ConnectedButtonGroupSize;
+ /**
+ * Icon to display before the label.
+ */
+ icon?: IconSource;
+ /**
+ * Label text of the button.
+ */
+ label?: string;
+ /**
+ * Whether the button is disabled.
+ */
+ disabled?: boolean;
+ /**
+ * Show an optional check icon in place of the leading icon to indicate the
+ * selected state.
+ */
+ showSelectedCheck?: boolean;
+ /**
+ * Custom color for the selected label and icon.
+ */
+ checkedColor?: string;
+ /**
+ * Custom color for the unselected label and icon.
+ */
+ uncheckedColor?: string;
+ /**
+ * Type of background drawable to display the feedback (Android).
+ * https://reactnative.dev/docs/pressable#rippleconfig
+ */
+ background?: PressableAndroidRippleConfig;
+ /**
+ * Accessibility label. Read by the screen reader when the button is focused.
+ */
+ 'aria-label'?: string;
+ /**
+ * Function to execute on press.
+ */
+ onPress?: (event: GestureResponderEvent) => void;
+ /**
+ * Specifies the largest possible scale a label font can reach.
+ */
+ labelMaxFontSizeMultiplier?: number;
+ /**
+ * Sets additional distance outside of the button in which a press can be
+ * detected.
+ */
+ hitSlop?: TouchableRippleProps['hitSlop'];
+ style?: StyleProp;
+ /**
+ * Style for the button label.
+ */
+ labelStyle?: StyleProp;
+ /**
+ * testID to be used on tests.
+ */
+ testID?: string;
+ /**
+ * @optional
+ */
+ theme?: ThemeProp;
+};
+
+/**
+ * A single button within a {@link ConnectedButtonGroup}. Not exported on its
+ * own — render it through the group's `buttons` prop.
+ */
+const ConnectedButton = ({
+ checked,
+ multiSelect,
+ position,
+ size,
+ icon,
+ label,
+ disabled,
+ showSelectedCheck,
+ checkedColor,
+ uncheckedColor,
+ background,
+ 'aria-label': ariaLabel,
+ onPress,
+ labelMaxFontSizeMultiplier,
+ hitSlop,
+ style,
+ labelStyle,
+ testID,
+ theme: themeOverrides,
+}: Props) => {
+ const theme = useInternalTheme(themeOverrides);
+
+ const sizeStyle = React.useMemo(
+ () => getConnectedButtonSizeStyle({ size, theme }),
+ [size, theme]
+ );
+ const colors = React.useMemo(
+ () =>
+ getConnectedButtonColors({
+ theme,
+ selected: checked,
+ disabled,
+ checkedColor,
+ uncheckedColor,
+ }),
+ [theme, checked, disabled, checkedColor, uncheckedColor]
+ );
+
+ const { outerRadius, innerRadius, pressedRadius } = sizeStyle;
+ const restRadius = checked ? outerRadius : innerRadius;
+ const cornerRadius = useSharedValue(restRadius);
+
+ const reduceMotion = useReduceMotion();
+ const springConfig = React.useMemo(
+ () => ({
+ ...toRawSpring(theme.motion.spring.fast.spatial),
+ reduceMotion: reduceMotion ? ReduceMotion.Always : ReduceMotion.Never,
+ }),
+ [theme.motion.spring.fast.spatial, reduceMotion]
+ );
+
+ const isFirstRender = React.useRef(true);
+
+ React.useEffect(() => {
+ // The shared value is already initialised to the resting radius, so skip
+ // the mount render and only animate subsequent selection / size changes.
+ if (isFirstRender.current) {
+ isFirstRender.current = false;
+ return;
+ }
+ cornerRadius.value = withSpring(restRadius, springConfig);
+ }, [restRadius, cornerRadius, springConfig]);
+
+ const handlePressIn = React.useCallback(() => {
+ // Pressed takes precedence over selection: even a selected (fully-rounded)
+ // button morphs its connected corner while pressed, matching the M3 spec.
+ cornerRadius.value = withSpring(pressedRadius, springConfig);
+ }, [cornerRadius, pressedRadius, springConfig]);
+ const handlePressOut = React.useCallback(() => {
+ cornerRadius.value = withSpring(restRadius, springConfig);
+ }, [cornerRadius, restRadius, springConfig]);
+
+ // The "outer" side keeps the group's fully-rounded radius; the "inner" side
+ // (the connected edge) morphs between the resting, pressed and selected radii.
+ const animateStart = position === 'last' || position === 'middle';
+ const animateEnd = position === 'first' || position === 'middle';
+
+ const animatedShapeStyle = useAnimatedStyle(() => {
+ const morph = cornerRadius.value;
+ const startRadius = animateStart ? morph : outerRadius;
+ const endRadius = animateEnd ? morph : outerRadius;
+ return {
+ borderTopStartRadius: startRadius,
+ borderBottomStartRadius: startRadius,
+ borderTopEndRadius: endRadius,
+ borderBottomEndRadius: endRadius,
+ };
+ }, [animateStart, animateEnd, outerRadius]);
+
+ const showCheck = checked && showSelectedCheck;
+ const showIcon = Boolean(icon) && !showCheck;
+ const iconGap = label ? { marginEnd: sizeStyle.iconLabelGap } : null;
+
+ const getTestID = (suffix: string) =>
+ testID ? `${testID}-${suffix}` : undefined;
+
+ // When the container is translucent (disabled), the fill is drawn by the
+ // overlay below, so the base view stays transparent.
+ const containerBackground =
+ colors.containerOpacity < 1 ? undefined : colors.containerColor;
+
+ return (
+
+ {colors.containerOpacity < 1 ? (
+ // Opacity is applied as a style so PlatformColor container values
+ // (Android dynamic themes) render at the MD3 disabled 12% correctly.
+
+ ) : null}
+
+
+ {showCheck ? (
+
+
+
+ ) : null}
+ {showIcon ? (
+
+
+
+ ) : null}
+ {label ? (
+
+ {label}
+
+ ) : null}
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ flexGrow: 1,
+ flexShrink: 1,
+ flexBasis: 'auto',
+ overflow: 'hidden',
+ },
+ ripple: {
+ flex: 1,
+ },
+ content: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ label: {
+ flexShrink: 1,
+ textAlign: 'center',
+ },
+});
+
+export default ConnectedButton;
diff --git a/src/components/ConnectedButtonGroup/ConnectedButtonGroup.tsx b/src/components/ConnectedButtonGroup/ConnectedButtonGroup.tsx
new file mode 100644
index 0000000000..690e001162
--- /dev/null
+++ b/src/components/ConnectedButtonGroup/ConnectedButtonGroup.tsx
@@ -0,0 +1,269 @@
+import { StyleSheet, View } from 'react-native';
+import type {
+ GestureResponderEvent,
+ PressableAndroidRippleConfig,
+ StyleProp,
+ TextStyle,
+ ViewStyle,
+} from 'react-native';
+
+import ConnectedButton from './ConnectedButton';
+import {
+ connectedButtonSizeTokens,
+ type ConnectedButtonGroupSize,
+} from './tokens';
+import { getConnectedButtonPosition } from './utils';
+import { useInternalTheme } from '../../core/theming';
+import type { ThemeProp } from '../../types';
+import type { IconSource } from '../Icon';
+import type { Props as TouchableRippleProps } from '../TouchableRipple/TouchableRipple';
+
+type ConditionalValue =
+ | {
+ /**
+ * Array of the currently selected button values.
+ */
+ value: T[];
+ /**
+ * Allow more than one button to be selected at a time.
+ */
+ multiSelect: true;
+ /**
+ * Function to execute on selection change.
+ */
+ onValueChange: (value: T[]) => void;
+ }
+ | {
+ /**
+ * Value of the currently selected button.
+ */
+ value: T;
+ /**
+ * Allow more than one button to be selected at a time.
+ */
+ multiSelect?: false;
+ /**
+ * Function to execute on selection change.
+ */
+ onValueChange: (value: T) => void;
+ };
+
+/**
+ * Configuration for a single button rendered inside the group.
+ */
+export type ConnectedButtonConfig = {
+ /**
+ * Value of the button (required).
+ */
+ value: T;
+ /**
+ * Icon to display for the button.
+ */
+ icon?: IconSource;
+ /**
+ * Label text of the button.
+ */
+ label?: string;
+ /**
+ * Whether the button is disabled.
+ */
+ disabled?: boolean;
+ /**
+ * Accessibility label for the button. Read by the screen reader when the
+ * user taps the button.
+ */
+ 'aria-label'?: string;
+ /**
+ * Custom color for the selected label and icon.
+ */
+ checkedColor?: string;
+ /**
+ * Custom color for the unselected label and icon.
+ */
+ uncheckedColor?: string;
+ /**
+ * Show an optional check icon to indicate the selected state.
+ */
+ showSelectedCheck?: boolean;
+ /**
+ * Type of background drawable to display the feedback (Android).
+ * https://reactnative.dev/docs/pressable#rippleconfig
+ */
+ background?: PressableAndroidRippleConfig;
+ /**
+ * Sets additional distance outside of the button in which a press can be
+ * detected.
+ */
+ hitSlop?: TouchableRippleProps['hitSlop'];
+ /**
+ * Callback that is called when the button is pressed, in addition to the
+ * group's `onValueChange`.
+ */
+ onPress?: (event: GestureResponderEvent) => void;
+ /**
+ * Specifies the largest possible scale a label font can reach.
+ */
+ labelMaxFontSizeMultiplier?: number;
+ /**
+ * Pass additional styles for the button container.
+ */
+ style?: StyleProp;
+ /**
+ * Style for the button label.
+ */
+ labelStyle?: StyleProp;
+ /**
+ * testID to be used on tests.
+ */
+ testID?: string;
+};
+
+export type Props = {
+ /**
+ * Buttons to display as options in the group. Each button should contain the
+ * following properties:
+ * - `value`: value of the button (required)
+ * - `icon`: icon to display for the button
+ * - `label`: label text of the button
+ * - `disabled`: whether the button is disabled
+ * - `aria-label`: accessibility label for the button
+ * - `checkedColor`: custom color for the selected label and icon
+ * - `uncheckedColor`: custom color for the unselected label and icon
+ * - `showSelectedCheck`: show an optional check icon to indicate the selected state
+ * - `onPress`: callback that is called when the button is pressed
+ * - `style`: pass additional styles for the button
+ * - `labelStyle`: style for the button label
+ * - `testID`: testID to be used on tests
+ */
+ buttons: ConnectedButtonConfig[];
+ /**
+ * Size of the buttons, following the Material Design 3 button-group scale.
+ */
+ size?: ConnectedButtonGroupSize;
+ style?: StyleProp;
+ /**
+ * testID to be used on tests.
+ */
+ testID?: string;
+ /**
+ * @optional
+ */
+ theme?: ThemeProp;
+} & ConditionalValue;
+
+/**
+ * Connected button groups let people select from a set of related options,
+ * switch views or sort elements. They are the Material Design 3 successor to
+ * `SegmentedButtons`: selected buttons morph to a fully-rounded shape and the
+ * group supports single- and multi-select.
+ *
+ * ## Usage
+ * ```js
+ * import * as React from 'react';
+ * import { SafeAreaView, StyleSheet } from 'react-native';
+ * import { ConnectedButtonGroup } from 'react-native-paper';
+ *
+ * const MyComponent = () => {
+ * const [value, setValue] = React.useState('walk');
+ *
+ * return (
+ *
+ *
+ *
+ * );
+ * };
+ *
+ * const styles = StyleSheet.create({
+ * container: {
+ * flex: 1,
+ * alignItems: 'center',
+ * },
+ * });
+ *
+ * export default MyComponent;
+ * ```
+ */
+const ConnectedButtonGroup = ({
+ value,
+ onValueChange,
+ buttons,
+ multiSelect,
+ size = 'small',
+ style,
+ testID,
+ theme: themeOverrides,
+}: Props) => {
+ const theme = useInternalTheme(themeOverrides);
+ const { betweenSpace } = connectedButtonSizeTokens[size];
+
+ return (
+
+ {buttons.map((item, index) => {
+ const position = getConnectedButtonPosition(index, buttons.length);
+ const checked =
+ multiSelect && Array.isArray(value)
+ ? value.includes(item.value)
+ : value === item.value;
+
+ const handlePress = (event: GestureResponderEvent) => {
+ item.onPress?.(event);
+
+ const nextValue =
+ multiSelect && Array.isArray(value)
+ ? checked
+ ? value.filter((val) => item.value !== val)
+ : [...value, item.value]
+ : item.value;
+
+ // @ts-expect-error: TS doesn't preserve types after destructuring, so the type isn't inferred correctly
+ onValueChange(nextValue);
+ };
+
+ return (
+
+ );
+ })}
+
+ );
+};
+
+const styles = StyleSheet.create({
+ row: {
+ flexDirection: 'row',
+ },
+});
+
+export default ConnectedButtonGroup;
diff --git a/src/components/ConnectedButtonGroup/index.ts b/src/components/ConnectedButtonGroup/index.ts
new file mode 100644
index 0000000000..40c0eaf058
--- /dev/null
+++ b/src/components/ConnectedButtonGroup/index.ts
@@ -0,0 +1,3 @@
+export { default } from './ConnectedButtonGroup';
+export type { Props, ConnectedButtonConfig } from './ConnectedButtonGroup';
+export type { ConnectedButtonGroupSize } from './tokens';
diff --git a/src/components/ConnectedButtonGroup/tokens.ts b/src/components/ConnectedButtonGroup/tokens.ts
new file mode 100644
index 0000000000..b00a3dcc31
--- /dev/null
+++ b/src/components/ConnectedButtonGroup/tokens.ts
@@ -0,0 +1,134 @@
+import type { TypescaleKey } from '../../theme/types';
+import type { ShapeToken } from '../../theme/utils/shape';
+
+export type ConnectedButtonGroupSize =
+ | 'extra-small'
+ | 'small'
+ | 'medium'
+ | 'large'
+ | 'extra-large';
+
+export type ConnectedButtonSizeTokens = {
+ /**
+ * Height of every button in the group.
+ */
+ containerHeight: number;
+ /**
+ * Gap rendered between adjacent buttons.
+ */
+ betweenSpace: number;
+ /**
+ * Corner shape applied to the outer edges of the group (leading corners of
+ * the first button, trailing corners of the last button) and to any
+ * selected button.
+ */
+ outerShape: ShapeToken;
+ /**
+ * Corner shape applied to the connected (inner) edges of unselected buttons.
+ */
+ innerShape: ShapeToken;
+ /**
+ * Corner shape the connected edges morph to while a button is pressed.
+ */
+ pressedShape: ShapeToken;
+ /**
+ * Icon size for both the leading icon and the selected-state check icon.
+ */
+ iconSize: number;
+ /**
+ * Leading (start) horizontal padding of a button's content.
+ */
+ leadingSpace: number;
+ /**
+ * Trailing (end) horizontal padding of a button's content.
+ */
+ trailingSpace: number;
+ /**
+ * Gap between the icon and the label.
+ */
+ iconLabelGap: number;
+ /**
+ * Minimum width of a single button.
+ */
+ minWidth: number;
+ /**
+ * Typescale variant used for the label.
+ */
+ labelVariant: TypescaleKey;
+};
+
+/**
+ * Per-size specs for the connected button group, following the Material Design 3
+ * button-group sizing scale (extra-small → extra-large).
+ * @see https://m3.material.io/components/button-groups/specs
+ */
+export const connectedButtonSizeTokens: Record<
+ ConnectedButtonGroupSize,
+ ConnectedButtonSizeTokens
+> = {
+ 'extra-small': {
+ containerHeight: 32,
+ betweenSpace: 2,
+ outerShape: 'full',
+ innerShape: 'extraSmall',
+ pressedShape: 'extraSmall',
+ iconSize: 20,
+ leadingSpace: 12,
+ trailingSpace: 12,
+ iconLabelGap: 8,
+ minWidth: 48,
+ labelVariant: 'labelLarge',
+ },
+ small: {
+ containerHeight: 40,
+ betweenSpace: 2,
+ outerShape: 'full',
+ innerShape: 'small',
+ pressedShape: 'extraSmall',
+ iconSize: 20,
+ leadingSpace: 16,
+ trailingSpace: 16,
+ iconLabelGap: 8,
+ minWidth: 48,
+ labelVariant: 'labelLarge',
+ },
+ medium: {
+ containerHeight: 56,
+ betweenSpace: 2,
+ outerShape: 'full',
+ innerShape: 'small',
+ pressedShape: 'extraSmall',
+ iconSize: 24,
+ leadingSpace: 24,
+ trailingSpace: 24,
+ iconLabelGap: 8,
+ minWidth: 56,
+ labelVariant: 'titleMedium',
+ },
+ large: {
+ containerHeight: 96,
+ betweenSpace: 2,
+ outerShape: 'full',
+ innerShape: 'large',
+ pressedShape: 'medium',
+ iconSize: 32,
+ leadingSpace: 48,
+ trailingSpace: 48,
+ iconLabelGap: 12,
+ minWidth: 96,
+ labelVariant: 'headlineSmall',
+ },
+ 'extra-large': {
+ containerHeight: 136,
+ betweenSpace: 2,
+ outerShape: 'full',
+ innerShape: 'largeIncreased',
+ pressedShape: 'large',
+ iconSize: 40,
+ leadingSpace: 64,
+ trailingSpace: 64,
+ iconLabelGap: 16,
+ minWidth: 136,
+ labelVariant: 'headlineLarge',
+ },
+};
diff --git a/src/components/ConnectedButtonGroup/utils.ts b/src/components/ConnectedButtonGroup/utils.ts
new file mode 100644
index 0000000000..190effba31
--- /dev/null
+++ b/src/components/ConnectedButtonGroup/utils.ts
@@ -0,0 +1,131 @@
+import type { ColorValue } from 'react-native';
+
+import {
+ connectedButtonSizeTokens,
+ type ConnectedButtonGroupSize,
+} from './tokens';
+import { tokens } from '../../theme/tokens';
+import { resolveCornerRadius } from '../../theme/utils/shape';
+import type { InternalTheme } from '../../types';
+
+const stateOpacity = tokens.md.sys.state.opacity;
+
+/**
+ * The MD3 disabled container is `onSurface` composited at 12% opacity.
+ * @see https://m3.material.io/foundations/interaction/states/state-layers
+ */
+const DISABLED_CONTAINER_OPACITY = 0.12;
+
+/**
+ * Position of a button within the connected group. Determines which corners
+ * stay pinned to the outer (fully-rounded) radius and which morph.
+ */
+export type ConnectedButtonPosition = 'first' | 'middle' | 'last' | 'single';
+
+export const getConnectedButtonPosition = (
+ index: number,
+ count: number
+): ConnectedButtonPosition => {
+ if (count <= 1) {
+ return 'single';
+ }
+ if (index === 0) {
+ return 'first';
+ }
+ if (index === count - 1) {
+ return 'last';
+ }
+ return 'middle';
+};
+
+export const getConnectedButtonSizeStyle = ({
+ size,
+ theme,
+}: {
+ size: ConnectedButtonGroupSize;
+ theme: InternalTheme;
+}) => {
+ const sizeTokens = connectedButtonSizeTokens[size];
+
+ return {
+ ...sizeTokens,
+ outerRadius: resolveCornerRadius(theme, sizeTokens.outerShape),
+ innerRadius: resolveCornerRadius(theme, sizeTokens.innerShape),
+ pressedRadius: resolveCornerRadius(theme, sizeTokens.pressedShape),
+ };
+};
+
+const getContainerColor = ({
+ theme,
+ selected,
+ disabled,
+}: {
+ theme: InternalTheme;
+ selected: boolean;
+ disabled?: boolean;
+}): ColorValue => {
+ const { colors } = theme;
+
+ if (disabled) {
+ return colors.onSurface;
+ }
+
+ return selected ? colors.secondaryContainer : colors.surfaceContainer;
+};
+
+const getContentColor = ({
+ theme,
+ selected,
+ disabled,
+ checkedColor,
+ uncheckedColor,
+}: {
+ theme: InternalTheme;
+ selected: boolean;
+ disabled?: boolean;
+ checkedColor?: string;
+ uncheckedColor?: string;
+}): ColorValue => {
+ const { colors } = theme;
+
+ if (disabled) {
+ return colors.onSurface;
+ }
+ if (selected) {
+ return checkedColor ?? colors.onSecondaryContainer;
+ }
+ return uncheckedColor ?? colors.onSurfaceVariant;
+};
+
+export const getConnectedButtonColors = ({
+ theme,
+ selected,
+ disabled,
+ checkedColor,
+ uncheckedColor,
+}: {
+ theme: InternalTheme;
+ selected: boolean;
+ disabled?: boolean;
+ checkedColor?: string;
+ uncheckedColor?: string;
+}) => {
+ const containerColor = getContainerColor({ theme, selected, disabled });
+ const contentColor = getContentColor({
+ theme,
+ selected,
+ disabled,
+ checkedColor,
+ uncheckedColor,
+ });
+ // Opacity is applied as a style instead of blending it into the color so
+ // that PlatformColor values (Android dynamic themes) keep working.
+ const containerOpacity = disabled
+ ? DISABLED_CONTAINER_OPACITY
+ : stateOpacity.enabled;
+ const contentOpacity = disabled
+ ? stateOpacity.disabled
+ : stateOpacity.enabled;
+
+ return { containerColor, containerOpacity, contentColor, contentOpacity };
+};
diff --git a/src/components/SegmentedButtons/SegmentedButtons.tsx b/src/components/SegmentedButtons/SegmentedButtons.tsx
index c5fceeaeae..96b12c8400 100644
--- a/src/components/SegmentedButtons/SegmentedButtons.tsx
+++ b/src/components/SegmentedButtons/SegmentedButtons.tsx
@@ -81,6 +81,11 @@ export type Props = {
} & ConditionalValue;
/**
+ * @deprecated Segmented buttons are deprecated in the Material Design 3 spec
+ * and replaced by the Connected Button Group. Use
+ * [`ConnectedButtonGroup`](../ConnectedButtonGroup/ConnectedButtonGroup) instead — it exposes the
+ * same single/multi-select API with `buttons`, `value` and `onValueChange`.
+ *
* Segmented buttons can be used to select options, switch views or sort elements.
*
* ## Usage
diff --git a/src/components/__tests__/ConnectedButtonGroup.test.tsx b/src/components/__tests__/ConnectedButtonGroup.test.tsx
new file mode 100644
index 0000000000..13e6098422
--- /dev/null
+++ b/src/components/__tests__/ConnectedButtonGroup.test.tsx
@@ -0,0 +1,299 @@
+import * as React from 'react';
+
+import { describe, expect, it, jest } from '@jest/globals';
+
+import { getTheme } from '../../core/theming';
+import { render, screen, userEvent } from '../../test-utils';
+import ConnectedButtonGroup from '../ConnectedButtonGroup/ConnectedButtonGroup';
+import { connectedButtonSizeTokens } from '../ConnectedButtonGroup/tokens';
+import {
+ getConnectedButtonColors,
+ getConnectedButtonPosition,
+ getConnectedButtonSizeStyle,
+} from '../ConnectedButtonGroup/utils';
+
+const theme = getTheme();
+
+type SingleSelectProps = Extract<
+ React.ComponentProps,
+ { multiSelect?: false | undefined }
+>;
+
+const buttons = [
+ { value: 'walk', label: 'Walking', testID: 'walk' },
+ { value: 'train', label: 'Transit', testID: 'train' },
+ { value: 'drive', label: 'Driving', testID: 'drive' },
+];
+
+const renderGroup = (props: Partial = {}) =>
+ render(
+ {}}
+ buttons={buttons}
+ {...props}
+ />
+ );
+
+it('renders every button with its label', async () => {
+ await renderGroup();
+
+ expect(screen.getByTestId('walk-label')).toHaveTextContent('Walking');
+ expect(screen.getByTestId('train-label')).toHaveTextContent('Transit');
+ expect(screen.getByTestId('drive-label')).toHaveTextContent('Driving');
+});
+
+it('defaults to the small size (40dp height)', async () => {
+ await renderGroup();
+
+ expect(screen.getByTestId('walk-container')).toHaveStyle({ height: 40 });
+});
+
+it('applies the requested size height', async () => {
+ await renderGroup({ size: 'medium' });
+
+ expect(screen.getByTestId('walk-container')).toHaveStyle({ height: 56 });
+});
+
+it('calls onValueChange with the pressed value in single-select mode', async () => {
+ const user = userEvent.setup();
+ const onValueChange = jest.fn();
+ await renderGroup({ onValueChange });
+
+ await user.press(screen.getByTestId('train'));
+
+ expect(onValueChange).toHaveBeenCalledWith('train');
+});
+
+it('invokes the per-button onPress alongside onValueChange', async () => {
+ const user = userEvent.setup();
+ const onPress = jest.fn();
+ const onValueChange = jest.fn();
+ await renderGroup({
+ onValueChange,
+ buttons: [
+ { value: 'walk', label: 'Walking', testID: 'walk' },
+ { value: 'train', label: 'Transit', testID: 'train', onPress },
+ ],
+ });
+
+ await user.press(screen.getByTestId('train'));
+
+ expect(onPress).toHaveBeenCalledTimes(1);
+ expect(onValueChange).toHaveBeenCalledWith('train');
+});
+
+it('toggles values in multi-select mode', async () => {
+ const user = userEvent.setup();
+ const onValueChange = jest.fn();
+ await render(
+
+ );
+
+ await user.press(screen.getByTestId('train'));
+ expect(onValueChange).toHaveBeenLastCalledWith(['walk', 'train']);
+
+ await user.press(screen.getByTestId('walk'));
+ expect(onValueChange).toHaveBeenLastCalledWith([]);
+});
+
+it('fills the selected button with the secondary container color', async () => {
+ await renderGroup();
+
+ expect(screen.getByTestId('walk-container')).toHaveStyle({
+ backgroundColor: theme.colors.secondaryContainer,
+ });
+ expect(screen.getByTestId('train-container')).toHaveStyle({
+ backgroundColor: theme.colors.surfaceContainer,
+ });
+});
+
+it('does not fire onValueChange for a disabled button', async () => {
+ const user = userEvent.setup();
+ const onValueChange = jest.fn();
+ await renderGroup({
+ onValueChange,
+ buttons: [
+ { value: 'walk', label: 'Walking', testID: 'walk' },
+ { value: 'train', label: 'Transit', disabled: true, testID: 'train' },
+ ],
+ });
+
+ await user.press(screen.getByTestId('train'));
+
+ expect(onValueChange).not.toHaveBeenCalled();
+});
+
+it('shows the selection check only on the selected button', async () => {
+ await renderGroup({
+ buttons: [
+ {
+ value: 'walk',
+ label: 'Walking',
+ showSelectedCheck: true,
+ testID: 'walk',
+ },
+ {
+ value: 'train',
+ label: 'Transit',
+ showSelectedCheck: true,
+ testID: 'train',
+ },
+ ],
+ });
+
+ expect(screen.getByTestId('walk-check-icon')).toBeTruthy();
+ expect(screen.queryByTestId('train-check-icon')).toBeNull();
+});
+
+it('applies a custom checked color to the selected label', async () => {
+ await renderGroup({
+ buttons: [
+ {
+ value: 'walk',
+ label: 'Walking',
+ checkedColor: 'rgb(255, 0, 0)',
+ testID: 'walk',
+ },
+ ],
+ });
+
+ expect(screen.getByTestId('walk-label')).toHaveStyle({
+ color: 'rgb(255, 0, 0)',
+ });
+});
+
+it('marks single-select buttons with the radio role', async () => {
+ await renderGroup();
+
+ expect(screen.getByTestId('walk')).toHaveProp('role', 'radio');
+});
+
+it('marks multi-select buttons with the checkbox role', async () => {
+ await render(
+ {}}
+ buttons={buttons}
+ />
+ );
+
+ expect(screen.getByTestId('walk')).toHaveProp('role', 'checkbox');
+});
+
+describe('connected button shape tokens', () => {
+ it('presses the inner corner sharper than its resting radius (M3)', () => {
+ // small: inner = small (8dp), pressed inner = extraSmall (4dp)
+ const { innerRadius, pressedRadius, outerRadius } =
+ getConnectedButtonSizeStyle({ size: 'small', theme });
+ expect(pressedRadius).toBeLessThan(innerRadius);
+ expect(innerRadius).toBe(theme.shapes.corner.small);
+ expect(pressedRadius).toBe(theme.shapes.corner.extraSmall);
+ // outer edge stays fully rounded
+ expect(outerRadius).toBeGreaterThan(innerRadius);
+ });
+
+ it('uses the spec inner corners for large and extra-large', () => {
+ expect(connectedButtonSizeTokens.large.innerShape).toBe('large');
+ expect(connectedButtonSizeTokens['extra-large'].innerShape).toBe(
+ 'largeIncreased'
+ );
+ });
+});
+
+describe('getConnectedButtonPosition', () => {
+ it('classifies a lone button as single', () => {
+ expect(getConnectedButtonPosition(0, 1)).toBe('single');
+ });
+
+ it('classifies first, middle and last positions', () => {
+ expect(getConnectedButtonPosition(0, 3)).toBe('first');
+ expect(getConnectedButtonPosition(1, 3)).toBe('middle');
+ expect(getConnectedButtonPosition(2, 3)).toBe('last');
+ });
+});
+
+describe('getConnectedButtonColors', () => {
+ it('uses MD3 selection color roles', () => {
+ const selected = getConnectedButtonColors({ theme, selected: true });
+ expect(selected.containerColor).toBe(theme.colors.secondaryContainer);
+ expect(selected.contentColor).toBe(theme.colors.onSecondaryContainer);
+
+ const unselected = getConnectedButtonColors({ theme, selected: false });
+ expect(unselected.containerColor).toBe(theme.colors.surfaceContainer);
+ expect(unselected.contentColor).toBe(theme.colors.onSurfaceVariant);
+ });
+
+ it('dims content and keeps onSurface when disabled', () => {
+ const disabled = getConnectedButtonColors({
+ theme,
+ selected: false,
+ disabled: true,
+ });
+ expect(disabled.contentColor).toBe(theme.colors.onSurface);
+ expect(disabled.contentOpacity).toBe(0.38);
+ });
+
+ it('honours custom checked and unchecked colors', () => {
+ expect(
+ getConnectedButtonColors({
+ theme,
+ selected: true,
+ checkedColor: 'red',
+ }).contentColor
+ ).toBe('red');
+ expect(
+ getConnectedButtonColors({
+ theme,
+ selected: false,
+ uncheckedColor: 'blue',
+ }).contentColor
+ ).toBe('blue');
+ });
+});
+
+it('renders connected button group', async () => {
+ const tree = (await renderGroup()).toJSON();
+
+ expect(tree).toMatchSnapshot();
+});
+
+it('renders multi-select connected button group with icons', async () => {
+ const tree = (
+ await render(
+ {}}
+ buttons={[
+ { value: 'bold', icon: 'format-bold', 'aria-label': 'Bold' },
+ { value: 'italic', icon: 'format-italic', 'aria-label': 'Italic' },
+ ]}
+ />
+ )
+ ).toJSON();
+
+ expect(tree).toMatchSnapshot();
+});
+
+it('renders disabled connected button group', async () => {
+ const tree = (
+ await renderGroup({
+ buttons: buttons.map((button) => ({ ...button, disabled: true })),
+ })
+ ).toJSON();
+
+ expect(tree).toMatchSnapshot();
+});
+
+it('renders large connected button group', async () => {
+ const tree = (await renderGroup({ size: 'large' })).toJSON();
+
+ expect(tree).toMatchSnapshot();
+});
diff --git a/src/components/__tests__/__snapshots__/ConnectedButtonGroup.test.tsx.snap b/src/components/__tests__/__snapshots__/ConnectedButtonGroup.test.tsx.snap
new file mode 100644
index 0000000000..b8390ef01d
--- /dev/null
+++ b/src/components/__tests__/__snapshots__/ConnectedButtonGroup.test.tsx.snap
@@ -0,0 +1,1500 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`renders connected button group 1`] = `
+
+
+
+
+
+ Walking
+
+
+
+
+
+
+
+
+ Transit
+
+
+
+
+
+
+
+
+ Driving
+
+
+
+
+
+`;
+
+exports[`renders disabled connected button group 1`] = `
+
+
+
+
+
+
+ Walking
+
+
+
+
+
+
+
+
+
+ Transit
+
+
+
+
+
+
+
+
+
+ Driving
+
+
+
+
+
+`;
+
+exports[`renders large connected button group 1`] = `
+
+
+
+
+
+ Walking
+
+
+
+
+
+
+
+
+ Transit
+
+
+
+
+
+
+
+
+ Driving
+
+
+
+
+
+`;
+
+exports[`renders multi-select connected button group with icons 1`] = `
+
+
+
+
+
+
+ format-bold
+
+
+
+
+
+
+
+
+
+
+ format-italic
+
+
+
+
+
+
+`;
diff --git a/src/index.tsx b/src/index.tsx
index 8863e2fa20..b528c64cfd 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -29,6 +29,7 @@ export { default as Button } from './components/Button/Button';
export { default as Card } from './components/Card/Card';
export { default as Checkbox } from './components/Checkbox';
export { default as Chip } from './components/Chip/Chip';
+export { default as ConnectedButtonGroup } from './components/ConnectedButtonGroup';
export { default as DataTable } from './components/DataTable/DataTable';
export { default as Dialog } from './components/Dialog/Dialog';
export { default as Divider } from './components/Divider';
@@ -78,6 +79,11 @@ export type { Props as CardTitleProps } from './components/Card/CardTitle';
export type { Props as CheckboxProps } from './components/Checkbox/Checkbox';
export type { Props as CheckboxItemProps } from './components/Checkbox/CheckboxItem';
export type { Props as ChipProps } from './components/Chip/Chip';
+export type {
+ Props as ConnectedButtonGroupProps,
+ ConnectedButtonConfig,
+ ConnectedButtonGroupSize,
+} from './components/ConnectedButtonGroup';
export type { Props as DataTableProps } from './components/DataTable/DataTable';
export type { Props as DataTableCellProps } from './components/DataTable/DataTableCell';
export type { Props as DataTableHeaderProps } from './components/DataTable/DataTableHeader';