diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js
index a4b672640c..6112c96a07 100644
--- a/docs/docusaurus.config.js
+++ b/docs/docusaurus.config.js
@@ -160,6 +160,9 @@ const config = {
SegmentedButtons: 'SegmentedButtons/SegmentedButtons',
},
Snackbar: 'Snackbar',
+ SplitButton: {
+ SplitButton: 'SplitButton/SplitButton',
+ },
Surface: 'Surface',
Switch: {
Switch: 'Switch/Switch',
diff --git a/docs/src/data/themeColors.js b/docs/src/data/themeColors.js
index 2e97dd1bce..4fad140f61 100644
--- a/docs/src/data/themeColors.js
+++ b/docs/src/data/themeColors.js
@@ -307,6 +307,32 @@ const themeColors = {
iconColor: 'theme.colors.inverseOnSurface',
},
},
+ SplitButton: {
+ active: {
+ filled: {
+ backgroundColor: 'theme.colors.primary',
+ textColor: 'theme.colors.onPrimary',
+ },
+ tonal: {
+ backgroundColor: 'theme.colors.secondaryContainer',
+ textColor: 'theme.colors.onSecondaryContainer',
+ },
+ elevated: {
+ backgroundColor: 'theme.colors.surfaceContainerLow',
+ textColor: 'theme.colors.primary',
+ },
+ outlined: {
+ textColor: 'theme.colors.onSurfaceVariant',
+ borderColor: 'theme.colors.outline',
+ },
+ },
+ disabled: {
+ '-': {
+ backgroundColor: 'theme.colors.onSurface',
+ textColor: 'theme.colors.onSurface',
+ },
+ },
+ },
Surface: {
flat: {
backgroundColor: 'theme.colors.elevation[elevation]',
diff --git a/docs/static/llms.txt b/docs/static/llms.txt
index 3244326f67..38bb058c0b 100644
--- a/docs/static/llms.txt
+++ b/docs/static/llms.txt
@@ -51,6 +51,7 @@
- Searchbar: https://callstack.github.io/react-native-paper/docs/components/Searchbar
- SegmentedButtons: https://callstack.github.io/react-native-paper/docs/components/SegmentedButtons
- Snackbar: https://callstack.github.io/react-native-paper/docs/components/Snackbar
+- SplitButton: https://callstack.github.io/react-native-paper/docs/components/SplitButton
- Surface: https://callstack.github.io/react-native-paper/docs/components/Surface
- Switch: https://callstack.github.io/react-native-paper/docs/components/Switch
- Text: https://callstack.github.io/react-native-paper/docs/components/Text
diff --git a/example/src/ExampleList.tsx b/example/src/ExampleList.tsx
index 18c958a4bb..4da8d4abea 100644
--- a/example/src/ExampleList.tsx
+++ b/example/src/ExampleList.tsx
@@ -37,6 +37,7 @@ import SegmentedButtonMultiselectRealCase from './Examples/SegmentedButtons/Segm
import SegmentedButtonRealCase from './Examples/SegmentedButtons/SegmentedButtonRealCase';
import SegmentedButtonExample from './Examples/SegmentedButtonsExample';
import SnackbarExample from './Examples/SnackbarExample';
+import SplitButtonExample from './Examples/SplitButtonExample';
import SurfaceExample from './Examples/SurfaceExample';
import SwitchExample from './Examples/SwitchExample';
import TeamDetails from './Examples/TeamDetails';
@@ -80,6 +81,7 @@ export const mainExamples = {
Searchbar: SearchbarExample,
SegmentedButton: SegmentedButtonExample,
Snackbar: SnackbarExample,
+ SplitButton: SplitButtonExample,
Surface: SurfaceExample,
Switch: SwitchExample,
Text: TextExample,
diff --git a/example/src/Examples/SplitButtonExample.tsx b/example/src/Examples/SplitButtonExample.tsx
new file mode 100644
index 0000000000..e6ed5aebe4
--- /dev/null
+++ b/example/src/Examples/SplitButtonExample.tsx
@@ -0,0 +1,127 @@
+import * as React from 'react';
+import { StyleSheet, View } from 'react-native';
+
+import { List, Menu, SplitButton, Switch, useTheme } from 'react-native-paper';
+
+import ScreenWrapper from '../ScreenWrapper';
+
+const modes = ['filled', 'tonal', 'elevated', 'outlined'] as const;
+const SplitButtonExample = () => {
+ const [menuVisible, setMenuVisible] = React.useState(false);
+ const [disabled, setDisabled] = React.useState(false);
+ const [loading, setLoading] = React.useState(false);
+ const theme = useTheme();
+
+ return (
+
+
+
+
+
+ }
+ />
+ }
+ />
+
+
+
+
+ {modes.map((mode) => (
+ {}}
+ onTrailingPress={() => {}}
+ trailingAccessibilityLabel={`${mode} options`}
+ />
+ ))}
+
+
+
+
+
+ {}}
+ onTrailingPress={() => {}}
+ trailingAccessibilityLabel="Custom color options"
+ />
+ {}}
+ onTrailingPress={() => {}}
+ trailingAccessibilityLabel="Custom label options"
+ />
+
+
+
+ );
+};
+
+SplitButtonExample.title = 'SplitButton';
+
+const styles = StyleSheet.create({
+ playground: {
+ paddingHorizontal: 16,
+ paddingVertical: 8,
+ alignItems: 'flex-start',
+ },
+ row: {
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ alignItems: 'center',
+ paddingHorizontal: 12,
+ gap: 12,
+ },
+ column: {
+ alignItems: 'flex-start',
+ paddingHorizontal: 16,
+ gap: 16,
+ },
+ boldLabel: {
+ fontWeight: '800',
+ },
+});
+
+export default SplitButtonExample;
diff --git a/src/components/SplitButton/SplitButton.tsx b/src/components/SplitButton/SplitButton.tsx
new file mode 100644
index 0000000000..b4eaaee75d
--- /dev/null
+++ b/src/components/SplitButton/SplitButton.tsx
@@ -0,0 +1,671 @@
+import * as React from 'react';
+import {
+ AccessibilityState,
+ Animated,
+ ColorValue,
+ Easing,
+ GestureResponderEvent,
+ PressableAndroidRippleConfig,
+ StyleProp,
+ StyleSheet,
+ TextStyle,
+ View,
+ ViewStyle,
+} from 'react-native';
+
+import { splitButtonElevation, type SplitButtonSize } from './tokens';
+import {
+ getSplitButtonColors,
+ getSplitButtonHitSlop,
+ getSplitButtonLeadingShape,
+ getSplitButtonRippleColor,
+ getSplitButtonSizeStyle,
+ getSplitButtonTrailingShape,
+ normalizeSplitButtonMode,
+ type SplitButtonMode,
+} from './utils';
+import { useInternalTheme } from '../../core/theming';
+import type { $Omit, Theme, ThemeProp } from '../../types';
+import { forwardRef } from '../../utils/forwardRef';
+import hasTouchHandler from '../../utils/hasTouchHandler';
+import ActivityIndicator from '../ActivityIndicator';
+import { getButtonTouchableRippleStyle } from '../Button/utils';
+import Icon, { IconSource } from '../Icon';
+import Surface from '../Surface';
+import TouchableRipple, {
+ Props as TouchableRippleProps,
+} from '../TouchableRipple/TouchableRipple';
+import Text from '../Typography/Text';
+
+export type Props = $Omit<
+ React.ComponentProps,
+ 'children' | 'mode'
+> & {
+ /**
+ * Mode of the split button.
+ * - `filled` - high-emphasis split button for important or final actions.
+ * - `tonal` - medium-emphasis split button using secondary container colors.
+ * - `elevated` - tonal split button with elevation for separation from busy surfaces.
+ * - `outlined` - medium-emphasis split button with transparent containers and outline.
+ */
+ mode?: SplitButtonMode;
+ /**
+ * Size of the split button.
+ */
+ size?: SplitButtonSize;
+ /**
+ * Label text for the leading button.
+ */
+ label: string;
+ /**
+ * Icon to display before the label in the leading button.
+ */
+ icon?: IconSource;
+ /**
+ * Icon to display in the trailing button.
+ */
+ trailingIcon?: IconSource;
+ /**
+ * Whether to show a loading indicator in the leading button.
+ */
+ loading?: boolean;
+ /**
+ * Whether both buttons are disabled.
+ */
+ disabled?: boolean;
+ /**
+ * Custom container color for both buttons.
+ */
+ buttonColor?: ColorValue;
+ /**
+ * Custom content color for icons and label.
+ */
+ textColor?: ColorValue;
+ /**
+ * Custom ripple color for both buttons.
+ */
+ rippleColor?: ColorValue;
+ /**
+ * Function to execute when the leading button is pressed.
+ */
+ onPress?: (e: GestureResponderEvent) => void;
+ /**
+ * Function to execute when the trailing button is pressed.
+ */
+ onTrailingPress?: (e: GestureResponderEvent) => void;
+ /**
+ * Function to execute as soon as the leading button is pressed.
+ */
+ onPressIn?: (e: GestureResponderEvent) => void;
+ /**
+ * Function to execute when the leading button press is released.
+ */
+ onPressOut?: (e: GestureResponderEvent) => void;
+ /**
+ * Function to execute as soon as the trailing button is pressed.
+ */
+ onTrailingPressIn?: (e: GestureResponderEvent) => void;
+ /**
+ * Function to execute when the trailing button press is released.
+ */
+ onTrailingPressOut?: (e: GestureResponderEvent) => void;
+ /**
+ * Function to execute when the leading button is long pressed.
+ */
+ onLongPress?: (e: GestureResponderEvent) => void;
+ /**
+ * Function to execute when the trailing button is long pressed.
+ */
+ onTrailingLongPress?: (e: GestureResponderEvent) => void;
+ /**
+ * The number of milliseconds a user must touch the leading button before executing `onLongPress`.
+ */
+ delayLongPress?: number;
+ /**
+ * The number of milliseconds a user must touch the trailing button before executing `onTrailingLongPress`.
+ */
+ trailingDelayLongPress?: number;
+ /**
+ * Accessibility label for the leading button. Falls back to `label`.
+ */
+ accessibilityLabel?: string;
+ /**
+ * Accessibility label for the trailing button.
+ */
+ trailingAccessibilityLabel?: string;
+ /**
+ * Accessibility state for the leading button.
+ */
+ accessibilityState?: AccessibilityState;
+ /**
+ * Accessibility state for the trailing button.
+ */
+ trailingAccessibilityState?: AccessibilityState;
+ /**
+ * Type of background drawable to display the feedback (Android).
+ * https://reactnative.dev/docs/pressable#rippleconfig
+ */
+ background?: PressableAndroidRippleConfig;
+ /**
+ * Style for the outer split-button group.
+ */
+ style?: Animated.WithAnimatedValue>;
+ /**
+ * Style for both button containers.
+ */
+ buttonStyle?: StyleProp;
+ /**
+ * Style for the leading button container.
+ */
+ leadingButtonStyle?: StyleProp;
+ /**
+ * Style for the trailing button container.
+ */
+ trailingButtonStyle?: StyleProp;
+ /**
+ * Style for the leading button content row.
+ */
+ contentStyle?: StyleProp;
+ /**
+ * Style for the label.
+ */
+ labelStyle?: StyleProp;
+ /**
+ * Specifies the largest possible scale a label font can reach.
+ */
+ maxFontSizeMultiplier?: number;
+ /**
+ * Sets additional distance outside of the leading button in which a press can be detected.
+ */
+ hitSlop?: TouchableRippleProps['hitSlop'];
+ /**
+ * Sets additional distance outside of the trailing button in which a press can be detected.
+ */
+ trailingHitSlop?: TouchableRippleProps['hitSlop'];
+ /**
+ * @optional
+ */
+ theme?: ThemeProp;
+ /**
+ * TestID used for testing purposes.
+ */
+ testID?: string;
+ ref?: React.RefObject;
+};
+
+/**
+ * Split buttons let people trigger a primary action from the leading button
+ * and open or trigger a contextual action from the trailing button.
+ *
+ * ## Usage
+ * ```js
+ * import * as React from 'react';
+ * import { SplitButton } from 'react-native-paper';
+ *
+ * const MyComponent = () => (
+ * console.log('Send')}
+ * onTrailingPress={() => console.log('Show options')}
+ * />
+ * );
+ *
+ * export default MyComponent;
+ * ```
+ */
+const SplitButton = forwardRef(
+ (
+ {
+ mode = 'filled',
+ size = 'small',
+ label,
+ icon,
+ trailingIcon = 'menu-down',
+ loading,
+ disabled,
+ buttonColor: customButtonColor,
+ textColor: customTextColor,
+ rippleColor: customRippleColor,
+ onPress,
+ onTrailingPress,
+ onPressIn,
+ onPressOut,
+ onTrailingPressIn,
+ onTrailingPressOut,
+ onLongPress,
+ onTrailingLongPress,
+ delayLongPress,
+ trailingDelayLongPress,
+ accessibilityLabel = label,
+ trailingAccessibilityLabel = 'Show options',
+ accessibilityState,
+ trailingAccessibilityState,
+ background,
+ style,
+ buttonStyle,
+ leadingButtonStyle,
+ trailingButtonStyle,
+ contentStyle,
+ labelStyle,
+ maxFontSizeMultiplier,
+ hitSlop,
+ trailingHitSlop,
+ theme: themeOverrides,
+ testID = 'split-button',
+ ...rest
+ },
+ ref
+ ) => {
+ const theme = useInternalTheme(themeOverrides);
+ const normalizedMode = normalizeSplitButtonMode(mode);
+ const [pressedButton, setPressedButton] = React.useState<
+ 'leading' | 'trailing' | null
+ >(null);
+ const sizeStyle = React.useMemo(
+ () => getSplitButtonSizeStyle({ size, theme }),
+ [size, theme]
+ );
+ const colors = React.useMemo(
+ () =>
+ getSplitButtonColors({
+ theme,
+ mode,
+ disabled,
+ customButtonColor,
+ customTextColor,
+ }),
+ [theme, mode, disabled, customButtonColor, customTextColor]
+ );
+ const rippleColor = React.useMemo(
+ () =>
+ getSplitButtonRippleColor({
+ contentColor: colors.contentColor,
+ customRippleColor,
+ }),
+ [colors.contentColor, customRippleColor]
+ );
+ const leadingShape = React.useMemo(
+ () =>
+ getSplitButtonLeadingShape({
+ containerRadius: sizeStyle.containerRadius,
+ innerRadius:
+ pressedButton === 'leading'
+ ? sizeStyle.innerPressedRadius
+ : sizeStyle.innerRadius,
+ }),
+ [
+ pressedButton,
+ sizeStyle.containerRadius,
+ sizeStyle.innerPressedRadius,
+ sizeStyle.innerRadius,
+ ]
+ );
+ const trailingShape = React.useMemo(
+ () =>
+ getSplitButtonTrailingShape({
+ containerRadius: sizeStyle.containerRadius,
+ innerRadius:
+ pressedButton === 'trailing'
+ ? sizeStyle.innerPressedRadius
+ : sizeStyle.innerRadius,
+ }),
+ [
+ pressedButton,
+ sizeStyle.containerRadius,
+ sizeStyle.innerPressedRadius,
+ sizeStyle.innerRadius,
+ ]
+ );
+ const leadingHitSlop = React.useMemo(
+ () => getSplitButtonHitSlop({ size, hitSlop }),
+ [size, hitSlop]
+ );
+ const resolvedTrailingHitSlop = React.useMemo(
+ () => getSplitButtonHitSlop({ size, hitSlop: trailingHitSlop }),
+ [size, trailingHitSlop]
+ );
+
+ const { color: customLabelColor, fontSize: customLabelSize } =
+ StyleSheet.flatten(labelStyle) || {};
+ const contentColor =
+ typeof customLabelColor === 'string'
+ ? customLabelColor
+ : colors.contentColor;
+ const labelTextStyle: TextStyle = {
+ color: colors.contentColor,
+ ...(theme as Theme).fonts[sizeStyle.labelVariant],
+ };
+ const disabledState = { disabled: true };
+ const leadingAccessibilityState = disabled
+ ? { ...accessibilityState, ...disabledState }
+ : accessibilityState;
+ const trailingAccessibilityStateWithDisabled = disabled
+ ? { ...trailingAccessibilityState, ...disabledState }
+ : trailingAccessibilityState;
+ const isElevationEntitled = !disabled && normalizedMode === 'elevated';
+ const { current: elevation } = React.useRef(
+ new Animated.Value(isElevationEntitled ? splitButtonElevation.enabled : 0)
+ );
+ const animateElevation = React.useCallback(
+ (toValue: number) => {
+ if (!isElevationEntitled) {
+ return;
+ }
+
+ Animated.timing(elevation, {
+ toValue,
+ duration:
+ toValue === splitButtonElevation.pressed
+ ? theme.motion.duration.short4
+ : theme.motion.duration.short3,
+ easing: Easing.bezier(...theme.motion.easing.standard),
+ useNativeDriver: false,
+ }).start();
+ },
+ [
+ elevation,
+ isElevationEntitled,
+ theme.motion.duration.short3,
+ theme.motion.duration.short4,
+ theme.motion.easing.standard,
+ ]
+ );
+ const leadingHasTouchHandler = hasTouchHandler({
+ onPress,
+ onPressIn,
+ onPressOut,
+ onLongPress,
+ });
+ const trailingHasTouchHandler = hasTouchHandler({
+ onPress: onTrailingPress,
+ onPressIn: onTrailingPressIn,
+ onPressOut: onTrailingPressOut,
+ onLongPress: onTrailingLongPress,
+ });
+ const handleLeadingPressIn = React.useCallback(
+ (e: GestureResponderEvent) => {
+ onPressIn?.(e);
+ setPressedButton('leading');
+ animateElevation(splitButtonElevation.pressed);
+ },
+ [animateElevation, onPressIn]
+ );
+ const handleLeadingPressOut = React.useCallback(
+ (e: GestureResponderEvent) => {
+ onPressOut?.(e);
+ setPressedButton(null);
+ animateElevation(splitButtonElevation.enabled);
+ },
+ [animateElevation, onPressOut]
+ );
+ const handleTrailingPressIn = React.useCallback(
+ (e: GestureResponderEvent) => {
+ onTrailingPressIn?.(e);
+ setPressedButton('trailing');
+ animateElevation(splitButtonElevation.pressed);
+ },
+ [animateElevation, onTrailingPressIn]
+ );
+ const handleTrailingPressOut = React.useCallback(
+ (e: GestureResponderEvent) => {
+ onTrailingPressOut?.(e);
+ setPressedButton(null);
+ animateElevation(splitButtonElevation.enabled);
+ },
+ [animateElevation, onTrailingPressOut]
+ );
+ React.useEffect(() => {
+ if (!isElevationEntitled) {
+ setPressedButton(null);
+ }
+
+ Animated.timing(elevation, {
+ toValue: isElevationEntitled ? splitButtonElevation.enabled : 0,
+ duration: 0,
+ useNativeDriver: false,
+ }).start();
+ }, [elevation, isElevationEntitled]);
+
+ const commonButtonStyle: ViewStyle = {
+ height: sizeStyle.containerHeight,
+ backgroundColor:
+ colors.containerOpacity < 1 ? 'transparent' : colors.containerColor,
+ borderColor: colors.borderColor,
+ borderWidth: colors.borderWidth,
+ };
+
+ return (
+ >
+ }
+ >
+
+
+
+
+ {icon && !loading ? (
+
+ ) : null}
+ {loading ? (
+
+ ) : null}
+
+ {label}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+);
+
+const ButtonBackground = ({
+ backgroundColor,
+ opacity,
+ borderRadiusStyle,
+}: {
+ backgroundColor: ColorValue;
+ opacity: number;
+ borderRadiusStyle: ViewStyle;
+}) => {
+ if (opacity >= 1) {
+ return null;
+ }
+
+ return (
+
+ );
+};
+
+const styles = StyleSheet.create({
+ group: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ maxWidth: '100%',
+ },
+ leading: {
+ minWidth: 48,
+ flexShrink: 1,
+ borderStyle: 'solid',
+ },
+ trailing: {
+ minWidth: 48,
+ borderStyle: 'solid',
+ },
+ ripple: {
+ height: '100%',
+ },
+ leadingContent: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ label: {
+ flexShrink: 1,
+ },
+ trailingContent: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+});
+
+export default SplitButton;
+
+// @component-docs ignore-next-line
+export { SplitButton };
diff --git a/src/components/SplitButton/index.ts b/src/components/SplitButton/index.ts
new file mode 100644
index 0000000000..8a70e0e614
--- /dev/null
+++ b/src/components/SplitButton/index.ts
@@ -0,0 +1,2 @@
+export { default } from './SplitButton';
+export type { Props } from './SplitButton';
diff --git a/src/components/SplitButton/tokens.ts b/src/components/SplitButton/tokens.ts
new file mode 100644
index 0000000000..75c4d569cd
--- /dev/null
+++ b/src/components/SplitButton/tokens.ts
@@ -0,0 +1,108 @@
+import type { ThemeShapeCorners, TypescaleKey } from '../../theme/types';
+
+export type SplitButtonSize =
+ | 'extra-small'
+ | 'small'
+ | 'medium'
+ | 'large'
+ | 'extra-large';
+
+export type SplitButtonShapeKey = keyof ThemeShapeCorners | 'full';
+
+export type SplitButtonSizeTokens = {
+ betweenSpace: number;
+ containerHeight: number;
+ containerShape: SplitButtonShapeKey;
+ innerCornerShape: SplitButtonShapeKey;
+ innerPressedCornerShape: SplitButtonShapeKey;
+ leadingButtonLeadingSpace: number;
+ leadingButtonTrailingSpace: number;
+ leadingIconSize: number;
+ trailingButtonLeadingSpace: number;
+ trailingButtonTrailingSpace: number;
+ trailingIconSize: number;
+ labelVariant: TypescaleKey;
+};
+
+export const splitButtonSizeTokens: Record<
+ SplitButtonSize,
+ SplitButtonSizeTokens
+> = {
+ 'extra-small': {
+ betweenSpace: 2,
+ containerHeight: 32,
+ containerShape: 'full',
+ innerCornerShape: 'extraSmall',
+ innerPressedCornerShape: 'small',
+ leadingButtonLeadingSpace: 12,
+ leadingButtonTrailingSpace: 10,
+ leadingIconSize: 20,
+ trailingButtonLeadingSpace: 13,
+ trailingButtonTrailingSpace: 13,
+ trailingIconSize: 22,
+ labelVariant: 'labelLarge',
+ },
+ small: {
+ betweenSpace: 2,
+ containerHeight: 40,
+ containerShape: 'full',
+ innerCornerShape: 'extraSmall',
+ innerPressedCornerShape: 'medium',
+ leadingButtonLeadingSpace: 16,
+ leadingButtonTrailingSpace: 12,
+ leadingIconSize: 20,
+ trailingButtonLeadingSpace: 13,
+ trailingButtonTrailingSpace: 13,
+ trailingIconSize: 22,
+ labelVariant: 'labelLarge',
+ },
+ medium: {
+ betweenSpace: 2,
+ containerHeight: 56,
+ containerShape: 'full',
+ innerCornerShape: 'extraSmall',
+ innerPressedCornerShape: 'medium',
+ leadingButtonLeadingSpace: 24,
+ leadingButtonTrailingSpace: 24,
+ leadingIconSize: 24,
+ trailingButtonLeadingSpace: 15,
+ trailingButtonTrailingSpace: 15,
+ trailingIconSize: 26,
+ labelVariant: 'titleMedium',
+ },
+ large: {
+ betweenSpace: 2,
+ containerHeight: 96,
+ containerShape: 'full',
+ innerCornerShape: 'small',
+ innerPressedCornerShape: 'largeIncreased',
+ leadingButtonLeadingSpace: 48,
+ leadingButtonTrailingSpace: 48,
+ leadingIconSize: 32,
+ trailingButtonLeadingSpace: 29,
+ trailingButtonTrailingSpace: 29,
+ trailingIconSize: 38,
+ labelVariant: 'headlineSmall',
+ },
+ 'extra-large': {
+ betweenSpace: 2,
+ containerHeight: 136,
+ containerShape: 'full',
+ innerCornerShape: 'medium',
+ innerPressedCornerShape: 'largeIncreased',
+ leadingButtonLeadingSpace: 64,
+ leadingButtonTrailingSpace: 64,
+ leadingIconSize: 40,
+ trailingButtonLeadingSpace: 43,
+ trailingButtonTrailingSpace: 43,
+ trailingIconSize: 50,
+ labelVariant: 'headlineLarge',
+ },
+};
+
+export const splitButtonMinInteractiveSize = 48;
+
+export const splitButtonElevation = {
+ enabled: 1,
+ pressed: 2,
+} as const;
diff --git a/src/components/SplitButton/utils.ts b/src/components/SplitButton/utils.ts
new file mode 100644
index 0000000000..1a2ae78f5f
--- /dev/null
+++ b/src/components/SplitButton/utils.ts
@@ -0,0 +1,242 @@
+import type { ColorValue, Insets, ViewStyle } from 'react-native';
+
+import color from 'color';
+
+import {
+ splitButtonMinInteractiveSize,
+ splitButtonSizeTokens,
+ type SplitButtonShapeKey,
+ type SplitButtonSize,
+} from './tokens';
+import { tokens } from '../../theme/tokens';
+import { cornerFull } from '../../theme/tokens/sys/shape';
+import type { InternalTheme, Theme } from '../../types';
+import type { Props as TouchableRippleProps } from '../TouchableRipple/TouchableRipple';
+
+const stateOpacity = tokens.md.sys.state.opacity;
+
+export type SplitButtonMode = 'filled' | 'tonal' | 'elevated' | 'outlined';
+
+export type SplitButtonNormalizedMode =
+ | 'filled'
+ | 'tonal'
+ | 'elevated'
+ | 'outlined';
+
+export const normalizeSplitButtonMode = (
+ mode: SplitButtonMode
+): SplitButtonNormalizedMode => {
+ return mode;
+};
+
+export const resolveSplitButtonCorner = (
+ theme: InternalTheme,
+ key: SplitButtonShapeKey
+) => (key === 'full' ? cornerFull : (theme as Theme).shapes.corner[key]);
+
+export const getSplitButtonSizeStyle = ({
+ size,
+ theme,
+}: {
+ size: SplitButtonSize;
+ theme: InternalTheme;
+}) => {
+ const sizeTokens = splitButtonSizeTokens[size];
+
+ return {
+ ...sizeTokens,
+ containerRadius: resolveSplitButtonCorner(theme, sizeTokens.containerShape),
+ innerRadius: resolveSplitButtonCorner(theme, sizeTokens.innerCornerShape),
+ innerPressedRadius: resolveSplitButtonCorner(
+ theme,
+ sizeTokens.innerPressedCornerShape
+ ),
+ };
+};
+
+const getSplitButtonContainerColor = ({
+ mode,
+ theme,
+ disabled,
+ customButtonColor,
+}: {
+ mode: SplitButtonNormalizedMode;
+ theme: InternalTheme;
+ disabled?: boolean;
+ customButtonColor?: ColorValue;
+}) => {
+ const { colors } = theme as Theme;
+
+ if (customButtonColor && !disabled) {
+ return customButtonColor;
+ }
+
+ if (disabled) {
+ return mode === 'outlined' ? 'transparent' : colors.onSurface;
+ }
+
+ if (mode === 'filled') {
+ return colors.primary;
+ }
+
+ if (mode === 'tonal') {
+ return colors.secondaryContainer;
+ }
+
+ if (mode === 'elevated') {
+ return colors.surfaceContainerLow;
+ }
+
+ return 'transparent';
+};
+
+const getSplitButtonContentColor = ({
+ mode,
+ theme,
+ disabled,
+ customTextColor,
+}: {
+ mode: SplitButtonNormalizedMode;
+ theme: InternalTheme;
+ disabled?: boolean;
+ customTextColor?: ColorValue;
+}) => {
+ const { colors } = theme as Theme;
+
+ if (customTextColor && !disabled) {
+ return customTextColor;
+ }
+
+ if (disabled) {
+ return colors.onSurface;
+ }
+
+ if (mode === 'filled') {
+ return colors.onPrimary;
+ }
+
+ if (mode === 'tonal') {
+ return colors.onSecondaryContainer;
+ }
+
+ if (mode === 'outlined') {
+ return colors.onSurfaceVariant;
+ }
+
+ return colors.primary;
+};
+
+export const getSplitButtonColors = ({
+ theme,
+ mode,
+ disabled,
+ customButtonColor,
+ customTextColor,
+}: {
+ theme: InternalTheme;
+ mode: SplitButtonMode;
+ disabled?: boolean;
+ customButtonColor?: ColorValue;
+ customTextColor?: ColorValue;
+}) => {
+ const normalizedMode = normalizeSplitButtonMode(mode);
+ const containerColor = getSplitButtonContainerColor({
+ mode: normalizedMode,
+ theme,
+ disabled,
+ customButtonColor,
+ });
+ const contentColor = getSplitButtonContentColor({
+ mode: normalizedMode,
+ theme,
+ disabled,
+ customTextColor,
+ });
+ const isOutlined = normalizedMode === 'outlined';
+
+ return {
+ containerColor,
+ contentColor,
+ borderColor: isOutlined ? (theme as Theme).colors.outline : 'transparent',
+ borderWidth: isOutlined ? 1 : 0,
+ containerOpacity:
+ disabled && normalizedMode !== 'outlined'
+ ? stateOpacity.pressed
+ : stateOpacity.enabled,
+ contentOpacity: disabled ? stateOpacity.disabled : stateOpacity.enabled,
+ };
+};
+
+export const getSplitButtonRippleColor = ({
+ contentColor,
+ customRippleColor,
+}: {
+ contentColor: ColorValue;
+ customRippleColor?: ColorValue;
+}): ColorValue | undefined => {
+ if (customRippleColor) {
+ return customRippleColor;
+ }
+
+ if (typeof contentColor !== 'string') {
+ return undefined;
+ }
+
+ return color(contentColor).alpha(stateOpacity.pressed).rgb().string();
+};
+
+export const getSplitButtonHitSlop = ({
+ size,
+ hitSlop,
+}: {
+ size: SplitButtonSize;
+ hitSlop?: TouchableRippleProps['hitSlop'];
+}): TouchableRippleProps['hitSlop'] => {
+ if (typeof hitSlop === 'number') {
+ return hitSlop;
+ }
+
+ const height = splitButtonSizeTokens[size].containerHeight;
+ const verticalSlop = Math.max(
+ 0,
+ (splitButtonMinInteractiveSize - height) / 2
+ );
+
+ if (verticalSlop === 0) {
+ return hitSlop;
+ }
+
+ const insetHitSlop = (hitSlop || {}) as Insets;
+
+ return {
+ ...insetHitSlop,
+ top: insetHitSlop.top ?? verticalSlop,
+ bottom: insetHitSlop.bottom ?? verticalSlop,
+ };
+};
+
+export const getSplitButtonLeadingShape = ({
+ containerRadius,
+ innerRadius,
+}: {
+ containerRadius: number;
+ innerRadius: number;
+}): ViewStyle => ({
+ borderTopStartRadius: containerRadius,
+ borderBottomStartRadius: containerRadius,
+ borderTopEndRadius: innerRadius,
+ borderBottomEndRadius: innerRadius,
+});
+
+export const getSplitButtonTrailingShape = ({
+ containerRadius,
+ innerRadius,
+}: {
+ containerRadius: number;
+ innerRadius: number;
+}): ViewStyle => ({
+ borderTopStartRadius: innerRadius,
+ borderBottomStartRadius: innerRadius,
+ borderTopEndRadius: containerRadius,
+ borderBottomEndRadius: containerRadius,
+});
diff --git a/src/components/__tests__/SplitButton.test.tsx b/src/components/__tests__/SplitButton.test.tsx
new file mode 100644
index 0000000000..3a7bb87c1d
--- /dev/null
+++ b/src/components/__tests__/SplitButton.test.tsx
@@ -0,0 +1,241 @@
+import * as React from 'react';
+import { StyleSheet } from 'react-native';
+
+import { fireEvent } from '@testing-library/react-native';
+
+import { getTheme } from '../../core/theming';
+import { render } from '../../test-utils';
+import SplitButton from '../SplitButton/SplitButton';
+import {
+ getSplitButtonColors,
+ getSplitButtonHitSlop,
+ getSplitButtonLeadingShape,
+ getSplitButtonSizeStyle,
+ getSplitButtonTrailingShape,
+ normalizeSplitButtonMode,
+} from '../SplitButton/utils';
+
+const styles = StyleSheet.create({
+ leading: {
+ minWidth: 120,
+ },
+ trailing: {
+ minWidth: 64,
+ },
+ label: {
+ fontSize: 18,
+ },
+});
+
+it('renders a filled split button by default', () => {
+ const { getByTestId } = render(
+ {}} onTrailingPress={() => {}} />
+ );
+
+ expect(getByTestId('split-button-label')).toHaveTextContent('Send');
+ expect(getByTestId('split-button-container')).toHaveStyle({
+ height: 40,
+ });
+ expect(getByTestId('split-button-leading-container')).toBeTruthy();
+ expect(getByTestId('split-button-trailing-container')).toBeTruthy();
+});
+
+it('calls leading and trailing press handlers separately', () => {
+ const onPress = jest.fn();
+ const onTrailingPress = jest.fn();
+ const { getByTestId } = render(
+
+ );
+
+ fireEvent.press(getByTestId('split-button-leading'));
+ fireEvent.press(getByTestId('split-button-trailing'));
+
+ expect(onPress).toHaveBeenCalledTimes(1);
+ expect(onTrailingPress).toHaveBeenCalledTimes(1);
+});
+
+it('calls leading and trailing press-in and press-out handlers separately', () => {
+ const onPress = jest.fn();
+ const onPressIn = jest.fn();
+ const onPressOut = jest.fn();
+ const onTrailingPress = jest.fn();
+ const onTrailingPressIn = jest.fn();
+ const onTrailingPressOut = jest.fn();
+ const { getByTestId } = render(
+
+ );
+
+ fireEvent(getByTestId('split-button-leading'), 'onPressIn');
+ fireEvent(getByTestId('split-button-leading'), 'onPressOut');
+ fireEvent(getByTestId('split-button-trailing'), 'onPressIn');
+ fireEvent(getByTestId('split-button-trailing'), 'onPressOut');
+
+ expect(onPressIn).toHaveBeenCalledTimes(1);
+ expect(onPressOut).toHaveBeenCalledTimes(1);
+ expect(onTrailingPressIn).toHaveBeenCalledTimes(1);
+ expect(onTrailingPressOut).toHaveBeenCalledTimes(1);
+});
+
+it('uses pressed inner-corner tokens for the active side', () => {
+ const theme = getTheme();
+ const { getByTestId } = render(
+ {}} onTrailingPress={() => {}} />
+ );
+
+ fireEvent(getByTestId('split-button-leading'), 'onPressIn');
+
+ expect(getByTestId('split-button-leading-container')).toHaveStyle({
+ borderTopEndRadius: theme.shapes.corner.medium,
+ borderBottomEndRadius: theme.shapes.corner.medium,
+ });
+ expect(getByTestId('split-button-trailing-container')).toHaveStyle({
+ borderTopStartRadius: theme.shapes.corner.extraSmall,
+ borderBottomStartRadius: theme.shapes.corner.extraSmall,
+ });
+
+ fireEvent(getByTestId('split-button-leading'), 'onPressOut');
+ fireEvent(getByTestId('split-button-trailing'), 'onPressIn');
+
+ expect(getByTestId('split-button-trailing-container')).toHaveStyle({
+ borderTopStartRadius: theme.shapes.corner.medium,
+ borderBottomStartRadius: theme.shapes.corner.medium,
+ });
+});
+
+it('marks both press targets disabled when disabled', () => {
+ const { getByTestId } = render(
+ {}}
+ onTrailingPress={() => {}}
+ />
+ );
+
+ expect(getByTestId('split-button-leading').props.accessibilityState).toEqual({
+ disabled: true,
+ });
+ expect(getByTestId('split-button-trailing').props.accessibilityState).toEqual(
+ {
+ disabled: true,
+ }
+ );
+});
+
+it('passes custom styles to the correct target', () => {
+ const { getByTestId } = render(
+ {}}
+ onTrailingPress={() => {}}
+ leadingButtonStyle={styles.leading}
+ trailingButtonStyle={styles.trailing}
+ labelStyle={styles.label}
+ />
+ );
+
+ expect(getByTestId('split-button-leading-container')).toHaveStyle(
+ styles.leading
+ );
+ expect(getByTestId('split-button-trailing-container')).toHaveStyle(
+ styles.trailing
+ );
+ expect(getByTestId('split-button-label')).toHaveStyle(styles.label);
+});
+
+it('merges trailing accessibility state with expanded state', () => {
+ const { getByTestId } = render(
+ {}}
+ onTrailingPress={() => {}}
+ trailingAccessibilityState={{ expanded: true }}
+ />
+ );
+
+ expect(
+ getByTestId('split-button-trailing').props.accessibilityState
+ ).toMatchObject({
+ expanded: true,
+ });
+});
+
+describe('SplitButton utils', () => {
+ it('normalizes supported MD3 modes', () => {
+ expect(normalizeSplitButtonMode('filled')).toBe('filled');
+ expect(normalizeSplitButtonMode('tonal')).toBe('tonal');
+ expect(normalizeSplitButtonMode('outlined')).toBe('outlined');
+ });
+
+ it('resolves MD3 color roles for modes', () => {
+ const theme = getTheme();
+
+ expect(getSplitButtonColors({ theme, mode: 'filled' }).containerColor).toBe(
+ theme.colors.primary
+ );
+ expect(getSplitButtonColors({ theme, mode: 'tonal' }).contentColor).toBe(
+ theme.colors.onSecondaryContainer
+ );
+ expect(getSplitButtonColors({ theme, mode: 'elevated' }).contentColor).toBe(
+ theme.colors.primary
+ );
+ expect(getSplitButtonColors({ theme, mode: 'outlined' }).borderColor).toBe(
+ theme.colors.outline
+ );
+ });
+
+ it('resolves per-size tokens against theme shape values', () => {
+ const theme = getTheme();
+ const sizeStyle = getSplitButtonSizeStyle({ theme, size: 'large' });
+
+ expect(sizeStyle.containerHeight).toBe(96);
+ expect(sizeStyle.trailingIconSize).toBe(38);
+ expect(sizeStyle.innerRadius).toBe(theme.shapes.corner.small);
+ expect(sizeStyle.innerPressedRadius).toBe(
+ theme.shapes.corner.largeIncreased
+ );
+ });
+
+ it('uses logical leading and trailing shapes', () => {
+ expect(
+ getSplitButtonLeadingShape({ containerRadius: 20, innerRadius: 4 })
+ ).toEqual({
+ borderTopStartRadius: 20,
+ borderBottomStartRadius: 20,
+ borderTopEndRadius: 4,
+ borderBottomEndRadius: 4,
+ });
+ expect(
+ getSplitButtonTrailingShape({ containerRadius: 20, innerRadius: 4 })
+ ).toEqual({
+ borderTopStartRadius: 4,
+ borderBottomStartRadius: 4,
+ borderTopEndRadius: 20,
+ borderBottomEndRadius: 20,
+ });
+ });
+
+ it('expands small visual sizes to a 48dp touch target', () => {
+ expect(getSplitButtonHitSlop({ size: 'extra-small' })).toEqual({
+ top: 8,
+ bottom: 8,
+ });
+ expect(getSplitButtonHitSlop({ size: 'small' })).toEqual({
+ top: 4,
+ bottom: 4,
+ });
+ expect(getSplitButtonHitSlop({ size: 'medium' })).toBeUndefined();
+ });
+});
diff --git a/src/index.tsx b/src/index.tsx
index 8863e2fa20..8280928eae 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -42,6 +42,7 @@ export { default as ProgressBar } from './components/ProgressBar';
export { default as RadioButton } from './components/RadioButton';
export { default as Searchbar } from './components/Searchbar';
export { default as Snackbar } from './components/Snackbar';
+export { default as SplitButton } from './components/SplitButton';
export { default as Surface } from './components/Surface';
export { default as Switch } from './components/Switch/Switch';
export { default as Appbar } from './components/Appbar';
@@ -126,6 +127,7 @@ export type { Props as RadioButtonIOSProps } from './components/RadioButton/Radi
export type { Props as RadioButtonItemProps } from './components/RadioButton/RadioButtonItem';
export type { Props as SearchbarProps } from './components/Searchbar';
export type { Props as SnackbarProps } from './components/Snackbar';
+export type { Props as SplitButtonProps } from './components/SplitButton';
export type { Props as SurfaceProps } from './components/Surface';
export type { Props as SwitchProps } from './components/Switch/Switch';
export type {