diff --git a/docs/component-docs.config.ts b/docs/component-docs.config.ts
index 4196e8e7bd..1403c5d5fa 100644
--- a/docs/component-docs.config.ts
+++ b/docs/component-docs.config.ts
@@ -135,6 +135,9 @@ const pages = {
SegmentedButtons: 'SegmentedButtons/SegmentedButtons',
},
Snackbar: 'Snackbar',
+ SplitButton: {
+ SplitButton: 'SplitButton/SplitButton',
+ },
Surface: 'Surface',
Switch: {
Switch: 'Switch/Switch',
diff --git a/docs/public/screenshots/split-button-collapsed.png b/docs/public/screenshots/split-button-collapsed.png
new file mode 100644
index 0000000000..569764f94f
Binary files /dev/null and b/docs/public/screenshots/split-button-collapsed.png differ
diff --git a/docs/public/screenshots/split-button-expanded.png b/docs/public/screenshots/split-button-expanded.png
new file mode 100644
index 0000000000..3cd03e79ab
Binary files /dev/null and b/docs/public/screenshots/split-button-expanded.png differ
diff --git a/docs/src/data/screenshots.ts b/docs/src/data/screenshots.ts
index 92bf8f2788..d2ec21821d 100644
--- a/docs/src/data/screenshots.ts
+++ b/docs/src/data/screenshots.ts
@@ -134,6 +134,10 @@ export const screenshots = {
multiselect: 'screenshots/segmented-button-multi-select.png',
},
Snackbar: 'screenshots/snackbar.gif',
+ SplitButton: {
+ collapsed: 'screenshots/split-button-collapsed.png',
+ expanded: 'screenshots/split-button-expanded.png',
+ },
Surface: {
elevated: 'screenshots/surface-elevated-full-width.png',
flat: 'screenshots/surface-flat-full-width.png',
diff --git a/docs/src/data/themeColors.ts b/docs/src/data/themeColors.ts
index 20f16962f7..a210904e0a 100644
--- a/docs/src/data/themeColors.ts
+++ b/docs/src/data/themeColors.ts
@@ -307,6 +307,37 @@ export 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.outlineVariant',
+ },
+ },
+ disabled: {
+ '-': {
+ backgroundColor: 'theme.colors.onSurface',
+ textColor: 'theme.colors.onSurface',
+ },
+ outlined: {
+ backgroundColor: 'transparent',
+ textColor: 'theme.colors.outlineVariant',
+ borderColor: 'theme.colors.outlineVariant',
+ },
+ },
+ },
Surface: {
flat: {
backgroundColor: 'theme.colors.elevation[elevation]',
diff --git a/example/src/ExampleList.tsx b/example/src/ExampleList.tsx
index 8a2646d798..a799344173 100644
--- a/example/src/ExampleList.tsx
+++ b/example/src/ExampleList.tsx
@@ -36,6 +36,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';
@@ -79,6 +80,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..1a73852585
--- /dev/null
+++ b/example/src/Examples/SplitButtonExample.tsx
@@ -0,0 +1,149 @@
+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 sizes = [
+ 'extra-small',
+ 'small',
+ 'medium',
+ 'large',
+ 'extra-large',
+] 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`}
+ />
+ ))}
+
+
+
+
+
+ {sizes.map((size) => (
+ {}}
+ onTrailingPress={() => {}}
+ trailingAccessibilityLabel={`${size} 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..e8d9dfe618
--- /dev/null
+++ b/src/components/SplitButton/SplitButton.tsx
@@ -0,0 +1,905 @@
+import * as React from 'react';
+import { Platform, StyleSheet, View } from 'react-native';
+import type {
+ AccessibilityState,
+ ColorValue,
+ GestureResponderEvent,
+ NativeSyntheticEvent,
+ PressableAndroidRippleConfig,
+ StyleProp,
+ TargetedEvent,
+ TextStyle,
+ ViewProps,
+ ViewStyle,
+} from 'react-native';
+
+import Animated, {
+ Easing,
+ type AnimatedStyle,
+ useAnimatedStyle,
+ useDerivedValue,
+ useSharedValue,
+ withTiming,
+} from 'react-native-reanimated';
+
+import {
+ splitButtonFocusRingInset,
+ splitButtonFocusRingThickness,
+ splitButtonMinInteractiveSize,
+ splitButtonStateLayerOpacity,
+ type SplitButtonSize,
+} from './tokens';
+import {
+ getSplitButtonColors,
+ getSplitButtonHitSlop,
+ getSplitButtonLeadingShape,
+ getSplitButtonRippleColor,
+ getSplitButtonSizeStyle,
+ getSplitButtonTrailingShape,
+ type SplitButtonMode,
+} from './utils';
+import { useLocale } from '../../core/locale';
+import { useInternalTheme } from '../../core/theming';
+import type { $Omit, ThemeProp } from '../../types';
+import hasTouchHandler from '../../utils/hasTouchHandler';
+import { isKeyboardFocusEvent } from '../../utils/isKeyboardFocusEvent';
+import ActivityIndicator from '../ActivityIndicator';
+import { getButtonTouchableRippleStyle } from '../Button/utils';
+import Icon, { type IconSource } from '../Icon';
+import Surface, { type SurfaceStyle } from '../Surface';
+import TouchableRipple, {
+ type Props as TouchableRippleProps,
+} from '../TouchableRipple/TouchableRipple';
+import Text from '../Typography/Text';
+
+export type Props = $Omit & {
+ /**
+ * 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.
+ * - `extra-small` - the smallest split button size, for the most compact layouts.
+ * - `small` - the default split button size.
+ * - `medium` - a larger split button size for more prominent actions.
+ * - `large` - a larger split button size for high-emphasis actions.
+ * - `extra-large` - the largest split button size, for the most prominent actions.
+ */
+ 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?: StyleProp;
+ /**
+ * 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;
+};
+
+/**
+ * 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 = ({
+ mode = 'filled',
+ size = 'small',
+ label,
+ icon,
+ trailingIcon = 'chevron-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,
+ ...rest
+}: Props) => {
+ const theme = useInternalTheme(themeOverrides);
+ const { direction } = useLocale();
+ // Used below to work around two unrelated web-only corner-radius bugs by
+ // substituting an explicit, direction-aware Left/Right pair for a logical
+ // Start/End one - see the comments at each of their use sites. Native is
+ // unaffected by either and keeps the original logical properties, since
+ // mixing physical names into it breaks native's own border radius
+ // resolution.
+ const isRTL = direction === 'rtl';
+ const isWeb = Platform.OS === 'web';
+ const sizeStyle = React.useMemo(
+ () => getSplitButtonSizeStyle({ size, theme }),
+ [size, theme]
+ );
+ const isTrailingExpanded = trailingAccessibilityState?.expanded === true;
+ const trailingIconRotation = useSharedValue(isTrailingExpanded ? 1 : 0);
+ const trailingInnerRadiusProgress = useSharedValue(
+ isTrailingExpanded ? 1 : 0
+ );
+ // Standalone `Animated.View` rings, driven by keyboard focus directly,
+ // until `TouchableRipple` grows native focus ring support to move onto.
+ const leadingFocusedSV = useSharedValue(false);
+ const trailingFocusedSV = useSharedValue(false);
+ const onLeadingFocus = React.useCallback(
+ (e: NativeSyntheticEvent) => {
+ if (isKeyboardFocusEvent(e)) {
+ leadingFocusedSV.value = true;
+ }
+ },
+ [leadingFocusedSV]
+ );
+ const onLeadingBlur = React.useCallback(() => {
+ leadingFocusedSV.value = false;
+ }, [leadingFocusedSV]);
+ const onTrailingFocus = React.useCallback(
+ (e: NativeSyntheticEvent) => {
+ if (isKeyboardFocusEvent(e)) {
+ trailingFocusedSV.value = true;
+ }
+ },
+ [trailingFocusedSV]
+ );
+ const onTrailingBlur = React.useCallback(() => {
+ trailingFocusedSV.value = false;
+ }, [trailingFocusedSV]);
+ // Resolved for both states up front, so the container crossfade below
+ // always has both endpoints on hand to animate between.
+ const { enabled: enabledColors, disabled: disabledColors } = React.useMemo(
+ () =>
+ getSplitButtonColors({ theme, mode, customButtonColor, customTextColor }),
+ [theme, mode, customButtonColor, customTextColor]
+ );
+ const colors = disabled ? disabledColors : enabledColors;
+ const { color: customLabelColor, fontSize: customLabelSize } =
+ StyleSheet.flatten(labelStyle) || {};
+ const contentColor =
+ typeof customLabelColor === 'string'
+ ? customLabelColor
+ : colors.contentColor;
+ const rippleColor = React.useMemo(
+ () =>
+ getSplitButtonRippleColor({
+ contentColor,
+ customRippleColor,
+ }),
+ [contentColor, customRippleColor]
+ );
+ const leadingShape = React.useMemo(
+ () =>
+ getSplitButtonLeadingShape({
+ containerRadius: sizeStyle.containerRadius,
+ innerRadius: sizeStyle.innerRadius,
+ }),
+ [sizeStyle.containerRadius, sizeStyle.innerRadius]
+ );
+ // `Surface` has a web-only bug: passing it a logical `Start`/`End` corner
+ // prop (its other 11 corner-prop names all resolving to `undefined`)
+ // silently drops the corner's bottom half from the DOM. Passing the
+ // physical `Left`/`Right` name instead avoids it, so `Surface` gets these
+ // (direction-aware) instead of spreading `leadingShape` directly - native
+ // is unaffected and keeps the original logical shape.
+ const leadingSurfaceCornerProps = !isWeb
+ ? leadingShape
+ : isRTL
+ ? {
+ borderTopRightRadius: sizeStyle.containerRadius,
+ borderBottomRightRadius: sizeStyle.containerRadius,
+ borderTopLeftRadius: sizeStyle.innerRadius,
+ borderBottomLeftRadius: sizeStyle.innerRadius,
+ }
+ : {
+ borderTopLeftRadius: sizeStyle.containerRadius,
+ borderBottomLeftRadius: sizeStyle.containerRadius,
+ borderTopRightRadius: sizeStyle.innerRadius,
+ borderBottomRightRadius: sizeStyle.innerRadius,
+ };
+ const trailingShape = React.useMemo(
+ () =>
+ getSplitButtonTrailingShape({
+ containerRadius: sizeStyle.containerRadius,
+ innerRadius: sizeStyle.innerRadius,
+ }),
+ [sizeStyle.containerRadius, sizeStyle.innerRadius]
+ );
+ const pressTimingConfig = React.useMemo(
+ () => ({
+ duration: theme.motion.duration.short4,
+ easing: Easing.bezier(...theme.motion.easing.standard),
+ }),
+ [theme.motion.duration.short4, theme.motion.easing.standard]
+ );
+ // Passed to both containers below as their `transitionDuration`
+ // explicitly, so their elevation/shadow transitions and this container
+ // crossfade always share one duration instead of two independently-
+ // computed values that could drift apart.
+ const disabledTimingConfig = React.useMemo(
+ () => ({
+ duration: theme.motion.duration.short3 * theme.animation.scale,
+ easing: Easing.bezier(...theme.motion.easing.standard),
+ }),
+ [
+ theme.motion.duration.short3,
+ theme.motion.easing.standard,
+ theme.animation.scale,
+ ]
+ );
+ const disabledProgress = useSharedValue(disabled ? 1 : 0);
+ React.useEffect(() => {
+ disabledProgress.value = withTiming(disabled ? 1 : 0, disabledTimingConfig);
+ }, [disabled, disabledTimingConfig, disabledProgress]);
+ // Neither `enabledColors.containerColor` nor `disabledColors.containerColor`
+ // is ever itself animated (each stays fixed on its own layer below) -
+ // only their opacity crossfades. That sidesteps Reanimated's inability to
+ // interpolate a Material You `PlatformColor`/`DynamicColorIOS` value.
+ const enabledContainerAnimatedStyle = useAnimatedStyle(() => ({
+ opacity: enabledColors.containerOpacity * (1 - disabledProgress.value),
+ }));
+ const dimContainerAnimatedStyle = useAnimatedStyle(() => ({
+ opacity: disabledProgress.value * disabledColors.containerOpacity,
+ }));
+ // Interpolated between `trailingShape`'s own Start radius (resting) and
+ // its End radius (expanded), so the Start corner always lands exactly on
+ // the same radius already driving the segment's static End corners.
+ // `trailingShape`'s corner properties are typed loosely (`ViewStyle`), so
+ // narrow to the `sizeStyle` values that `getSplitButtonTrailingShape`
+ // always assigns them from, rather than asserting the type.
+ const trailingRestingRadius =
+ typeof trailingShape.borderTopStartRadius === 'number'
+ ? trailingShape.borderTopStartRadius
+ : sizeStyle.innerRadius;
+ const trailingExpandedRadius =
+ typeof trailingShape.borderTopEndRadius === 'number'
+ ? trailingShape.borderTopEndRadius
+ : sizeStyle.containerRadius;
+ // Shared between the trailing segment's own start-corner props (for its
+ // elevation shadow) and the inner clip view's animated style below, so
+ // both always land on the same radius.
+ const trailingStartRadius = useDerivedValue(
+ () =>
+ trailingRestingRadius +
+ trailingInnerRadiusProgress.value *
+ (trailingExpandedRadius - trailingRestingRadius)
+ );
+ // Same `Surface` web bug as `leadingSurfaceCornerProps` above - both the
+ // segment's static (End/outer) and animated (Start/inner) corners need
+ // the physical name on web to reach `Surface` correctly.
+ const trailingSurfaceEndCornerProps = !isWeb
+ ? trailingShape
+ : isRTL
+ ? {
+ borderTopLeftRadius: sizeStyle.containerRadius,
+ borderBottomLeftRadius: sizeStyle.containerRadius,
+ }
+ : {
+ borderTopRightRadius: sizeStyle.containerRadius,
+ borderBottomRightRadius: sizeStyle.containerRadius,
+ };
+ const trailingStartCornerProps = !isWeb
+ ? {
+ borderTopStartRadius: trailingStartRadius,
+ borderBottomStartRadius: trailingStartRadius,
+ }
+ : isRTL
+ ? {
+ borderTopRightRadius: trailingStartRadius,
+ borderBottomRightRadius: trailingStartRadius,
+ }
+ : {
+ borderTopLeftRadius: trailingStartRadius,
+ borderBottomLeftRadius: trailingStartRadius,
+ };
+ // Separate (worklet-freeze, not the `Surface` bug above) reason for the
+ // same physical-on-web treatment: this drives the inner clip `Animated.View`
+ // below directly, not `Surface`, but Reanimated's web engine doesn't
+ // resolve a worklet-driven logical corner property at all past its first
+ // render.
+ const trailingAnimatedShapeStyle = useAnimatedStyle(() => {
+ if (!isWeb) {
+ return {
+ borderTopStartRadius: trailingStartRadius.value,
+ borderBottomStartRadius: trailingStartRadius.value,
+ };
+ }
+ return isRTL
+ ? {
+ borderTopRightRadius: trailingStartRadius.value,
+ borderBottomRightRadius: trailingStartRadius.value,
+ }
+ : {
+ borderTopLeftRadius: trailingStartRadius.value,
+ borderBottomLeftRadius: trailingStartRadius.value,
+ };
+ });
+ const trailingIconAnimatedStyle = useAnimatedStyle(() => ({
+ transform: [{ rotate: `${trailingIconRotation.value * 180}deg` }],
+ }));
+ // Per the M3 spec, the trailing button's color doesn't change when
+ // selected (expanded) — only a state layer is applied on top of it. Fades
+ // out via `disabledProgress` (rather than snapping on the raw `disabled`
+ // boolean) so it stays in sync with the container crossfade above.
+ const trailingStateLayerAnimatedStyle = useAnimatedStyle(() => ({
+ opacity:
+ (1 - disabledProgress.value) *
+ trailingInnerRadiusProgress.value *
+ splitButtonStateLayerOpacity,
+ }));
+ // Corners grow outward by the same inset as the ring itself, so it traces
+ // each segment's shape rather than sitting flush with its edge.
+ const leadingFocusRingShape = React.useMemo(
+ () =>
+ getSplitButtonLeadingShape({
+ containerRadius: sizeStyle.containerRadius + splitButtonFocusRingInset,
+ innerRadius: sizeStyle.innerRadius + splitButtonFocusRingInset,
+ }),
+ [sizeStyle.containerRadius, sizeStyle.innerRadius]
+ );
+ const trailingFocusRingShape = React.useMemo(
+ () =>
+ getSplitButtonTrailingShape({
+ containerRadius: sizeStyle.containerRadius + splitButtonFocusRingInset,
+ // The start corners are overridden by `trailingFocusRingAnimatedStyle`
+ // below, so this value is never actually rendered.
+ innerRadius: sizeStyle.innerRadius + splitButtonFocusRingInset,
+ }),
+ [sizeStyle.containerRadius, sizeStyle.innerRadius]
+ );
+ const leadingFocusRingAnimatedStyle = useAnimatedStyle(() => ({
+ opacity: leadingFocusedSV.value ? 1 : 0,
+ }));
+ const trailingFocusRingAnimatedStyle = useAnimatedStyle(() => {
+ const cornerRadius = trailingStartRadius.value + splitButtonFocusRingInset;
+ if (!isWeb) {
+ return {
+ opacity: trailingFocusedSV.value ? 1 : 0,
+ borderTopStartRadius: cornerRadius,
+ borderBottomStartRadius: cornerRadius,
+ };
+ }
+ return {
+ opacity: trailingFocusedSV.value ? 1 : 0,
+ ...(isRTL
+ ? {
+ borderTopRightRadius: cornerRadius,
+ borderBottomRightRadius: cornerRadius,
+ }
+ : {
+ borderTopLeftRadius: cornerRadius,
+ borderBottomLeftRadius: cornerRadius,
+ }),
+ };
+ });
+ const leadingHitSlop = React.useMemo(
+ () => getSplitButtonHitSlop({ size, hitSlop }),
+ [size, hitSlop]
+ );
+ const resolvedTrailingHitSlop = React.useMemo(
+ () => getSplitButtonHitSlop({ size, hitSlop: trailingHitSlop }),
+ [size, trailingHitSlop]
+ );
+
+ const labelTextStyle: TextStyle = {
+ color: colors.contentColor,
+ };
+ const disabledState = { disabled: true };
+ const leadingAccessibilityState = disabled
+ ? { ...accessibilityState, ...disabledState }
+ : accessibilityState;
+ const trailingAccessibilityStateWithDisabled = disabled
+ ? { ...trailingAccessibilityState, ...disabledState }
+ : trailingAccessibilityState;
+ const leadingHasTouchHandler = hasTouchHandler({
+ onPress,
+ onPressIn,
+ onPressOut,
+ onLongPress,
+ });
+ const trailingHasTouchHandler = hasTouchHandler({
+ onPress: onTrailingPress,
+ onPressIn: onTrailingPressIn,
+ onPressOut: onTrailingPressOut,
+ onLongPress: onTrailingLongPress,
+ });
+ React.useEffect(() => {
+ const progress = isTrailingExpanded ? 1 : 0;
+
+ trailingIconRotation.value = withTiming(progress, pressTimingConfig);
+ trailingInnerRadiusProgress.value = withTiming(progress, pressTimingConfig);
+ }, [
+ isTrailingExpanded,
+ pressTimingConfig,
+ trailingIconRotation,
+ trailingInnerRadiusProgress,
+ ]);
+
+ const commonButtonStyle: ViewStyle = {
+ height: sizeStyle.containerHeight,
+ borderColor: colors.borderColor,
+ borderWidth: colors.borderWidth,
+ };
+ const getTestID = (suffix: string) =>
+ testID ? `${testID}-${suffix}` : undefined;
+
+ return (
+
+
+
+
+
+
+ {icon && !loading ? (
+
+ ) : null}
+ {loading ? (
+
+ ) : null}
+
+ {label}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Sibling of `trailingClip` (not a child) so the ring isn't clipped
+ to the segment's shape - it needs to extend past it. */}
+
+
+
+ );
+};
+
+// Both the enabled and disabled-dim container colors are always rendered as
+// two stacked, statically-colored layers, crossfading only via `animatedStyle`'s
+// `opacity` - never interpolating `backgroundColor` itself. See the comment
+// above `disabledProgress`.
+const ButtonBackground = ({
+ testID,
+ backgroundColor,
+ animatedStyle,
+ borderRadiusStyle,
+}: {
+ testID?: string;
+ backgroundColor: ColorValue;
+ animatedStyle: AnimatedStyle<{ opacity: number }>;
+ borderRadiusStyle: ViewStyle;
+}) => {
+ return (
+
+ );
+};
+
+const styles = StyleSheet.create({
+ group: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ maxWidth: '100%',
+ },
+ leading: {
+ minWidth: splitButtonMinInteractiveSize,
+ flexShrink: 1,
+ borderStyle: 'solid',
+ // Its focus ring overflows rightward into the (narrow) gap toward the
+ // trailing segment; without this, the trailing segment - painted after
+ // it in document order - covers that overflow.
+ zIndex: 1,
+ },
+ trailing: {
+ minWidth: splitButtonMinInteractiveSize,
+ borderStyle: 'solid',
+ },
+ // Clips the ripple and background to the trailing segment's current
+ // (possibly animated) shape, since `TouchableRipple`'s own clip is static
+ // and would let the ripple show past the segment once its corners morph.
+ trailingClip: {
+ overflow: 'hidden',
+ },
+ ripple: {
+ height: '100%',
+ },
+ leadingContent: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ label: {
+ flexShrink: 1,
+ },
+ trailingContent: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ focusRing: {
+ position: 'absolute',
+ top: -splitButtonFocusRingInset,
+ left: -splitButtonFocusRingInset,
+ right: -splitButtonFocusRingInset,
+ bottom: -splitButtonFocusRingInset,
+ borderWidth: splitButtonFocusRingThickness,
+ pointerEvents: 'none',
+ },
+ noPointerEvents: {
+ pointerEvents: 'none',
+ },
+});
+
+// Web-only style; not in StyleSheet because `outline` is outside ViewStyle.
+// Suppresses the browser's own focus outline in favor of the custom ring
+// above.
+// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+const webNoOutline = { outline: 'none' } as unknown as ViewStyle;
+
+export default SplitButton;
diff --git a/src/components/SplitButton/tokens.ts b/src/components/SplitButton/tokens.ts
new file mode 100644
index 0000000000..4194de9159
--- /dev/null
+++ b/src/components/SplitButton/tokens.ts
@@ -0,0 +1,193 @@
+import { tokens } from '../../theme/tokens';
+import type { ColorRole, Elevation, TypescaleKey } from '../../theme/types';
+import type { ShapeToken } from '../../theme/utils/shape';
+
+export type SplitButtonMode = 'filled' | 'tonal' | 'elevated' | 'outlined';
+
+export type SplitButtonSize =
+ | 'extra-small'
+ | 'small'
+ | 'medium'
+ | 'large'
+ | 'extra-large';
+
+export type SplitButtonSizeTokens = {
+ betweenSpace: number;
+ containerHeight: number;
+ containerShape: ShapeToken;
+ innerCornerShape: ShapeToken;
+ leadingButtonLeadingSpace: number;
+ leadingButtonTrailingSpace: number;
+ leadingIconSize: number;
+ iconLabelGap: 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',
+ leadingButtonLeadingSpace: 12,
+ leadingButtonTrailingSpace: 10,
+ leadingIconSize: 20,
+ iconLabelGap: 8,
+ trailingButtonLeadingSpace: 13,
+ trailingButtonTrailingSpace: 13,
+ trailingIconSize: 22,
+ labelVariant: 'labelLarge',
+ },
+ small: {
+ betweenSpace: 2,
+ containerHeight: 40,
+ containerShape: 'full',
+ innerCornerShape: 'extraSmall',
+ leadingButtonLeadingSpace: 16,
+ leadingButtonTrailingSpace: 12,
+ leadingIconSize: 20,
+ iconLabelGap: 8,
+ trailingButtonLeadingSpace: 13,
+ trailingButtonTrailingSpace: 13,
+ trailingIconSize: 22,
+ labelVariant: 'labelLarge',
+ },
+ medium: {
+ betweenSpace: 2,
+ containerHeight: 56,
+ containerShape: 'full',
+ innerCornerShape: 'extraSmall',
+ leadingButtonLeadingSpace: 24,
+ leadingButtonTrailingSpace: 24,
+ leadingIconSize: 24,
+ iconLabelGap: 8,
+ trailingButtonLeadingSpace: 15,
+ trailingButtonTrailingSpace: 15,
+ trailingIconSize: 26,
+ labelVariant: 'titleMedium',
+ },
+ large: {
+ betweenSpace: 2,
+ containerHeight: 96,
+ containerShape: 'full',
+ innerCornerShape: 'small',
+ leadingButtonLeadingSpace: 48,
+ leadingButtonTrailingSpace: 48,
+ leadingIconSize: 32,
+ iconLabelGap: 12,
+ trailingButtonLeadingSpace: 29,
+ trailingButtonTrailingSpace: 29,
+ trailingIconSize: 38,
+ labelVariant: 'headlineSmall',
+ },
+ 'extra-large': {
+ betweenSpace: 2,
+ containerHeight: 136,
+ containerShape: 'full',
+ innerCornerShape: 'medium',
+ leadingButtonLeadingSpace: 64,
+ leadingButtonTrailingSpace: 64,
+ leadingIconSize: 40,
+ iconLabelGap: 16,
+ trailingButtonLeadingSpace: 43,
+ trailingButtonTrailingSpace: 43,
+ trailingIconSize: 50,
+ labelVariant: 'headlineLarge',
+ },
+};
+
+export const splitButtonMinInteractiveSize = 48;
+
+export const splitButtonStateLayerOpacity = 0.1;
+
+// TODO: drop once TouchableRipple grows native focus ring support, and wire
+// SplitButton into that instead of this standalone `Animated.View` hack.
+const focusIndicator = tokens.md.sys.state.focusIndicator;
+export const splitButtonFocusRingThickness = focusIndicator.thickness;
+export const splitButtonFocusRingInset =
+ focusIndicator.outerOffset + focusIndicator.thickness;
+
+export type SplitButtonColorTokens = {
+ containerColor?: ColorRole;
+ containerOpacity: number;
+ contentColor: ColorRole;
+ contentOpacity: number;
+ borderColor?: ColorRole;
+ elevation: Elevation;
+};
+
+export const splitButtonColorTokens: Record<
+ SplitButtonMode,
+ { enabled: SplitButtonColorTokens; disabled: SplitButtonColorTokens }
+> = {
+ elevated: {
+ enabled: {
+ containerColor: 'surfaceContainerLow',
+ containerOpacity: 1,
+ contentColor: 'primary',
+ contentOpacity: 1,
+ elevation: 1,
+ },
+ disabled: {
+ containerColor: 'onSurface',
+ containerOpacity: 0.1,
+ contentColor: 'onSurface',
+ contentOpacity: 0.38,
+ elevation: 0,
+ },
+ },
+ filled: {
+ enabled: {
+ containerColor: 'primary',
+ containerOpacity: 1,
+ contentColor: 'onPrimary',
+ contentOpacity: 1,
+ elevation: 0,
+ },
+ disabled: {
+ containerColor: 'onSurface',
+ containerOpacity: 0.1,
+ contentColor: 'onSurface',
+ contentOpacity: 0.38,
+ elevation: 0,
+ },
+ },
+ tonal: {
+ enabled: {
+ containerColor: 'secondaryContainer',
+ containerOpacity: 1,
+ contentColor: 'onSecondaryContainer',
+ contentOpacity: 1,
+ elevation: 0,
+ },
+ disabled: {
+ containerColor: 'onSurface',
+ containerOpacity: 0.1,
+ contentColor: 'onSurface',
+ contentOpacity: 0.38,
+ elevation: 0,
+ },
+ },
+ outlined: {
+ enabled: {
+ containerOpacity: 1,
+ contentColor: 'onSurfaceVariant',
+ contentOpacity: 1,
+ borderColor: 'outlineVariant',
+ elevation: 0,
+ },
+ disabled: {
+ containerOpacity: 1,
+ contentColor: 'onSurface',
+ contentOpacity: 0.38,
+ borderColor: 'outlineVariant',
+ elevation: 0,
+ },
+ },
+};
diff --git a/src/components/SplitButton/utils.ts b/src/components/SplitButton/utils.ts
new file mode 100644
index 0000000000..f1b82c700f
--- /dev/null
+++ b/src/components/SplitButton/utils.ts
@@ -0,0 +1,210 @@
+import type { ColorValue, ViewStyle } from 'react-native';
+
+import color from 'color';
+
+import {
+ splitButtonColorTokens,
+ splitButtonMinInteractiveSize,
+ splitButtonSizeTokens,
+ type SplitButtonMode,
+ type SplitButtonSize,
+} from './tokens';
+import { tokens } from '../../theme/tokens';
+import { resolveCornerRadius, type ShapeToken } from '../../theme/utils/shape';
+import type { Elevation, InternalTheme } from '../../types';
+import type { Props as TouchableRippleProps } from '../TouchableRipple/TouchableRipple';
+
+const stateOpacity = tokens.md.sys.state.opacity;
+
+export type { SplitButtonMode } from './tokens';
+
+// `resolveCornerRadius`'s 'full' case returns a large sentinel radius
+// (`cornerFull`) meant for shapes whose corners are all resolved the same
+// way. Paired on the same edge with the smaller `innerRadius`, that
+// sentinel triggers RN's corner-overlap correction and collapses the inner
+// radius too — so the container shape is resolved on its own, relative to
+// its own height, instead: 'full' means "fully rounded", i.e. a stadium
+// whose radius is exactly half its height.
+export const resolveSplitButtonContainerRadius = (
+ theme: InternalTheme,
+ shape: ShapeToken,
+ containerHeight: number
+) =>
+ shape === 'full' ? containerHeight / 2 : resolveCornerRadius(theme, shape);
+
+export const getSplitButtonSizeStyle = ({
+ size,
+ theme,
+}: {
+ size: SplitButtonSize;
+ theme: InternalTheme;
+}) => {
+ const sizeTokens = splitButtonSizeTokens[size];
+
+ return {
+ ...sizeTokens,
+ containerRadius: resolveSplitButtonContainerRadius(
+ theme,
+ sizeTokens.containerShape,
+ sizeTokens.containerHeight
+ ),
+ innerRadius: resolveCornerRadius(theme, sizeTokens.innerCornerShape),
+ };
+};
+
+export type SplitButtonResolvedColors = {
+ containerColor: ColorValue;
+ contentColor: ColorValue;
+ borderColor: ColorValue;
+ borderWidth: number;
+ containerOpacity: number;
+ contentOpacity: number;
+ elevation: Elevation;
+};
+
+const resolveSplitButtonColors = ({
+ theme,
+ mode,
+ disabled,
+ customButtonColor,
+ customTextColor,
+}: {
+ theme: InternalTheme;
+ mode: SplitButtonMode;
+ disabled: boolean;
+ customButtonColor?: ColorValue;
+ customTextColor?: ColorValue;
+}): SplitButtonResolvedColors => {
+ const { colors } = theme;
+ const colorTokens =
+ splitButtonColorTokens[mode][disabled ? 'disabled' : 'enabled'];
+
+ const containerColor =
+ customButtonColor && !disabled
+ ? customButtonColor
+ : colorTokens.containerColor
+ ? colors[colorTokens.containerColor]
+ : 'transparent';
+ const contentColor =
+ customTextColor && !disabled
+ ? customTextColor
+ : colors[colorTokens.contentColor];
+
+ return {
+ containerColor,
+ contentColor,
+ borderColor: colorTokens.borderColor
+ ? colors[colorTokens.borderColor]
+ : 'transparent',
+ borderWidth: colorTokens.borderColor ? 1 : 0,
+ containerOpacity: colorTokens.containerOpacity,
+ contentOpacity: colorTokens.contentOpacity,
+ elevation: colorTokens.elevation,
+ };
+};
+
+// Resolves both the enabled and disabled variants up front, so callers that
+// need to animate or crossfade between states (e.g. SplitButton's disabled
+// container crossfade) always have both endpoints on hand, instead of only
+// ever being able to derive the current one.
+export const getSplitButtonColors = ({
+ theme,
+ mode,
+ customButtonColor,
+ customTextColor,
+}: {
+ theme: InternalTheme;
+ mode: SplitButtonMode;
+ customButtonColor?: ColorValue;
+ customTextColor?: ColorValue;
+}): {
+ enabled: SplitButtonResolvedColors;
+ disabled: SplitButtonResolvedColors;
+} => ({
+ enabled: resolveSplitButtonColors({
+ theme,
+ mode,
+ disabled: false,
+ customButtonColor,
+ customTextColor,
+ }),
+ disabled: resolveSplitButtonColors({
+ theme,
+ mode,
+ disabled: true,
+ customButtonColor,
+ customTextColor,
+ }),
+});
+
+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;
+ }
+
+ return {
+ ...hitSlop,
+ top: hitSlop?.top ?? verticalSlop,
+ bottom: hitSlop?.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..5e882ebc27
--- /dev/null
+++ b/src/components/__tests__/SplitButton.test.tsx
@@ -0,0 +1,430 @@
+import * as React from 'react';
+import { StyleSheet } from 'react-native';
+
+import { expect, it, jest } from '@jest/globals';
+
+import { getTheme } from '../../core/theming';
+import { fireEvent, render, screen, userEvent } from '../../test-utils';
+import SplitButton from '../SplitButton/SplitButton';
+
+const styles = StyleSheet.create({
+ button: {
+ opacity: 0.9,
+ },
+ leading: {
+ minWidth: 120,
+ },
+ trailing: {
+ minWidth: 64,
+ },
+ label: {
+ fontSize: 18,
+ },
+});
+
+const segments = ['leading', 'trailing'] as const;
+const modes = ['filled', 'tonal', 'elevated'] as const;
+
+const renderSplitButton = (
+ props: Partial> = {}
+) =>
+ render(
+ {}}
+ onTrailingPress={() => {}}
+ {...props}
+ />
+ );
+
+it('renders the label text', async () => {
+ await renderSplitButton();
+
+ expect(screen.getByTestId('split-button-label')).toHaveTextContent('Send');
+});
+
+it('applies the default container height', async () => {
+ await renderSplitButton();
+
+ expect(screen.getByTestId('split-button-container')).toHaveStyle({
+ height: 40,
+ });
+});
+
+it.each(segments)('renders the %s segment', async (segment) => {
+ await renderSplitButton();
+
+ expect(screen.getByTestId(`split-button-${segment}-container`)).toBeTruthy();
+});
+
+it.each(segments)(
+ 'calls the %s press handler on its own press',
+ async (segment) => {
+ const user = userEvent.setup();
+ const propName = segment === 'leading' ? 'onPress' : 'onTrailingPress';
+ const handler = jest.fn();
+ await renderSplitButton({ [propName]: handler });
+
+ await user.press(screen.getByTestId(`split-button-${segment}`));
+
+ expect(handler).toHaveBeenCalledTimes(1);
+ }
+);
+
+it.each(segments)(
+ 'calls the %s press-in handler separately',
+ async (segment) => {
+ const propName = segment === 'leading' ? 'onPressIn' : 'onTrailingPressIn';
+ const handler = jest.fn();
+ await renderSplitButton({ [propName]: handler });
+
+ await fireEvent(screen.getByTestId(`split-button-${segment}`), 'onPressIn');
+
+ expect(handler).toHaveBeenCalledTimes(1);
+ }
+);
+
+it.each(segments)(
+ 'calls the %s press-out handler separately',
+ async (segment) => {
+ const propName =
+ segment === 'leading' ? 'onPressOut' : 'onTrailingPressOut';
+ const handler = jest.fn();
+ await renderSplitButton({ [propName]: handler });
+
+ await fireEvent(
+ screen.getByTestId(`split-button-${segment}`),
+ 'onPressOut'
+ );
+
+ expect(handler).toHaveBeenCalledTimes(1);
+ }
+);
+
+it.each(segments)(
+ 'keeps the outline color at full opacity for a disabled outlined %s segment',
+ async (segment) => {
+ const theme = getTheme();
+ await renderSplitButton({ mode: 'outlined', disabled: true });
+
+ expect(screen.getByTestId(`split-button-${segment}-container`)).toHaveStyle(
+ {
+ borderColor: theme.colors.outlineVariant,
+ }
+ );
+ }
+);
+
+it.each(segments)(
+ 'applies the filled mode container color to the %s segment',
+ async (segment) => {
+ const theme = getTheme();
+ await renderSplitButton({ mode: 'filled' });
+
+ expect(
+ screen.getByTestId(`split-button-${segment}-background`)
+ ).toHaveStyle({
+ backgroundColor: theme.colors.primary,
+ });
+ }
+);
+
+it.each(segments)(
+ 'applies the tonal mode container color to the %s segment',
+ async (segment) => {
+ const theme = getTheme();
+ await renderSplitButton({ mode: 'tonal' });
+
+ expect(
+ screen.getByTestId(`split-button-${segment}-background`)
+ ).toHaveStyle({
+ backgroundColor: theme.colors.secondaryContainer,
+ });
+ }
+);
+
+it.each(segments)(
+ 'applies the elevated mode container color to the %s segment',
+ async (segment) => {
+ const theme = getTheme();
+ await renderSplitButton({ mode: 'elevated' });
+
+ expect(
+ screen.getByTestId(`split-button-${segment}-background`)
+ ).toHaveStyle({
+ backgroundColor: theme.colors.surfaceContainerLow,
+ });
+ }
+);
+
+it.each(segments)(
+ 'applies a transparent container and visible border to the %s segment in outlined mode',
+ async (segment) => {
+ const theme = getTheme();
+ await renderSplitButton({ mode: 'outlined' });
+
+ expect(screen.getByTestId(`split-button-${segment}-container`)).toHaveStyle(
+ {
+ backgroundColor: 'transparent',
+ borderColor: theme.colors.outlineVariant,
+ }
+ );
+ }
+);
+
+it.each(
+ segments.flatMap((segment) =>
+ modes.map((mode) => [segment, mode] as [string, (typeof modes)[number]])
+ )
+)(
+ 'dims the %s background to a disabled onSurface tint in %s mode',
+ async (segment, mode) => {
+ const theme = getTheme();
+ await renderSplitButton({ mode, disabled: true });
+
+ expect(
+ screen.getByTestId(`split-button-${segment}-disabled-background`)
+ ).toHaveStyle({
+ backgroundColor: theme.colors.onSurface,
+ opacity: 0.1,
+ });
+ }
+);
+
+it.each(segments)(
+ 'fades out the %s enabled background when disabled',
+ async (segment) => {
+ await renderSplitButton({ mode: 'filled', disabled: true });
+
+ expect(
+ screen.getByTestId(`split-button-${segment}-background`)
+ ).toHaveStyle({
+ opacity: 0,
+ });
+ }
+);
+
+it.each(
+ segments.flatMap((segment) =>
+ modes.map((mode) => [segment, mode] as [string, (typeof modes)[number]])
+ )
+)(
+ 'keeps the %s container transparent when disabled in %s mode',
+ async (segment, mode) => {
+ await renderSplitButton({ mode, disabled: true });
+
+ expect(screen.getByTestId(`split-button-${segment}-container`)).toHaveStyle(
+ {
+ backgroundColor: 'transparent',
+ }
+ );
+ }
+);
+
+it.each(segments)(
+ 'applies the custom button color to the %s segment',
+ async (segment) => {
+ await renderSplitButton({ buttonColor: 'purple' });
+
+ expect(
+ screen.getByTestId(`split-button-${segment}-background`)
+ ).toHaveStyle({
+ backgroundColor: 'purple',
+ });
+ }
+);
+
+it('applies the custom text color to the label', async () => {
+ await renderSplitButton({ textColor: 'yellow' });
+
+ expect(screen.getByTestId('split-button-label')).toHaveStyle({
+ color: 'yellow',
+ });
+});
+
+it('applies the container height for the extra-small size', async () => {
+ await renderSplitButton({ size: 'extra-small' });
+
+ expect(screen.getByTestId('split-button-container')).toHaveStyle({
+ height: 32,
+ });
+});
+
+it('applies the container height for the large size', async () => {
+ await renderSplitButton({ size: 'large' });
+
+ expect(screen.getByTestId('split-button-container')).toHaveStyle({
+ height: 96,
+ });
+});
+
+it('shows a progress indicator instead of the icon while loading', async () => {
+ await renderSplitButton({ icon: 'send', loading: true });
+
+ expect(screen.getByRole('progressbar')).toBeTruthy();
+});
+
+it('defaults the leading accessibility label to the label prop', async () => {
+ await renderSplitButton();
+
+ expect(screen.getByTestId('split-button-leading')).toHaveProp(
+ 'accessibilityLabel',
+ 'Send'
+ );
+});
+
+it('defaults the trailing accessibility label to "Show options"', async () => {
+ await renderSplitButton();
+
+ expect(screen.getByTestId('split-button-trailing')).toHaveProp(
+ 'accessibilityLabel',
+ 'Show options'
+ );
+});
+
+it.each(segments)(
+ 'calls the %s long-press handler separately',
+ async (segment) => {
+ const user = userEvent.setup();
+ const propName =
+ segment === 'leading' ? 'onLongPress' : 'onTrailingLongPress';
+ const handler = jest.fn();
+ await renderSplitButton({ [propName]: handler });
+
+ await user.longPress(screen.getByTestId(`split-button-${segment}`));
+
+ expect(handler).toHaveBeenCalledTimes(1);
+ }
+);
+
+it('does not change the trailing background color when selected', async () => {
+ // Per the M3 spec, the trailing button's color doesn't change when
+ // selected — only a state layer, tinted with the container's own "on"
+ // color, is applied on top of it.
+ const theme = getTheme();
+ await renderSplitButton({
+ mode: 'filled',
+ trailingAccessibilityState: { expanded: true },
+ });
+
+ expect(screen.getByTestId('split-button-trailing-background')).toHaveStyle({
+ backgroundColor: theme.colors.primary,
+ });
+});
+
+it('applies a state layer tinted with the "on" color when selected', async () => {
+ const theme = getTheme();
+ await renderSplitButton({
+ mode: 'filled',
+ trailingAccessibilityState: { expanded: true },
+ });
+
+ expect(screen.getByTestId('split-button-trailing-state-layer')).toHaveStyle({
+ backgroundColor: theme.colors.onPrimary,
+ opacity: 0.1,
+ });
+});
+
+it('does not affect the leading segment when the trailing segment is selected', async () => {
+ const theme = getTheme();
+ await renderSplitButton({
+ mode: 'filled',
+ trailingAccessibilityState: { expanded: true },
+ });
+
+ expect(screen.getByTestId('split-button-leading-background')).toHaveStyle({
+ backgroundColor: theme.colors.primary,
+ });
+});
+
+it('hides the state layer when not selected', async () => {
+ await renderSplitButton({
+ trailingAccessibilityState: { expanded: false },
+ });
+
+ expect(screen.getByTestId('split-button-trailing-state-layer')).toHaveStyle({
+ opacity: 0,
+ });
+});
+
+it('hides the state layer when disabled, even if selected', async () => {
+ await renderSplitButton({
+ disabled: true,
+ trailingAccessibilityState: { expanded: true },
+ });
+
+ expect(screen.getByTestId('split-button-trailing-state-layer')).toHaveStyle({
+ opacity: 0,
+ });
+});
+
+it.each(segments)(
+ 'marks the %s press target disabled when disabled',
+ async (segment) => {
+ await renderSplitButton({ disabled: true });
+
+ expect(screen.getByTestId(`split-button-${segment}`)).toBeDisabled();
+ }
+);
+
+it.each([
+ ['leading', styles.leading],
+ ['trailing', styles.trailing],
+])(
+ 'passes buttonStyle and its own segment style to the %s container',
+ async (segment, segmentStyle) => {
+ await renderSplitButton({
+ buttonStyle: styles.button,
+ leadingButtonStyle: styles.leading,
+ trailingButtonStyle: styles.trailing,
+ });
+
+ expect(screen.getByTestId(`split-button-${segment}-container`)).toHaveStyle(
+ {
+ ...styles.button,
+ ...segmentStyle,
+ }
+ );
+ }
+);
+
+it('passes labelStyle to the label', async () => {
+ await renderSplitButton({ labelStyle: styles.label });
+
+ expect(screen.getByTestId('split-button-label')).toHaveStyle(styles.label);
+});
+
+it('passes custom accessibility state to the leading button', async () => {
+ await renderSplitButton({
+ accessibilityState: { checked: true },
+ });
+
+ expect(screen.getByTestId('split-button-leading')).toHaveProp(
+ 'accessibilityState',
+ expect.objectContaining({ checked: true })
+ );
+});
+
+it('merges trailing accessibility state with expanded state', async () => {
+ await renderSplitButton({
+ trailingAccessibilityState: { expanded: true },
+ });
+
+ expect(screen.getByTestId('split-button-trailing')).toHaveProp(
+ 'accessibilityState',
+ expect.objectContaining({ expanded: true })
+ );
+});
+
+it.each([
+ 'split-button-container',
+ 'split-button-leading',
+ 'split-button-trailing',
+])('does not add the %s test ID unless testID is provided', async (testID) => {
+ await render(
+ {}} onTrailingPress={() => {}} />
+ );
+
+ expect(screen.queryByTestId(testID)).toBeNull();
+});
diff --git a/src/components/__tests__/SplitButtonUtils.test.tsx b/src/components/__tests__/SplitButtonUtils.test.tsx
new file mode 100644
index 0000000000..6c463be247
--- /dev/null
+++ b/src/components/__tests__/SplitButtonUtils.test.tsx
@@ -0,0 +1,316 @@
+import { PlatformColor } from 'react-native';
+
+import { describe, expect, it } from '@jest/globals';
+import color from 'color';
+
+import { getTheme } from '../../core/theming';
+import {
+ getSplitButtonColors,
+ getSplitButtonHitSlop,
+ getSplitButtonLeadingShape,
+ getSplitButtonRippleColor,
+ getSplitButtonSizeStyle,
+ getSplitButtonTrailingShape,
+ resolveSplitButtonContainerRadius,
+} from '../SplitButton/utils';
+
+describe('resolveSplitButtonContainerRadius', () => {
+ it('resolves a "full" shape to exactly half the container height, not the corner-overlap sentinel', () => {
+ const theme = getTheme();
+
+ const radius = resolveSplitButtonContainerRadius(theme, 'full', 40);
+
+ expect(radius).toBe(20);
+ // Regression guard: 'full' used to resolve through the `cornerFull`
+ // sentinel (9999), which triggers RN's corner-overlap correction and
+ // silently collapses the paired inner radius too.
+ expect(radius).toBeLessThan(9999);
+ });
+
+ it('resolves a non-"full" shape from the theme, ignoring container height', () => {
+ const theme = getTheme();
+
+ const radius = resolveSplitButtonContainerRadius(theme, 'medium', 999);
+
+ expect(radius).toBe(theme.shapes.corner.medium);
+ });
+});
+
+describe('getSplitButtonSizeStyle', () => {
+ it('resolves the container radius to exactly half the container height', () => {
+ const theme = getTheme();
+
+ (
+ ['extra-small', 'small', 'medium', 'large', 'extra-large'] as const
+ ).forEach((size) => {
+ const sizeStyle = getSplitButtonSizeStyle({ size, theme });
+
+ expect(sizeStyle.containerRadius).toBe(sizeStyle.containerHeight / 2);
+ expect(sizeStyle.containerRadius).toBeLessThan(9999);
+ });
+ });
+
+ it('resolves the inner radius from the theme shape corner for the size', () => {
+ const theme = getTheme();
+ const sizeStyle = getSplitButtonSizeStyle({ size: 'small', theme });
+
+ expect(sizeStyle.innerRadius).toBe(theme.shapes.corner.extraSmall);
+ });
+
+ it('returns different inner radii for sizes with different corner shapes', () => {
+ const theme = getTheme();
+ const small = getSplitButtonSizeStyle({ size: 'small', theme });
+ const large = getSplitButtonSizeStyle({ size: 'large', theme });
+
+ expect(small.innerRadius).toBe(theme.shapes.corner.extraSmall);
+ expect(large.innerRadius).toBe(theme.shapes.corner.small);
+ });
+});
+
+describe('getSplitButtonColors', () => {
+ it('returns filled mode colors', () => {
+ const theme = getTheme();
+ const { enabled } = getSplitButtonColors({ theme, mode: 'filled' });
+
+ expect(enabled.containerColor).toBe(theme.colors.primary);
+ expect(enabled.contentColor).toBe(theme.colors.onPrimary);
+ expect(enabled.borderColor).toBe('transparent');
+ expect(enabled.borderWidth).toBe(0);
+ });
+
+ it('returns tonal mode colors', () => {
+ const theme = getTheme();
+ const { enabled } = getSplitButtonColors({ theme, mode: 'tonal' });
+
+ expect(enabled.containerColor).toBe(theme.colors.secondaryContainer);
+ expect(enabled.contentColor).toBe(theme.colors.onSecondaryContainer);
+ });
+
+ it('returns elevated mode colors', () => {
+ const theme = getTheme();
+ const { enabled } = getSplitButtonColors({ theme, mode: 'elevated' });
+
+ expect(enabled.containerColor).toBe(theme.colors.surfaceContainerLow);
+ expect(enabled.contentColor).toBe(theme.colors.primary);
+ });
+
+ it('returns outlined mode colors with a visible border', () => {
+ const theme = getTheme();
+ const { enabled } = getSplitButtonColors({ theme, mode: 'outlined' });
+
+ expect(enabled.containerColor).toBe('transparent');
+ expect(enabled.contentColor).toBe(theme.colors.onSurfaceVariant);
+ expect(enabled.borderColor).toBe(theme.colors.outlineVariant);
+ expect(enabled.borderWidth).toBe(1);
+ });
+
+ it('prefers custom container and text colors when not disabled', () => {
+ const theme = getTheme();
+ const { enabled } = getSplitButtonColors({
+ theme,
+ mode: 'filled',
+ customButtonColor: '#123456',
+ customTextColor: '#abcdef',
+ });
+
+ expect(enabled.containerColor).toBe('#123456');
+ expect(enabled.contentColor).toBe('#abcdef');
+ });
+
+ it('ignores custom colors when disabled', () => {
+ const theme = getTheme();
+ const { disabled } = getSplitButtonColors({
+ theme,
+ mode: 'filled',
+ customButtonColor: '#123456',
+ customTextColor: '#abcdef',
+ });
+
+ expect(disabled.containerColor).toBe(theme.colors.onSurface);
+ expect(disabled.contentColor).toBe(theme.colors.onSurface);
+ });
+
+ it('fades a disabled filled container instead of using a flat disabled color', () => {
+ const theme = getTheme();
+ const { disabled } = getSplitButtonColors({
+ theme,
+ mode: 'filled',
+ });
+
+ expect(disabled.containerColor).toBe(theme.colors.onSurface);
+ expect(disabled.containerOpacity).toBeLessThan(1);
+ });
+
+ it('shares the same disabled onSurface treatment across filled, tonal, and elevated', () => {
+ const theme = getTheme();
+
+ (['filled', 'tonal', 'elevated'] as const).forEach((mode) => {
+ const { disabled } = getSplitButtonColors({ theme, mode });
+
+ expect(disabled.containerColor).toBe(theme.colors.onSurface);
+ expect(disabled.contentColor).toBe(theme.colors.onSurface);
+ expect(disabled.containerOpacity).toBe(0.1);
+ expect(disabled.contentOpacity).toBe(0.38);
+ });
+ });
+
+ it('uses a transparent container for a disabled outlined split button', () => {
+ const theme = getTheme();
+ const { disabled } = getSplitButtonColors({
+ theme,
+ mode: 'outlined',
+ });
+
+ expect(disabled.containerColor).toBe('transparent');
+ expect(disabled.containerOpacity).toBe(1);
+ });
+
+ it('keeps the outline color at full opacity for a disabled outlined split button', () => {
+ const theme = getTheme();
+ const { disabled } = getSplitButtonColors({
+ theme,
+ mode: 'outlined',
+ });
+
+ expect(disabled.borderColor).toBe(theme.colors.outlineVariant);
+ });
+
+ it('uses onSurface content color for a disabled outlined split button', () => {
+ const theme = getTheme();
+ const { disabled } = getSplitButtonColors({
+ theme,
+ mode: 'outlined',
+ });
+
+ expect(disabled.contentColor).toBe(theme.colors.onSurface);
+ });
+
+ it('only grants elevation to an enabled elevated split button', () => {
+ const theme = getTheme();
+
+ (['elevated', 'filled', 'tonal', 'outlined'] as const).forEach((mode) => {
+ const { enabled, disabled } = getSplitButtonColors({ theme, mode });
+
+ expect(enabled.elevation).toBe(mode === 'elevated' ? 1 : 0);
+ expect(disabled.elevation).toBe(0);
+ });
+ });
+
+ it('reduces content opacity when disabled', () => {
+ const theme = getTheme();
+ const { enabled, disabled } = getSplitButtonColors({
+ theme,
+ mode: 'filled',
+ });
+
+ expect(disabled.contentOpacity).toBeLessThan(enabled.contentOpacity);
+ });
+});
+
+describe('getSplitButtonRippleColor', () => {
+ it('derives a translucent ripple color from the content color', () => {
+ const rippleColor = getSplitButtonRippleColor({
+ contentColor: '#ffffff',
+ });
+
+ expect(rippleColor).toBe(color('#ffffff').alpha(0.1).rgb().string());
+ });
+
+ it('prefers a custom ripple color when provided', () => {
+ const rippleColor = getSplitButtonRippleColor({
+ contentColor: '#ffffff',
+ customRippleColor: '#ff0000',
+ });
+
+ expect(rippleColor).toBe('#ff0000');
+ });
+
+ it('returns undefined when the content color is not a string', () => {
+ const rippleColor = getSplitButtonRippleColor({
+ contentColor: PlatformColor('label'),
+ });
+
+ expect(rippleColor).toBeUndefined();
+ });
+});
+
+describe('getSplitButtonHitSlop', () => {
+ it('pads small sizes up to the minimum interactive size', () => {
+ const hitSlop = getSplitButtonHitSlop({ size: 'small' });
+
+ expect(hitSlop).toEqual({ top: 4, bottom: 4 });
+ });
+
+ it('pads extra-small sizes further, since they are further from the minimum', () => {
+ const hitSlop = getSplitButtonHitSlop({ size: 'extra-small' });
+
+ expect(hitSlop).toEqual({ top: 8, bottom: 8 });
+ });
+
+ it('does not add slop for sizes already at or above the minimum', () => {
+ const hitSlop = getSplitButtonHitSlop({ size: 'medium' });
+
+ expect(hitSlop).toBeUndefined();
+ });
+
+ it('passes a numeric hitSlop through unchanged', () => {
+ const hitSlop = getSplitButtonHitSlop({ size: 'small', hitSlop: 10 });
+
+ expect(hitSlop).toBe(10);
+ });
+
+ it('does not override explicit top/bottom values in an object hitSlop', () => {
+ const hitSlop = getSplitButtonHitSlop({
+ size: 'small',
+ hitSlop: { top: 1, left: 2 },
+ });
+
+ expect(hitSlop).toEqual({ top: 1, left: 2, bottom: 4 });
+ });
+});
+
+describe('getSplitButtonLeadingShape / getSplitButtonTrailingShape', () => {
+ it('rounds the leading segment on the Start side and squares it on the End side', () => {
+ const shape = getSplitButtonLeadingShape({
+ containerRadius: 20,
+ innerRadius: 4,
+ });
+
+ expect(shape).toEqual({
+ borderTopStartRadius: 20,
+ borderBottomStartRadius: 20,
+ borderTopEndRadius: 4,
+ borderBottomEndRadius: 4,
+ });
+ });
+
+ it('rounds the trailing segment on the End side and squares it on the Start side', () => {
+ const shape = getSplitButtonTrailingShape({
+ containerRadius: 20,
+ innerRadius: 4,
+ });
+
+ expect(shape).toEqual({
+ borderTopStartRadius: 4,
+ borderBottomStartRadius: 4,
+ borderTopEndRadius: 20,
+ borderBottomEndRadius: 20,
+ });
+ });
+
+ it('mirrors the leading and trailing shapes around the shared inner edge', () => {
+ const leading = getSplitButtonLeadingShape({
+ containerRadius: 20,
+ innerRadius: 4,
+ });
+ const trailing = getSplitButtonTrailingShape({
+ containerRadius: 20,
+ innerRadius: 4,
+ });
+
+ expect(leading.borderTopEndRadius).toBe(trailing.borderTopStartRadius);
+ expect(leading.borderBottomEndRadius).toBe(
+ trailing.borderBottomStartRadius
+ );
+ });
+});
diff --git a/src/index.tsx b/src/index.tsx
index 8863e2fa20..527190eea6 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/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/SplitButton';
export type { Props as SurfaceProps } from './components/Surface';
export type { Props as SwitchProps } from './components/Switch/Switch';
export type {