Skip to content
Component installs need the registry setup— the Base UI shadcn project, the @vegastack namespace and the Cloudflare Access service token.
VegaStack Design

Split Button

A primary action joined to a dropdown of related secondary actions — one default click, plus a chevron menu.

Status
stable
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-button

The 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.

SplitButton — data-slot="split-button" | "split-button-primary" | "split-button-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; carries data-variant / data-tone / data-size.
  • split-button-primary — the primary action Button; children is its label and onClick its handler.
  • split-button-trigger — the chevron IconButton that opens the menu; named by menuLabel (default "More options"). It is a square of the same size, with its leading corners squared off (rounded-s-none).
  • The menu is filled either by the declarative actions array or by composing DropdownMenuItem children via the menu slot.

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

PropTypeDefaultDescription
children*React.ReactNodeThe primary action's label.
actions[SplitButtonAction, ...SplitButtonAction[]]Secondary actions shown in the dropdown.
classNamestring | ((state: ButtonState) => string | undefined)Classes or a Base UI state resolver merged with the button variants.
data-loadingstringLoading-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-slotstring'button'Slot marker for wrapper components that compose Button through Base UI render and need their own generated registry slot.
loadingbooleanfalseShows 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.
menuReact.ReactNodeCompose DropdownMenuItem (and labels, separators, submenus) directly instead of using the declarative actions array.
menuAlignAlign'end'Alignment of the dropdown relative to the trigger.
menuContentPropsOmit<DropdownMenuContentProps, "align" | "children">Props forwarded to the DropdownMenuContent.
menuLabelstring'More options'Accessible name for the dropdown trigger (the chevron has no visible text).
sizeButtonSize'md'Control height, from the one xs · sm · md · lg vocabulary the --size-* tokens carry.
toneButtonTone
variant"cta" | "ghost" | "link" | "outline" | "soft" | "solid"

Data attributes and CSS variables on SplitButton

AttributeValues
data-loading""
data-sizemirrors a prop or state value
data-slot"split-button" | "split-button-primary" | "split-button-trigger"
data-tonemirrors a prop or state value
data-variantmirrors a prop or state value

SplitButtonAction

PropTypeDefaultDescription
label*React.ReactNodeThe visible label for the action.
destructivebooleanfalseStyles the item as a destructive (delete/remove) action.
disabledbooleanfalseDisables the action and removes it from keyboard navigation.
iconReact.ReactNodeOptional 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 as menuitem; Base UI provides roving focus, the highlighted item uses data-highlighted, and focus returns to the trigger on close.
  • Each half is a real Button, so :focus-visible follows Button's treatment: every variant keeps the design system's global 2px focus ring (outline-ring), and the outline variant additionally re-colors its border with the ring token (focus-visible:border-ring/(--alpha-tint-border)) — never outline: none.
  • loading / disabled disable both halves and remove them from the tab order.
KeyAction
Enter / SpaceActivate 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).
EscClose the menu and return focus to the trigger.
ContractStates tested
Behaviourdefault, disabled, error, loading, selected
Accessibilitybusy, disabled, labeled
Visualdefault, disabled, loading, error

Do / Don't

Do
Put the single most common action on the primary half and the related variants in the menu.
Don't
Stuff unrelated actions into the menu — if the items aren't variants of the primary, use a Dropdown Menu instead.

On this page