Split Button
A primary action joined to a dropdown of related secondary actions — one default click, plus a chevron menu.
- Status
- Since
0.1.0- Accessibility pattern
- native button + APG menu button
Last updated
Install
Add Split Button from the VegaStack registry. The CLI verifies the item's integrity hash before writing it.
pnpm dlx shadcn@latest add @vegastack/split-buttonThe same command installs the registry items it composes: @vegastack/button, @vegastack/dropdown-menu, @vegastack/icon-button.
Usage
import { SplitButton } from "@/components/ui/split-button";
import { ArrowRight, Copy } from "lucide-react";
<SplitButton
onClick={save}
actions={[
{
label: "Save and continue",
icon: <ArrowRight />,
onClick: saveAndContinue,
},
{ label: "Save as draft", icon: <Copy />, onClick: saveDraft },
]}
>
Save changes
</SplitButton>;Anatomy
SplitButton composes a Button for the primary action with a DropdownMenu whose
trigger is a second, chevron-only Button. The two halves are joined into one control: the primary
is rounded-l, the trigger is rounded-r, and a shared 1px seam (-ml-px) overlaps their borders.
The dropdown content portals to <body> and positions itself against the trigger.
<div data-slot="split-button">
<Button data-slot="split-button-primary" /> {/* default action, rounded-l, square right */}
<DropdownMenu>
<DropdownMenuTrigger
render={<Button data-slot="split-button-trigger" />} {/* chevron, rounded-r, square left */}
/>
<DropdownMenuContent align="end">
<DropdownMenuItem /> {/* one per `actions` entry, or composed via `menu` */}
</DropdownMenuContent>
</DropdownMenu>
</div>;split-button— the inline-flex wrapper that joins both halves; carriesdata-variant/data-tone/data-size.split-button-primary— the primary actionButton;childrenis its label andonClickits handler.split-button-trigger— the chevronIconButtonthat opens the menu; named bymenuLabel(default"More options"). It is a square of the samesize, with its leading corners squared off (rounded-s-none).- The menu is filled either by the declarative
actionsarray or by composingDropdownMenuItemchildren via themenuslot.
Examples
Variants
variant passes straight through to both halves, so the joined control always stays visually
consistent: solid, soft, outline, ghost, link. The shared seam (-ms-px) reads differently
per variant — on ghost / link the halves blend into a single hover surface, while bordered
variants keep a crisp divider between them.
Tones
tone passes through as well, and the same forbidden cell applies: tone="destructive" never takes
variant="solid".
Sizes
xs, sm, md, and lg — mirroring Button. The chevron half is an IconButton of the same
size, so it is a perfect square against whatever width the primary half takes.
States
Mark a secondary action destructive to tint it; loading shows a spinner on the primary and
disables both halves; disabled disables the whole control. Compose DropdownMenuItem children via
menu when you need separators, labels, or submenus instead of the flat actions array.
Disabled action item
Set disabled on an individual actions entry to dim it and remove it from keyboard navigation while
keeping the rest of the menu active — distinct from disabling the whole control. Open the menu to see
the dimmed “Send to channel” row.
<SplitButton
variant="outline"
actions={[
{ label: "Copy link", icon: <Copy /> },
{ label: "Add to favorites", icon: <Star /> },
{ label: "Send to channel", icon: <Send />, disabled: true },
]}
>
Share
</SplitButton>Playground
Try the variant × tone matrix and every size on the joined control, flip the second menu action
destructive, and toggle the disabled / loading states, then copy the generated JSX.
<SplitButton
actions={[
{ label: 'Save and continue' },
{ label: 'Save as draft' },
]}
>
Save
</SplitButton>API Reference
SplitButton
| Prop | Type | Default | Description |
|---|---|---|---|
children* | React.ReactNode | — | The primary action's label. |
actions | [SplitButtonAction, ...SplitButtonAction[]] | — | Secondary actions shown in the dropdown. |
className | string | ((state: ButtonState) => string | undefined) | — | Classes or a Base UI state resolver merged with the button variants. |
data-loading | string | — | Loading-state marker for wrapper components that reflect a host-owned pending
state onto a composed Button without its loading visuals (e.g. SplitButton's
chevron half). The Button's own loading prop always wins when set. |
data-slot | string | 'button' | Slot marker for wrapper components that compose Button through Base UI
render and need their own generated registry slot. |
loading | boolean | false | Shows a spinner over the label, disables interaction, and sets aria-busy. The
label keeps its box at opacity: 0, so the button's width does not move across
the flip and its accessible name survives. |
menu | React.ReactNode | — | Compose DropdownMenuItem (and labels, separators, submenus)
directly instead of using the declarative actions array. |
menuAlign | Align | 'end' | Alignment of the dropdown relative to the trigger. |
menuContentProps | Omit<DropdownMenuContentProps, "align" | "children"> | — | Props forwarded to the DropdownMenuContent. |
menuLabel | string | 'More options' | Accessible name for the dropdown trigger (the chevron has no visible text). |
size | ButtonSize | 'md' | Control height, from the one xs · sm · md · lg vocabulary the --size-* tokens carry. |
tone | ButtonTone | — | |
variant | "cta" | "ghost" | "link" | "outline" | "soft" | "solid" | — |
Data attributes and CSS variables on SplitButton
| Attribute | Values |
|---|---|
data-loading | "" |
data-size | mirrors a prop or state value |
data-slot | "split-button" | "split-button-primary" | "split-button-trigger" |
data-tone | mirrors a prop or state value |
data-variant | mirrors a prop or state value |
SplitButtonAction
| Prop | Type | Default | Description |
|---|---|---|---|
label* | React.ReactNode | — | The visible label for the action. |
destructive | boolean | false | Styles the item as a destructive (delete/remove) action. |
disabled | boolean | false | Disables the action and removes it from keyboard navigation. |
icon | React.ReactNode | — | Optional leading icon — a single lucide-react / @vegastack/design/icons element. |
onClick | ((event: React.MouseEvent<HTMLDivElement>) => void) | — | Invoked when the action is selected. |
Accessibility
- Two distinct buttons: the primary runs the default action on Enter / Space; the chevron trigger opens the menu. The chevron is icon-only, so it carries an
aria-label(menuLabel, default"More options"). - The popup renders with
role="menu"and its rows asmenuitem; Base UI provides roving focus, the highlighted item usesdata-highlighted, and focus returns to the trigger on close. - Each half is a real
Button, so:focus-visiblefollowsButton's treatment: every variant keeps the design system's global 2px focus ring (outline-ring), and theoutlinevariant additionally re-colors its border with theringtoken (focus-visible:border-ring/(--alpha-tint-border)) — neveroutline: none. loading/disableddisable both halves and remove them from the tab order.
| Key | Action |
|---|---|
| Enter / Space | Activate the focused half (run the primary action, or open the menu). |
| ↓ | Open the menu from the trigger, focusing the first item. |
| ↑ / ↓ | Move between menu items (wraps at the ends). |
| Esc | Close the menu and return focus to the trigger. |
| Contract | States tested |
|---|---|
| Behaviour | default, disabled, error, loading, selected |
| Accessibility | busy, disabled, labeled |
| Visual | default, disabled, loading, error |