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..601a757762 100644
--- a/docs/6.x/docs/components/SegmentedButtons/SegmentedButtons.mdx
+++ b/docs/6.x/docs/components/SegmentedButtons/SegmentedButtons.mdx
@@ -8,6 +8,14 @@ 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`
+instead — it takes the same `buttons`, `value` and `onValueChange` props for
+both the single- and multi-select models. The only prop without a direct
+equivalent is `density`: pick a `size` from the Material Design 3
+button-group scale instead, where the default `"small"` (40dp) is the
+closest match to a `"regular"` density segmented button.
+
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 300c315b99..3b0563efcb 100644
--- a/docs/src/data/componentDocs6x.json
+++ b/docs/src/data/componentDocs6x.json
@@ -4661,6 +4661,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. On a button\n * that also has a label the check replaces the leading icon.\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",
@@ -10704,10 +10912,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 `ConnectedButtonGroup`\ninstead — it takes the same `buttons`, `value` and `onValueChange` props for\nboth the single- and multi-select models. The only prop without a direct\nequivalent is `density`: pick a `size` from the Material Design 3\nbutton-group scale instead, where the default `\"small\"` (40dp) is the\nclosest match to a `\"regular\"` density segmented button.\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 `ConnectedButtonGroup`\ninstead — it takes the same `buttons`, `value` and `onValueChange` props for\nboth the single- and multi-select models. The only prop without a direct\nequivalent is `density`: pick a `size` from the Material Design 3\nbutton-group scale instead, where the default `\"small\"` (40dp) is the\nclosest match to a `\"regular\"` density segmented button.\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 8a2646d798..854fd84382 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..92be909471
--- /dev/null
+++ b/src/components/ConnectedButtonGroup/ConnectedButton.tsx
@@ -0,0 +1,296 @@
+import * as React from 'react';
+import { StyleSheet, View } from 'react-native';
+import type { GestureResponderEvent } from 'react-native';
+
+import Animated, {
+ ReduceMotion,
+ useAnimatedStyle,
+ useDerivedValue,
+ useSharedValue,
+ withSpring,
+} from 'react-native-reanimated';
+
+import type { ConnectedButtonProps as Props } from './types';
+import {
+ getConnectedButtonColors,
+ getConnectedButtonSizeStyle,
+ getTestID,
+ morphingCorners,
+} from './utils';
+import { useInternalTheme } from '../../core/theming';
+import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext';
+import { toRawSpring } from '../../theme/tokens/sys/motion';
+import Icon from '../Icon';
+import TouchableRipple from '../TouchableRipple/TouchableRipple';
+import Text from '../Typography/Text';
+
+/**
+ * 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 pressed = useSharedValue(false);
+
+ // Selection-check behaviour matches `SegmentedButtonItem` for migration
+ // parity: the check scales in and takes the place of the leading icon on a
+ // labelled button, while an icon-only button keeps its icon alongside it.
+ const showCheck = Boolean(checked && showSelectedCheck);
+ const showIcon = Boolean(icon) && !(label && showCheck);
+ const checkScale = useSharedValue(showCheck ? 1 : 0);
+
+ 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]
+ );
+
+ React.useEffect(() => {
+ // Springs to the value it already holds on mount, which is a no-op.
+ checkScale.value = withSpring(showCheck ? 1 : 0, springConfig);
+ }, [showCheck, checkScale, springConfig]);
+
+ // Only the raw pressed flag is tracked here; the radius itself is derived
+ // from props in the worklet below, so no handler writes a radius directly.
+ const handlePressIn = React.useCallback(() => {
+ pressed.value = true;
+ }, [pressed]);
+ const handlePressOut = React.useCallback(() => {
+ pressed.value = false;
+ }, [pressed]);
+ // Clearing on press as well as press-out matters: re-pressing the already
+ // selected button reports the same value, so the group never re-renders and
+ // nothing else would restore the shape. A press-out swallowed by a gesture
+ // race used to leave the button stuck at its pressed corner indefinitely.
+ const handlePress = React.useCallback(
+ (event: GestureResponderEvent) => {
+ pressed.value = false;
+ onPress?.(event);
+ },
+ [pressed, onPress]
+ );
+
+ const { start: animateStart, end: animateEnd } = morphingCorners[position];
+
+ // A single spring owns the morphing radius, derived from the current props
+ // rather than written by the press handlers and the selection separately —
+ // those used to fire competing springs on one shared value, so a tap ran
+ // three transitions (pressed, old rest, new rest) instead of one.
+ const cornerRadius = useDerivedValue(
+ () => withSpring(pressed.value ? pressedRadius : restRadius, springConfig),
+ [restRadius, pressedRadius, springConfig]
+ );
+
+ const animatedShapeStyle = useAnimatedStyle(() => {
+ // Read the plain number. A `withSpring` result is a stateful animation
+ // object and must never be handed to more than one style prop: four
+ // corners sharing one object step the same state machine four times a
+ // frame and desync.
+ 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 checkIconStyle = useAnimatedStyle(() => ({
+ transform: [{ scale: checkScale.value }],
+ }));
+
+ // The mirror of `checkIconStyle`, and it cannot be flattened to a constant.
+ // `useAnimatedStyle` caches its initial style from the component's *first*
+ // render, so whichever of the two remounts is painted at that cached value
+ // before the animation can take over. The icon only has something to grow
+ // from because `checkScale` still holds the previous selection when it
+ // mounts — see the effect above, whose after-paint timing is load-bearing.
+ // Icon-only buttons show both at once and never swap, so they stay at 1.
+ const hasLabel = Boolean(label);
+ const leadingIconStyle = useAnimatedStyle(
+ () => ({
+ transform: [{ scale: hasLabel ? 1 - checkScale.value : 1 }],
+ }),
+ [hasLabel]
+ );
+
+ // A gap is needed before the label, and between the check and the leading
+ // icon when an icon-only button shows both.
+ const iconGap =
+ label || (showCheck && showIcon)
+ ? { marginEnd: sizeStyle.iconLabelGap }
+ : null;
+
+ // When the container is translucent (disabled), the fill is drawn by the
+ // overlay below, so the base view stays transparent.
+ const hasContainerOverlay = colors.containerOpacity < 1;
+ const containerBackground = hasContainerOverlay
+ ? undefined
+ : colors.containerColor;
+
+ return (
+
+ {hasContainerOverlay ? (
+ // 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: {
+ // `flex: 1` (basis 0), like `SegmentedButtonItem`, so every button in the
+ // group is the same width. With a content-derived basis the row reflows
+ // whenever the selection check mounts or unmounts, which reads as the
+ // buttons snapping to a new width on every tap.
+ flex: 1,
+ 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/types.ts b/src/components/ConnectedButtonGroup/types.ts
new file mode 100644
index 0000000000..2c07eaf7c2
--- /dev/null
+++ b/src/components/ConnectedButtonGroup/types.ts
@@ -0,0 +1,116 @@
+import type {
+ GestureResponderEvent,
+ PressableAndroidRippleConfig,
+ StyleProp,
+ TextStyle,
+ ViewStyle,
+} from 'react-native';
+
+import type { ConnectedButtonGroupSize } from './tokens';
+import type { ThemeProp } from '../../types';
+import type { IconSource } from '../Icon';
+import type { Props as TouchableRippleProps } from '../TouchableRipple/TouchableRipple';
+
+/**
+ * Position of a button within the connected group. Determines which corners
+ * stay pinned to the outer (fully-rounded) radius and which morph.
+ */
+export const connectedButtonPositions = {
+ single: 'single',
+ first: 'first',
+ middle: 'middle',
+ last: 'last',
+} as const;
+
+export type ConnectedButtonPosition =
+ (typeof connectedButtonPositions)[keyof typeof connectedButtonPositions];
+
+/**
+ * Props of a single button rendered by the group. Everything a
+ * `ConnectedButtonConfig` entry carries, plus the state the group derives for
+ * it (`checked`, `position`, `size`, `multiSelect`).
+ *
+ * The overlap with `ConnectedButtonConfig` is deliberate: the docs generator
+ * only expands the `buttons` API table when the config is an inline object
+ * literal in `ConnectedButtonGroup.tsx`.
+ */
+export type ConnectedButtonProps = {
+ /**
+ * 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 to indicate the selected state. When the
+ * button also has a label, the check replaces the leading icon.
+ */
+ 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;
+};
diff --git a/src/components/ConnectedButtonGroup/utils.ts b/src/components/ConnectedButtonGroup/utils.ts
new file mode 100644
index 0000000000..10b1a9a897
--- /dev/null
+++ b/src/components/ConnectedButtonGroup/utils.ts
@@ -0,0 +1,149 @@
+import type { ColorValue } from 'react-native';
+
+import {
+ connectedButtonSizeTokens,
+ type ConnectedButtonGroupSize,
+} from './tokens';
+import {
+ connectedButtonPositions,
+ type ConnectedButtonPosition,
+} from './types';
+import { tokens } from '../../theme/tokens';
+import { resolveCornerRadius, type ShapeToken } 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;
+
+export const getConnectedButtonPosition = (
+ index: number,
+ count: number
+): ConnectedButtonPosition => {
+ if (count <= 1) {
+ return connectedButtonPositions.single;
+ }
+ if (index === 0) {
+ return connectedButtonPositions.first;
+ }
+ if (index === count - 1) {
+ return connectedButtonPositions.last;
+ }
+ return connectedButtonPositions.middle;
+};
+
+/**
+ * Which of a button's two sides morph with selection and press.
+ */
+export const morphingCorners: Record<
+ ConnectedButtonPosition,
+ { start: boolean; end: boolean }
+> = {
+ single: { start: false, end: false },
+ first: { start: false, end: true },
+ middle: { start: true, end: true },
+ last: { start: true, end: false },
+};
+
+export const getConnectedButtonSizeStyle = ({
+ size,
+ theme,
+}: {
+ size: ConnectedButtonGroupSize;
+ theme: InternalTheme;
+}) => {
+ const sizeTokens = connectedButtonSizeTokens[size];
+
+ const pillRadius = sizeTokens.containerHeight / 2;
+ const resolve = (token: ShapeToken) =>
+ token === 'full' ? pillRadius : resolveCornerRadius(theme, token);
+
+ return {
+ ...sizeTokens,
+ outerRadius: resolve(sizeTokens.outerShape),
+ innerRadius: resolve(sizeTokens.innerShape),
+ pressedRadius: resolve(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 };
+};
+
+export const getTestID = (testID: string | undefined, suffix: string) =>
+ testID ? `${testID}-${suffix}` : undefined;
diff --git a/src/components/SegmentedButtons/SegmentedButtons.tsx b/src/components/SegmentedButtons/SegmentedButtons.tsx
index c5fceeaeae..b260dde711 100644
--- a/src/components/SegmentedButtons/SegmentedButtons.tsx
+++ b/src/components/SegmentedButtons/SegmentedButtons.tsx
@@ -81,6 +81,14 @@ export type Props = {
} & ConditionalValue;
/**
+ * @deprecated Segmented buttons are deprecated in the Material Design 3 spec
+ * and replaced by the Connected Button Group. Use `ConnectedButtonGroup`
+ * instead — it takes the same `buttons`, `value` and `onValueChange` props for
+ * both the single- and multi-select models. The only prop without a direct
+ * equivalent is `density`: pick a `size` from the Material Design 3
+ * button-group scale instead, where the default `"small"` (40dp) is the
+ * closest match to a `"regular"` density segmented button.
+ *
* 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..bbffa2902e
--- /dev/null
+++ b/src/components/__tests__/ConnectedButtonGroup.test.tsx
@@ -0,0 +1,404 @@
+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 { cornerFull } from '../../theme/tokens/sys/shape';
+import ConnectedButtonGroup from '../ConnectedButtonGroup/ConnectedButtonGroup';
+import {
+ connectedButtonSizeTokens,
+ type ConnectedButtonGroupSize,
+} 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('swaps the leading icon for the selection check on labelled buttons', async () => {
+ await renderGroup({
+ buttons: [
+ {
+ value: 'walk',
+ label: 'Walking',
+ icon: 'walk',
+ showSelectedCheck: true,
+ testID: 'walk',
+ },
+ {
+ value: 'train',
+ label: 'Transit',
+ icon: 'train',
+ showSelectedCheck: true,
+ testID: 'train',
+ },
+ ],
+ });
+
+ expect(screen.getByTestId('walk-check-icon')).toBeTruthy();
+ expect(screen.queryByTestId('walk-icon')).toBeNull();
+
+ expect(screen.getByTestId('train-icon')).toBeTruthy();
+ expect(screen.queryByTestId('train-check-icon')).toBeNull();
+});
+
+it('keeps the icon of a selected icon-only button', async () => {
+ await renderGroup({
+ buttons: [
+ { value: 'walk', icon: 'walk', showSelectedCheck: true, testID: 'walk' },
+ ],
+ });
+
+ expect(screen.getByTestId('walk-check-icon')).toBeTruthy();
+ expect(screen.getByTestId('walk-icon')).toBeTruthy();
+});
+
+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');
+});
+
+it('morphs only the connected corners of each position', async () => {
+ const { innerRadius, outerRadius } = getConnectedButtonSizeStyle({
+ size: 'small',
+ theme,
+ });
+
+ await renderGroup({ value: 'train' });
+
+ // first, unselected — leading corner pinned, trailing corner morphs
+ expect(screen.getByTestId('walk-container')).toHaveStyle({
+ borderTopStartRadius: outerRadius,
+ borderTopEndRadius: innerRadius,
+ });
+ // middle, selected — both sides morph, so both reach the pill
+ expect(screen.getByTestId('train-container')).toHaveStyle({
+ borderTopStartRadius: outerRadius,
+ borderTopEndRadius: outerRadius,
+ });
+ // last, unselected — leading corner morphs, trailing corner pinned
+ expect(screen.getByTestId('drive-container')).toHaveStyle({
+ borderTopStartRadius: innerRadius,
+ borderTopEndRadius: outerRadius,
+ });
+});
+
+it('leaves a lone button fully rounded on both sides', async () => {
+ const { outerRadius } = getConnectedButtonSizeStyle({
+ size: 'small',
+ theme,
+ });
+
+ await renderGroup({
+ buttons: [{ value: 'walk', label: 'Walking', testID: 'walk' }],
+ });
+
+ expect(screen.getByTestId('walk-container')).toHaveStyle({
+ borderTopStartRadius: outerRadius,
+ borderTopEndRadius: outerRadius,
+ });
+});
+
+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('resolves the pill corner to half the height, not the full-corner sentinel', () => {
+ const sizes: ConnectedButtonGroupSize[] = [
+ 'extra-small',
+ 'small',
+ 'medium',
+ 'large',
+ 'extra-large',
+ ];
+ expect(sizes).toHaveLength(Object.keys(connectedButtonSizeTokens).length);
+
+ sizes.forEach((size) => {
+ const { outerRadius, containerHeight } = getConnectedButtonSizeStyle({
+ size,
+ theme,
+ });
+
+ expect(connectedButtonSizeTokens[size].outerShape).toBe('full');
+ expect(outerRadius).toBe(containerHeight / 2);
+ expect(outerRadius).toBeLessThan(cornerFull);
+ });
+ });
+
+ 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..8dd4817855
--- /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';