Skip to content
Primitiv home
Framework
Consumption mode

Split Button

stableSource

One primary action welded to a chevron trigger that opens a menu of related alternatives, bound into a single role="group" widget.

Playground

Density

Preview

Size
Variant
import { SplitButton, SplitButtonAction, SplitButtonTrigger, SplitButtonMenu, SplitButtonItem, SplitButtonSeparator } from "@/components/ui/split-button";import { ChevronDown } from "@primitiv-ui/icons";import { useState } from "react";
const [action, setAction] = useState("Save");
<SplitButton variant="primary" size="md">  <SplitButtonAction>{action}</SplitButtonAction>  <SplitButtonTrigger aria-label="Change save action">    <ChevronDown aria-hidden="true" />  </SplitButtonTrigger>  <SplitButtonMenu>    <SplitButtonItem onSelect={() => setAction("Save")}>Save</SplitButtonItem>    <SplitButtonItem onSelect={() => setAction("Save and close")}>Save and close</SplitButtonItem>    <SplitButtonItem onSelect={() => setAction("Save as draft")}>Save as draft</SplitButtonItem>    <SplitButtonSeparator />    <SplitButtonItem onSelect={() => setAction("Save as template")}>Save as template</SplitButtonItem>  </SplitButtonMenu></SplitButton>

Density is set by a data-density ancestor — the Context system, not a SplitButton prop.

Installation

npx primitiv add split-button

Import

import { SplitButton } from "@/components/ui/split-button";

Copied into your project as .primitiv-split-button — you own the file afterwards, so upgrades are opt-in.

Headless mode installs the npm package instead: @primitiv-ui/react

Anatomy

<SplitButton>  <SplitButtonAction />        {/* the primary action */}  <SplitButtonTrigger />       {/* the chevron, opens the menu */}  <SplitButtonMenu>    <SplitButtonItem />    <SplitButtonSeparator />    <SplitButtonItem />  </SplitButtonMenu></SplitButton>

Props

SplitButton.Root

Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
asChildbooleanfalseheadlessWhen true, Root delegates rendering to a single consumer-supplied element via the Slot pattern instead of the default <div>; role="group", data-state, data-disabled and the remaining props are merged onto it.
childrenReactNode—headlessThe widget's parts — typically one SplitButtonActionProps`SplitButton.Action`, one SplitButtonTriggerProps`SplitButton.Trigger`, and one SplitButtonMenuProps`SplitButton.Menu`.
defaultOpenbooleanfalseheadlessWhether the menu is open on first render. Omit to start closed. The component owns the state thereafter. Forbidden in controlled mode — use open instead.
dir"ltr" | "rtl"—headlessReading direction for the menu. Only affects which arrow key opens or closes a submenu composed from Dropdown.Sub — ArrowRight opens in "ltr", ArrowLeft in "rtl". When omitted it is inherited from the nearest DirectionProvider, falling back to "ltr".
disabledbooleanfalseheadlessDisables the whole widget — both the primary action and the menu trigger become non-interactive, and data-disabled="" is set on the group. OR-ed with each half's own disabled, so a disabled group can never be overridden back to enabled by a part.
onOpenChange(open: boolean) => void—headlessCalled whenever a user-driven transition opens or closes the menu (trigger click, ArrowDown on the action, Escape, outside click, selection). Optional in uncontrolled mode. Called whenever a user-driven transition would open or close the menu. The parent is responsible for reflecting the new value back into open. Required in controlled mode.
openboolean—headlessForbidden in uncontrolled mode — use defaultOpen instead. Whether the menu is currently open. Must be kept in sync by the parent via onOpenChange; the component never mutates it internally.
refRef<HTMLDivElement>—headlessAllows getting a ref to the component instance. Once the component unmounts, React will set ref.current to null (or call the ref with null if you passed a callback ref). Forwarded to the underlying HTMLDivElement (or to the asChild element).
variant"primary" | "secondary" | "danger"primarystyledVisual intent, applied to both halves. Ghost and link are deliberately unavailable — neither has a box at rest for the seam to divide.
size"xs" | "sm" | "md" | "lg" | "xl"mdstyledControl size, applied to both halves and the menu; data-density scales each size further.

SplitButton.Action

Extends HTMLButtonElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
asChildbooleanfalseheadlessWhen true, renders the consumer's element instead of <button> via the Slot pattern. type="button" is not forwarded in this mode — the child owns its own type semantics.
childrenReactNode—headlessThe action's visible label. Keep it real text — the menu trigger borrows it for its own accessible name.
refRef<HTMLButtonElement>—headlessAllows getting a ref to the component instance. Once the component unmounts, React will set ref.current to null (or call the ref with null if you passed a callback ref). Forwarded to the underlying HTMLButtonElement (or to the asChild element).

SplitButton.Trigger

Extends HTMLButtonElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
asChildbooleanfalseheadlessRender the composed child element instead of the default <button type="button">. The trigger's ARIA contract (aria-haspopup, aria-expanded, aria-controls) and its click handler are merged onto the child via the Slot pattern.
childrenReactNode—headlessThe trigger's visible label (or an element, with asChild).
refRef<HTMLButtonElement>—headlessAllows getting a ref to the component instance. Once the component unmounts, React will set ref.current to null (or call the ref with null if you passed a callback ref). Forwarded to the underlying HTMLButtonElement.

SplitButton.Menu

Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
asChildbooleanfalseheadlessRender the composed child element (with menu semantics) instead of the default <menu role="menu" popover="auto">. The managed role, popover, and id attributes and the keyboard handler are merged onto the child via the Slot pattern.
childrenReactNode—headlessThe menu items — any mix of DropdownItemProps`Dropdown.Item`, DropdownCheckboxItemProps`Dropdown.CheckboxItem`, DropdownRadioGroupProps`Dropdown.RadioGroup`, DropdownGroupProps`Dropdown.Group`, DropdownSeparatorProps`Dropdown.Separator`, and DropdownSubProps`Dropdown.Sub`.
refRef<HTMLMenuElement>—headlessForwarded to the underlying HTMLMenuElement.

SplitButton.Item

Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
asChildbooleanfalseheadlessRender the composed child element (with menuitem semantics) instead of the default <li role="menuitem">.
childrenReactNode—headlessThe item's visible content (or an element, with asChild).
disabledbooleanfalseheadlessMark the item non-interactive. Sets aria-disabled="true" and skips the item during arrow navigation, typeahead, and activation.
onSelect(event: Event) => void—headlessFires when the item is activated (click, Enter, or Space). Called with a cancellable event whose preventDefault() skips the auto-close that Dropdown performs after selection.
refRef<HTMLLIElement>—headlessAllows getting a ref to the component instance. Once the component unmounts, React will set ref.current to null (or call the ref with null if you passed a callback ref). Forwarded to the underlying HTMLLIElement.

SplitButton.Separator

Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
asChildbooleanfalseheadlessRender the composed child element (with separator semantics) instead of the default <li role="separator">.
childrenReactNode—headlessOptional content; separators are usually empty.
refRef<HTMLLIElement>—headlessAllows getting a ref to the component instance. Once the component unmounts, React will set ref.current to null (or call the ref with null if you passed a callback ref). Forwarded to the underlying HTMLLIElement.

Styling contract

--primitiv-split-button-seam--primitiv-split-button-trigger-inline-size--primitiv-split-button-ring-outset--primitiv-split-button-ring-radius--primitiv-dropdown-min-inline-size

Keyboard

KeyBehaviour
Enter / SpaceRun the action (on the action half); open the menu (on the trigger half); activate the focused row (in the menu).
ArrowDown / ArrowUpOpen the menu from the trigger, then move between rows (wrapping at the ends).
Home / EndMove to the first / last row of the open menu.
EscapeClose the menu and return focus to the trigger.

Data attributes

SplitButton

className: .primitiv-split-button

AttributeValueWhen
data-split-button""always, on the frame — the hook that joins the action and the trigger into one control
data-stateopen | closedwhether the menu is open
data-disabled""the whole control is disabled

SplitButtonAction

className: .primitiv-split-button__action

AttributeValueWhen
data-split-button-action""always — the hook that tells the two halves apart
data-disabled""the action half is disabled

SplitButtonTrigger

className: .primitiv-split-button__trigger

AttributeValueWhen
data-split-button-trigger""always — the hook that tells the two halves apart
data-disabled""the menu half is disabled

SplitButtonMenu

className: .primitiv-split-button__menu

AttributeValueWhen
data-split-button-menu""always — marks the panel as this control's menu rather than a bare Dropdown

Accessibility

  • The whole control is one role="group" (data-split-button), and the two halves are separate real <button>s — so the action runs on one press while the menu opens from the other, and both are in the tab order.
  • The SplitButton.Trigger is icon-only, so it needs an accessible name: it derives one from the SplitButton.Action's visible label automatically, or pass aria-label/aria-labelledby to override — the chevron itself is aria-hidden.
  • The menu is the WAI-ARIA Menu pattern — a single tab stop with roving focus, opened in the top layer as a native [popover], so it escapes overflow: hidden ancestors and light-dismisses on an outside click. Focus moves into it on open and back to the trigger on close.
  • disabled works per half or for the whole group: disable the action alone (the menu of alternatives may still be useful), the trigger alone, or the root (which sets data-disabled on the frame and both halves).
  • variant and size are set once on the root and cascade to both halves and the menu, so the two buttons can never drift out of step — the seam always divides one coherent control.

Examples

SplitButton.Action runs the default immediately; click the chevron to open SplitButton.Menu for the alternatives. Here the action is a sticky default — each SplitButton.Item's onSelect sets state that becomes the button's label, so picking one makes it the new primary (try it, then read the button). onSelect closes the menu; SplitButton.Separator groups related runs. The menu is a Dropdown panel floored at the group's width and aligned to its leading edge, and the anchor is wired for you.

Density
import { SplitButton, SplitButtonAction, SplitButtonTrigger, SplitButtonMenu, SplitButtonItem, SplitButtonSeparator } from "@/components/ui/split-button";import { ChevronDown } from "@primitiv-ui/icons";import { useState } from "react";
const [action, setAction] = useState("Save");
<SplitButton>  <SplitButtonAction onClick={() => run(action)}>{action}</SplitButtonAction>  <SplitButtonTrigger aria-label="Change save action">    <ChevronDown aria-hidden="true" />  </SplitButtonTrigger>  <SplitButtonMenu>    <SplitButtonItem onSelect={() => setAction("Save")}>Save</SplitButtonItem>    <SplitButtonItem onSelect={() => setAction("Save and close")}>Save and close</SplitButtonItem>    <SplitButtonItem onSelect={() => setAction("Save as draft")}>Save as draft</SplitButtonItem>    <SplitButtonSeparator />    <SplitButtonItem onSelect={() => setAction("Save as template")}>Save as template</SplitButtonItem>  </SplitButtonMenu></SplitButton>

Variants

variant is set once on the root and applies to both halves, so the seam divides one coherent control. Three intents: primary, secondary, danger. ghost and link are deliberately unavailable — neither has a box at rest for the seam to divide, so a welded pair would read as a label with a stray chevron.

Density
import { SplitButton, SplitButtonAction, SplitButtonTrigger, SplitButtonMenu, SplitButtonItem } from "@/components/ui/split-button";import { ChevronDown } from "@primitiv-ui/icons";
<div data-density="comfortable">  <SplitButton variant="danger">...</SplitButton></div>

Sizes and density

size is also set once on the root and drives both halves and the menu together — five sizes, xs to xl — and each rescales again with the nearest data-density ancestor. The chevron half stays square at every size.

Density
import { SplitButton, SplitButtonAction, SplitButtonTrigger, SplitButtonMenu, SplitButtonItem } from "@/components/ui/split-button";import { ChevronDown } from "@primitiv-ui/icons";
<div data-density="comfortable">  <SplitButton size="sm">...</SplitButton>  <SplitButton size="md">...</SplitButton>  <SplitButton size="lg">...</SplitButton></div>

Richer menu rows

SplitButton provides the Dropdown context, so any Dropdown row part composes inside SplitButton.Menu — here DropdownItemLeading + DropdownItemLabel for an icon-plus-label row. DropdownGroup/DropdownLabel, DropdownCheckboxItem and DropdownSub work the same way; import them from the dropdown component.

Density
import { SplitButton, SplitButtonAction, SplitButtonTrigger, SplitButtonMenu, SplitButtonItem } from "@/components/ui/split-button";import { ChevronDown, File, Plus } from "@primitiv-ui/icons";import { DropdownItemLeading, DropdownItemLabel } from "@/components/ui/dropdown";
  <SplitButtonMenu>    <SplitButtonItem>      <DropdownItemLeading><Plus aria-hidden="true" /></DropdownItemLeading>      <DropdownItemLabel>New from template</DropdownItemLabel>    </SplitButtonItem>    <SplitButtonItem>      <DropdownItemLeading><File aria-hidden="true" /></DropdownItemLeading>      <DropdownItemLabel>Duplicate</DropdownItemLabel>    </SplitButtonItem>  </SplitButtonMenu>