--primitiv-split-button-seam--primitiv-split-button-trigger-inline-size--primitiv-split-button-ring-outset--primitiv-split-button-ring-radius--primitiv-dropdown-min-inline-sizeOne primary action welded to a chevron trigger that opens a menu of related alternatives, bound into a single role="group" widget.
Preview
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>import { SplitButton } from "@primitiv-ui/react";import { ChevronDown } from "@primitiv-ui/icons";import { useState } from "react";
const [action, setAction] = useState("Save");
<SplitButton.Root style={{ anchorName: "--actions" }}> <SplitButton.Action>{action}</SplitButton.Action> <SplitButton.Trigger aria-label="Change save action"> <ChevronDown aria-hidden="true" /> </SplitButton.Trigger> <SplitButton.Menu style={{ positionAnchor: "--actions" }}> <SplitButton.Item onSelect={() => setAction("Save")}>Save</SplitButton.Item> <SplitButton.Item onSelect={() => setAction("Save and close")}>Save and close</SplitButton.Item> <SplitButton.Item onSelect={() => setAction("Save as draft")}>Save as draft</SplitButton.Item> <SplitButton.Separator /> <SplitButton.Item onSelect={() => setAction("Save as template")}>Save as template</SplitButton.Item> </SplitButton.Menu></SplitButton.Root>Density is set by a data-density ancestor — the Context system, not a SplitButton prop.
npx primitiv add split-buttonpnpm dlx primitiv add split-buttonyarn dlx primitiv add split-buttonbunx primitiv add split-buttonImport
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
<SplitButton> <SplitButtonAction /> {/* the primary action */} <SplitButtonTrigger /> {/* the chevron, opens the menu */} <SplitButtonMenu> <SplitButtonItem /> <SplitButtonSeparator /> <SplitButtonItem /> </SplitButtonMenu></SplitButton><SplitButton.Root> <SplitButton.Action /> {/* the primary action */} <SplitButton.Trigger /> {/* the chevron, opens the menu */} <SplitButton.Menu> <SplitButton.Item /> <SplitButton.Separator /> <SplitButton.Item /> </SplitButton.Menu></SplitButton.Root>Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.
| Prop | Type | Default | From | Description |
|---|---|---|---|---|
| asChild | boolean | false | headless | When 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. |
| children | ReactNode | — | headless | The widget's parts — typically one
SplitButtonActionProps`SplitButton.Action`, one
SplitButtonTriggerProps`SplitButton.Trigger`, and one
SplitButtonMenuProps`SplitButton.Menu`. |
| defaultOpen | boolean | false | headless | Whether 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" | — | headless | Reading 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". |
| disabled | boolean | false | headless | Disables 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 | — | headless | Called 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. |
| open | boolean | — | headless | Forbidden 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. |
| ref | Ref<HTMLDivElement> | — | headless | Allows 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" | primary | styled | Visual 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" | md | styled | Control size, applied to both halves and the menu; data-density scales each size further. |
Extends HTMLButtonElement — every native attribute of that element is accepted and forwarded.
| Prop | Type | Default | From | Description |
|---|---|---|---|---|
| asChild | boolean | false | headless | When 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. |
| children | ReactNode | — | headless | The action's visible label. Keep it real text — the menu trigger borrows it for its own accessible name. |
| ref | Ref<HTMLButtonElement> | — | headless | Allows 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). |
Extends HTMLButtonElement — every native attribute of that element is accepted and forwarded.
| Prop | Type | Default | From | Description |
|---|---|---|---|---|
| asChild | boolean | false | headless | Render 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. |
| children | ReactNode | — | headless | The trigger's visible label (or an element, with asChild). |
| ref | Ref<HTMLButtonElement> | — | headless | Allows 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. |
Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.
| Prop | Type | Default | From | Description |
|---|---|---|---|---|
| asChild | boolean | false | headless | Render 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. |
| children | ReactNode | — | headless | The menu items — any mix of DropdownItemProps`Dropdown.Item`,
DropdownCheckboxItemProps`Dropdown.CheckboxItem`,
DropdownRadioGroupProps`Dropdown.RadioGroup`,
DropdownGroupProps`Dropdown.Group`,
DropdownSeparatorProps`Dropdown.Separator`, and
DropdownSubProps`Dropdown.Sub`. |
| ref | Ref<HTMLMenuElement> | — | headless | Forwarded to the underlying HTMLMenuElement. |
Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.
| Prop | Type | Default | From | Description |
|---|---|---|---|---|
| asChild | boolean | false | headless | Render the composed child element (with menuitem semantics) instead of
the default <li role="menuitem">. |
| children | ReactNode | — | headless | The item's visible content (or an element, with asChild). |
| disabled | boolean | false | headless | Mark the item non-interactive. Sets aria-disabled="true" and skips
the item during arrow navigation, typeahead, and activation. |
| onSelect | (event: Event) => void | — | headless | Fires 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. |
| ref | Ref<HTMLLIElement> | — | headless | Allows 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. |
Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.
| Prop | Type | Default | From | Description |
|---|---|---|---|---|
| asChild | boolean | false | headless | Render the composed child element (with separator semantics) instead of
the default <li role="separator">. |
| children | ReactNode | — | headless | Optional content; separators are usually empty. |
| ref | Ref<HTMLLIElement> | — | headless | Allows 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. |
--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| Key | Behaviour |
|---|---|
| Enter / Space | Run the action (on the action half); open the menu (on the trigger half); activate the focused row (in the menu). |
| ArrowDown / ArrowUp | Open the menu from the trigger, then move between rows (wrapping at the ends). |
| Home / End | Move to the first / last row of the open menu. |
| Escape | Close the menu and return focus to the trigger. |
className: .primitiv-split-button
| Attribute | Value | When |
|---|---|---|
data-split-button | "" | always, on the frame — the hook that joins the action and the trigger into one control |
data-state | open | closed | whether the menu is open |
data-disabled | "" | the whole control is disabled |
| Attribute | Value | When |
|---|---|---|
data-split-button | "" | always, on the frame — the hook that joins the action and the trigger into one control |
data-state | open | closed | whether the menu is open |
data-disabled | "" | the whole control is disabled |
className: .primitiv-split-button__action
| Attribute | Value | When |
|---|---|---|
data-split-button-action | "" | always — the hook that tells the two halves apart |
data-disabled | "" | the action half is disabled |
| Attribute | Value | When |
|---|---|---|
data-split-button-action | "" | always — the hook that tells the two halves apart |
data-disabled | "" | the action half is disabled |
className: .primitiv-split-button__trigger
| Attribute | Value | When |
|---|---|---|
data-split-button-trigger | "" | always — the hook that tells the two halves apart |
data-disabled | "" | the menu half is disabled |
| Attribute | Value | When |
|---|---|---|
data-split-button-trigger | "" | always — the hook that tells the two halves apart |
data-disabled | "" | the menu half is disabled |
className: .primitiv-split-button__menu
| Attribute | Value | When |
|---|---|---|
data-split-button-menu | "" | always — marks the panel as this control's menu rather than a bare Dropdown |
| Attribute | Value | When |
|---|---|---|
data-split-button-menu | "" | always — marks the panel as this control's menu rather than a bare Dropdown |
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.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.[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.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.
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>import { SplitButton } from "@primitiv-ui/react";import { ChevronDown } from "@primitiv-ui/icons";import { useState } from "react";
const [action, setAction] = useState("Save");
<SplitButton.Root style={{ anchorName: "--actions" }}> <SplitButton.Action onClick={() => run(action)}>{action}</SplitButton.Action> <SplitButton.Trigger aria-label="Change save action"> <ChevronDown aria-hidden="true" /> </SplitButton.Trigger> <SplitButton.Menu style={{ positionAnchor: "--actions" }}> <SplitButton.Item onSelect={() => setAction("Save")}>Save</SplitButton.Item> <SplitButton.Item onSelect={() => setAction("Save and close")}>Save and close</SplitButton.Item> <SplitButton.Item onSelect={() => setAction("Save as draft")}>Save as draft</SplitButton.Item> <SplitButton.Separator /> <SplitButton.Item onSelect={() => setAction("Save as template")}>Save as template</SplitButton.Item> </SplitButton.Menu></SplitButton.Root>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.
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>import { SplitButton } from "@primitiv-ui/react";import { ChevronDown } from "@primitiv-ui/icons";
<div data-density="comfortable"> <SplitButton.Root style={{ anchorName: "--actions" }}>...</SplitButton.Root></div>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.
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>import { SplitButton } from "@primitiv-ui/react";import { ChevronDown } from "@primitiv-ui/icons";
<div data-density="comfortable"> <SplitButton.Root style={{ anchorName: "--actions" }}>...</SplitButton.Root> <SplitButton.Root style={{ anchorName: "--actions" }}>...</SplitButton.Root> <SplitButton.Root style={{ anchorName: "--actions" }}>...</SplitButton.Root></div>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.
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>import { SplitButton } from "@primitiv-ui/react";import { ChevronDown, File, Plus } from "@primitiv-ui/icons";import { DropdownItemLeading, DropdownItemLabel } from "@/components/ui/dropdown";
<SplitButton.Menu style={{ positionAnchor: "--actions" }}> <SplitButton.Item> <DropdownItemLeading><Plus aria-hidden="true" /></DropdownItemLeading> <DropdownItemLabel>New from template</DropdownItemLabel> </SplitButton.Item> <SplitButton.Item> <DropdownItemLeading><File aria-hidden="true" /></DropdownItemLeading> <DropdownItemLabel>Duplicate</DropdownItemLabel> </SplitButton.Item> </SplitButton.Menu>