Skip to content

feat: add SplitButton component - #4998

Open
draggie wants to merge 3 commits into
callstack:mainfrom
draggie:feat/split-button
Open

feat: add SplitButton component#4998
draggie wants to merge 3 commits into
callstack:mainfrom
draggie:feat/split-button

Conversation

@draggie

@draggiedraggie commented Jun 15, 2026

Copy link
Copy Markdown

Motivation

Implements the new Material Design 3 SplitButton component as part of the v6 component modernization work.

The component provides a primary leading action and a separate trailing action for contextual options. It reuses the modernized Button API direction
from #4928/#4943: explicit label/icon props, MD3 mode names (filled, tonal, elevated, outlined), derived ripple/state-layer colors,
extracted component tokens, size metrics, shape tokens, disabled/loading states, and separate accessibility/press handlers for each button segment.

This also adds the component to the public exports, documentation generation config, theme color docs data, and the example app. The example includes a
Menu-based playground, mode showcase, loading/disabled controls, and custom color/label styling.

Related issue

Closes#4986

Related to #4928 and #4943

Test plan

  • yarn lint-no-fix
    • Passes with one existing unrelated warning in src/components/__tests__/TextInput.test.tsx.
  • yarn typescript
  • yarn test --watchman=false
  • yarn docs build

Manual verification in the example app:

  • Open the SplitButton example screen.
  • In the playground, tap the leading Send action and trailing menu action separately.
  • Verify the trailing menu opens below the SplitButton anchor and does not cover the button.
  • Toggle Disabled and verify both press targets are disabled and styled correctly.
  • Toggle Loading and verify the leading icon is replaced with a spinner.
  • Review all modes: filled, tonal, elevated, and outlined.
  • Review custom color and custom label style examples.
Screenshot_1781525547Simulator Screenshot - iPhone 17 - 2026-06-15 at 14 15 22

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new Material Design 3 SplitButton component to React Native Paper as part of the v6 modernization effort, and wires it into the library’s exports, docs, and example app.

Changes:

  • Introduces SplitButton component implementation with size/mode tokens and styling utilities.
  • Adds unit tests for SplitButton rendering/handlers and several utility helpers.
  • Exposes the component publicly and integrates it into docs + the example app (screen + docs config/theme color docs data).

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.

Show a summary per file
FileDescription
src/index.tsxExports SplitButton and SplitButtonProps from the public entrypoint.
src/components/SplitButton/utils.tsAdds MD3 color/shape/size helpers, ripple color derivation, and hitSlop expansion logic.
src/components/SplitButton/tokens.tsIntroduces SplitButton size/shape/elevation tokens.
src/components/SplitButton/SplitButton.tsxImplements the SplitButton component UI, interaction handling, accessibility, and pressed-elevation/shape behavior.
src/components/SplitButton/index.tsBarrel export for the new component and its props.
src/components/tests/SplitButton.test.tsxAdds tests for SplitButton rendering, interaction separation, accessibility state, and several utils.
example/src/Examples/SplitButtonExample.tsxAdds a SplitButton example screen with Menu-based playground and showcases.
example/src/ExampleList.tsxRegisters the SplitButton example in the example app’s main list.
docs/static/llms.txtAdds SplitButton docs link to the LLMs index.
docs/src/data/themeColors.jsAdds SplitButton entries to the theme color documentation data.
docs/docusaurus.config.jsRegisters SplitButton in the docs sidebar/config.

Comment threadsrc/components/SplitButton/SplitButton.tsx
Comment threadsrc/components/SplitButton/utils.ts

@satya164satya164 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the outline split button design doesn't seem to match the screenshots on md guidelines. they look very dark

Comment threadsrc/components/SplitButton/utils.ts Outdated
Comment threadsrc/components/SplitButton/SplitButton.tsx Outdated
Comment threadsrc/components/SplitButton/SplitButton.tsx Outdated
Comment threadsrc/components/SplitButton/SplitButton.tsx Outdated
Comment threadsrc/components/SplitButton/SplitButton.tsx Outdated
@draggie

Copy link
Copy Markdown
Author

Improved PR @satya164

@satya164satya164 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the code doesn't run, and has lint and type errors + failing tests.

Simulator Screenshot - iPhone 17 Pro - 2026-06-28 at 11 30 59Screenshot_1782639050

@satya164satya164 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The design doesn't quite match Material design guidelines

MD:

Image

Ours:

Image

Specifically the following:

  • Chevron icon
  • Border radius on inner sides

We're also missing expanded state. In MD3, the trailing segment becomes circular (which we can skip for now as we don't have shape morphing), and the chevron animates and rotates. See the video here https://m3.material.io/components/split-button/overview

Also, the documentation is missing. Update docs/component-docs.config.ts

return {
containerColor,
contentColor,
borderColor: isOutlined ? theme.colors.outlineVariant : 'transparent',

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to handle this for disabled according to MD3 tokens.

style={[
styles.label,
icon || loading
? { marginStart: sizeStyle.leadingIconSize / 3 }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The spacing shouldn't be a division of icon size. Material uses multiplier of 4, e.g. 4, 8, 12, 16 etc. This division produces fractions.

Comment on lines +20 to +30
export type SplitButtonNormalizedMode =
| 'filled'
| 'tonal'
| 'elevated'
| 'outlined';

export const normalizeSplitButtonMode = (
mode: SplitButtonMode
): SplitButtonNormalizedMode => {
return mode;
};

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This SplitButtonNormalizedMode type is same as SplitButtonMode and normalizeSplitButtonMode does nothing. Remove them.

Comment on lines +15 to +100
return (
<ScreenWrapper>
<List.Section title="Playground">
<View style={styles.playground}>
<Menu
visible={menuVisible}
onDismiss={() => setMenuVisible(false)}
anchorPosition="bottom"
anchor={
<SplitButton
label="Send"
icon="send"
mode="filled"
disabled={disabled}
loading={loading}
onPress={() => {}}
onTrailingPress={() => setMenuVisible(true)}
trailingAccessibilityLabel="Show send options"
trailingAccessibilityState={{ expanded: menuVisible }}
/>
}
>
<Menu.Item
leadingIcon="schedule"
title="Schedule send"
onPress={() => setMenuVisible(false)}
/>
<Menu.Item
leadingIcon="content-save"
title="Save draft"
onPress={() => setMenuVisible(false)}
/>
</Menu>
</View>
<List.Item
title="Disabled"
right={() => <Switch value={disabled} onValueChange={setDisabled} />}
/>
<List.Item
title="Loading"
right={() => <Switch value={loading} onValueChange={setLoading} />}
/>
</List.Section>

<List.Section title="Modes">
<View style={styles.row}>
{modes.map((mode) => (
<SplitButton
key={mode}
mode={mode}
icon="plus"
label={mode}
onPress={() => {}}
onTrailingPress={() => {}}
trailingAccessibilityLabel={`${mode} options`}
/>
))}
</View>
</List.Section>

<List.Section title="Custom">
<View style={styles.column}>
<SplitButton
mode="outlined"
icon="palette"
label="Custom color"
buttonColor={theme.colors.tertiaryContainer}
textColor={theme.colors.onTertiaryContainer}
onPress={() => {}}
onTrailingPress={() => {}}
trailingAccessibilityLabel="Custom color options"
/>
<SplitButton
mode="filled"
label="Custom label style"
icon="format-bold"
labelStyle={styles.boldLabel}
onPress={() => {}}
onTrailingPress={() => {}}
trailingAccessibilityLabel="Custom label options"
/>
</View>
</List.Section>
</ScreenWrapper>
);
};

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Include examples for multiple sizes

export const splitButtonElevation = {
disabled: 0,
enabled: 1,
pressed: 2,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems unused

/>
) : null}
<Text
variant={sizeStyle.labelVariant}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

labelTextStyle also applies font styles. Keep only one to avoid confusion on which one is applied.


export type Props = $Omit<
ViewProps,
'children' | 'style' | 'onPress' | 'onPressIn' | 'onPressOut'

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

View doesn't have these

Suggested change
'children'|'style'|'onPress'|'onPressIn'|'onPressOut'
'children'|'style'

return hitSlop;
}

const insetHitSlop = (hitSlop || {}) as Insets;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't use as type. It's also unnecessary to fallback to {}. undefined/null can spread fine.

Comment on lines +44 to +233
it('renders a filled split button by default', async () => {
await renderSplitButton();

expect(screen.getByTestId('split-button-label')).toHaveTextContent('Send');
expect(screen.getByTestId('split-button-container')).toHaveStyle({
height: 40,
});
expect(screen.getByTestId('split-button-leading-container')).toBeTruthy();
expect(screen.getByTestId('split-button-trailing-container')).toBeTruthy();
});

it('calls leading and trailing press handlers separately', async () => {
const user = userEvent.setup();
const onPress = jest.fn();
const onTrailingPress = jest.fn();
await renderSplitButton({ onPress, onTrailingPress });

await user.press(screen.getByTestId('split-button-leading'));
await user.press(screen.getByTestId('split-button-trailing'));

expect(onPress).toHaveBeenCalledTimes(1);
expect(onTrailingPress).toHaveBeenCalledTimes(1);
});

it('calls leading and trailing press-in and press-out handlers separately', async () => {
const onPressIn = jest.fn();
const onPressOut = jest.fn();
const onTrailingPressIn = jest.fn();
const onTrailingPressOut = jest.fn();
await renderSplitButton({
onPressIn,
onPressOut,
onTrailingPressIn,
onTrailingPressOut,
});

await fireEvent(screen.getByTestId('split-button-leading'), 'onPressIn');
await fireEvent(screen.getByTestId('split-button-leading'), 'onPressOut');
await fireEvent(screen.getByTestId('split-button-trailing'), 'onPressIn');
await fireEvent(screen.getByTestId('split-button-trailing'), 'onPressOut');

expect(onPressIn).toHaveBeenCalledTimes(1);
expect(onPressOut).toHaveBeenCalledTimes(1);
expect(onTrailingPressIn).toHaveBeenCalledTimes(1);
expect(onTrailingPressOut).toHaveBeenCalledTimes(1);
});

it('uses resting inner-corner tokens for both sides', async () => {
const theme = getTheme();
await renderSplitButton();

expect(screen.getByTestId('split-button-leading-container')).toHaveStyle({
borderTopEndRadius: theme.shapes.corner.extraSmall,
borderBottomEndRadius: theme.shapes.corner.extraSmall,
});
expect(screen.getByTestId('split-button-trailing-container')).toHaveStyle({
borderTopStartRadius: theme.shapes.corner.extraSmall,
borderBottomStartRadius: theme.shapes.corner.extraSmall,
});
});

it('marks both press targets disabled when disabled', async () => {
await renderSplitButton({ disabled: true });

expect(screen.getByTestId('split-button-leading')).toBeDisabled();
expect(screen.getByTestId('split-button-trailing')).toBeDisabled();
});

it('passes custom styles to the correct target', async () => {
await renderSplitButton({
leadingButtonStyle: styles.leading,
trailingButtonStyle: styles.trailing,
labelStyle: styles.label,
});

expect(screen.getByTestId('split-button-leading-container')).toHaveStyle(
styles.leading
);
expect(screen.getByTestId('split-button-trailing-container')).toHaveStyle(
styles.trailing
);
expect(screen.getByTestId('split-button-label')).toHaveStyle(styles.label);
});

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('does not add SplitButton test IDs unless testID is provided', async () => {
await render(
<SplitButton label="Send" onPress={() => {}} onTrailingPress={() => {}} />
);

expect(screen.queryByTestId('split-button-container')).toBeNull();
expect(screen.queryByTestId('split-button-leading')).toBeNull();
expect(screen.queryByTestId('split-button-trailing')).toBeNull();
});

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.outlineVariant
);
});

it('resolves ripple colors from overrides, string content colors, and opaque colors', () => {
const theme = getTheme();
const customRippleColor = 'rgba(1, 2, 3, 0.4)';

expect(
getSplitButtonRippleColor({
contentColor: theme.colors.primary,
customRippleColor,
})
).toBe(customRippleColor);
expect(
getSplitButtonRippleColor({ contentColor: theme.colors.primary })
).toBe('rgba(103, 80, 164, 0.1)');
expect(
getSplitButtonRippleColor({ contentColor: PlatformColor('label') })
).toBeUndefined();
});

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();
});
});

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove tests that test implementation details such as exact style values. only test public behavior.

Comment on lines +151 to +153
expect(normalizeSplitButtonMode('filled')).toBe('filled');
expect(normalizeSplitButtonMode('tonal')).toBe('tonal');
expect(normalizeSplitButtonMode('outlined')).toBe('outlined');

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these tests are testing nothing. normalizeSplitButtonMode doesn't do anything.

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated no new comments.

Suppressed comments (3)

src/components/SplitButton/utils.ts:160

  • Outlined mode uses outlineVariant, but the SplitButton theme documentation added in this PR specifies the MD3 outline role (docs/src/data/themeColors.ts:326). This makes the rendered border lighter than the documented/spec color. Use theme.colors.outline here.
 borderColor: isOutlined ? theme.colors.outlineVariant : 'transparent',

src/components/SplitButton/SplitButton.tsx:289

  • The ripple is derived before the labelStyle color is resolved. When labelStyle.color is set, the label and both icons use that color below, but the pressed state layer still uses the mode's default content color. Resolve the label color inside this memo so the state layer follows the content actually rendered.
 const rippleColor = React.useMemo(
() =>
getSplitButtonRippleColor({
contentColor: colors.contentColor,
customRippleColor,
}),
[colors.contentColor, customRippleColor]
);

src/components/SplitButton/SplitButton.tsx:460

  • splitButtonElevation.pressed is unreachable: elevation is based only on mode and disabled, so an elevated SplitButton remains at level 1 throughout both press flows. Wire the segment press-in/out handlers to apply the newly defined pressed elevation and restore the enabled elevation on release.
 const elevation = isElevationEntitled
? splitButtonElevation.enabled
: splitButtonElevation.disabled;

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: add Split Button

3 participants

@draggie@satya164