DocumentationComponentsTheme CreatorGitHub
Theme CreatorGitHubIntroduction
InstallationUsageTheming
ComponentsAccordionAction SheetAlertAlertDialogArea ChartAspectRatioAvatarBadgeBannerBar ChartBottom NavBreadcrumbButtonButtonGroupCalendarCardCarouselChartChatBubbleChatBubbleNewCheckboxChipCoachMarkCodeBlockCollapsibleColor PickerComboboxCommandContainerContextMenuDate PickerDividerDrawerDropdown MenuEmptyStateFABFieldFieldsetFile UploadFlexFormGridHoverCardIconButtonLabelLine ChartLinkMenubarMeterModalNavigation MenuNumberFieldOTP InputPaginationPickerPie ChartPopoverProgressPromptInputRadar ChartRadial ChartRadioRatingScroll AreaSearchSegmentedControlSelectShortcutSidebarSkeletonSliderCircularSliderMediaTrimmerSpacerSpinnerSplit ButtonStackStatusStepperSurfaceSwitchTableTabsTextareaTime PickerToastToggleButtonToggleGroupTokenizerToolbarTooltipTop Header DesktopTop Header Mobile
Contributing
Components

Split Button

A prominent action with a menu of related actions attached to it

A split button is two controls that read as one: a dominant action and a menu of variations on that action. Reach for it when one option is right most of the time and the alternatives are worth keeping one tap away — merging a pull request, sending now versus scheduling, saving versus saving a copy.

Use something else when the options are equal in weight (a plain Dropdown Menu, so nothing looks preferred), when there is no sensible default action, or when the extra actions are unrelated to the main one (a primary Button beside an overflow Icon Button).

Playground

Installation

pnpm add @tessinaui/ui

Usage

import { SplitButton } from "@tessinaui/ui";
<SplitButton label="Create project">
  <SplitButton.Item>Blank project</SplitButton.Item>
  <SplitButton.Item>Use a template</SplitButton.Item>
</SplitButton>

The children are menu contents. SplitButton.Item and its siblings are the Dropdown Menu parts, so anything that works in a menu works here.

Rules

  • Never repeat the primary action inside the menu. The button already does it; a duplicate row makes people wonder which one is real.
  • Keep the menu short — under about six items. A long list belongs in a plain menu, where nothing pretends to be the default.
  • One clear default. If you cannot name the action people want most of the time, this is the wrong component.
  • Destructive options go in the menu, last, with intent="error" — never as the primary action.
  • Label both halves in full. The menu button's name is composed for you ("More options, Save"); override it with menuLabel to translate or to phrase it per the action ("More merge options").

Examples

Default

The primary action, a seam, and the menu button. The seam fades while either half is hovered or the menu is held open.

Variants

Four visual weights, shared by both halves so the pair reads as one control. Every variant — outline included — marks the join with the same short seam; the outline's outer border stays continuous around the pair.

Intents

Semantic meaning. A destructive primary action is variant="primary" intent="error"; a destructive option is a menu item with intent="error".

Sizes

Five sizes. xs and sm grow to the 44px touch floor on phones, on both axes, so a short label cannot fall under the target size.

Rounded

Every step of the radius scale. Outer corners follow rounded; the corners at the join stay square, so the control reads as one object with a seam rather than two pills pushed together. The menu's radius follows the same prop.

With icon

A leading icon before the label. Icons are decorative — the label names the action.

Menu features

Groups with labels, per-item descriptions, keyboard shortcuts, leading icons, separators and link rows — the menu's full vocabulary.

Destructive option

intent="error" on the item, last in the menu, separated from the safe choices.

Remembered default

A radio group in the menu, with the chosen option becoming the primary action's label — the action you picked last is the one the button does next.

Pending action

loading keeps the action focused and reports aria-busy instead of natively disabling it, so focus is never thrown away mid-interaction. The label stays readable and the spinner takes the leading-icon slot — swapping an existing leadingIcon, so the button's width does not move. The menu stays operable while the action is in flight, which is what lets a long-running or agent-driven action offer a cancel.

Two notes on why it works this way, since most systems do something else. The label is the only thing distinguishing this control's two segments, so hiding it behind a spinner leaves two targets with nothing to read — of eleven systems surveyed, the seven that thought hardest about button loading keep the label and give the spinner a slot (Apple, Primer, Ant Design, Polaris, Chakra, MUI at start/end, Carbon), and the ones that centre-and-hide were designing for a solo button with no slot to borrow. And a spinner centred on the whole control would sit on the seam, claiming a menu half that is still live.

Pair it with a label that says what is happening — "Deploy" → "Deploying…" — the way Apple's HIG describes. That reads only because the label survives.

States

Disabled and loading. disabled takes both halves; loading takes only the action.

Controlled

open with onOpenChange, plus defaultOpen for the uncontrolled case.

Placement

align defaults to end, so the menu hangs under the chevron. side and sideOffset cover the rest.

On a coloured surface

Inside a Surface, both halves inherit tone="on-color" with no prop. The filled pill publishes its own focus-ring offset, so the ring does not cut a page-coloured notch out of the middle of the control.

API Reference

SplitButton

className and style land on the wrapper — the element that owns the control's box. Every other DOM attribute (id, data-*, aria-describedby, form, name, onKeyDown, …) is forwarded to the action button, which is also what ref points at.

PropTypeDefaultDescription
labelstring—The primary action's label. Also names the menu button
variant"primary" | "secondary" | "ghost" | "outline""primary"Visual weight, shared by both halves
intent"none" | "error" | "warning" | "success" | "info""none"Semantic meaning
tone"default" | "on-color"inheritedWhether the control sits on a coloured surface. Inherited from <Surface>; an explicit value wins
size"xs" | "sm" | "md" | "lg" | "xl""md"Density
rounded"none" | "sm" | "md" | "lg" | "xl" | "full""full"Corner treatment
leadingIconReact.ReactNode—Icon before the label. Decorative
trailingIconReact.ReactNode—Icon after the label. Decorative
onClickMouseEventHandler—The primary action
disabledbooleanfalseUnavailable. Disables both halves
loadingbooleanfalseThe action is busy: keeps focus and its label, reports aria-busy, puts a spinner in the leading-icon slot, swallows activation. The menu stays operable
openboolean—Menu open state (controlled)
defaultOpenbooleanfalseInitial menu open state (uncontrolled)
onOpenChange(open: boolean) => void—Called when the menu opens or closes
menuLabelstring"More options"Accessible name for the menu button, announced in front of the action's label
side"top" | "bottom" | "left" | "right""bottom"Which side the menu opens on
align"start" | "center" | "end""end"Menu alignment against the control
sideOffsetnumber4Gap in px between control and menu
menuClassNamestring—Classes for the menu popup. It renders in a portal, so pass a theme class here to style it
menuStyleCSSProperties—Inline styles for the menu popup
menuPositionerClassNamestring—Classes for the menu's positioner — raise the menu's z-index here, not on the popup
classNamestring—Classes for the wrapper
styleCSSProperties—Inline styles for the wrapper
childrenReact.ReactNode—Menu contents

Menu parts

These are re-exports of the Dropdown Menu components — the same components, not lookalikes — so their props are documented there in full.

PartNotes
SplitButton.ItemA menu row. Takes leadingIcon, trailingIcon, description, shortcut, badge, intent="error", disabled
SplitButton.LinkItemA row that navigates; renders an <a>
SplitButton.SeparatorA divider between rows
SplitButton.Group + SplitButton.LabelA titled group of rows
SplitButton.CheckboxItem / SplitButton.SwitchItemToggleable rows
SplitButton.RadioGroup + SplitButton.RadioItemOne-of-many rows — the remembered-default pattern
SplitButton.ShortcutKeyboard hint inside a custom row
SplitButton.Sub + .SubTrigger + .SubContentA submenu

SplitButtonSkeleton

PropTypeDefaultDescription
variantButtonVariant"primary"Mirror the real control's variant so the frame keeps its geometry
sizeSplitButtonSize"md"—
roundedButtonRounded"full"—
actionWidthstring"w-[8ch]"Width class of the label placeholder
charsnumber—Label length in characters; overrides actionWidth

Sizes

SizeHeightUse case
xs32pxCompact toolbars
sm40pxSecondary actions
md48pxDefault
lg56pxProminent actions
xl96pxHero CTAs

Heights are the desktop scale. Below the md breakpoint (768px) xs and sm grow to a 44×44 touch target on both axes per WCAG 2.5.5 — height and width, for the action half and the menu button alike. md and up already clear it.

Data attributes

Stable hooks for styling and for automation.

AttributeOnWhen
data-slot="split-button"wrapperalways
data-slot="split-button-action"action buttonalways
data-slot="split-button-divider"seam (inside the menu half, on the join)always
data-slot="split-button-trigger"menu buttonalways
data-openwrapperwhile the menu is open
data-disabledwrapperwhile disabled
data-loadingwrapper and actionwhile loading and not disabled
data-slot="split-button-spinner"busy spinner, inside the actionwhile loading and not disabled

Accessibility

Two buttons, two tab stops. The action half is a plain button; the menu button is the only one carrying aria-haspopup="menu" and aria-expanded. The wrapper is presentational — it takes no role, so assistive technology announces two controls rather than a group wrapping them.

Names. The menu button's accessible name is composed from menuLabel and the action's label, so it carries enough context without a second visible label. Measured with VoiceOver on Safari, for a button labelled "Create project":

  • action half → "Create project, button"
  • menu button → "More options Create project, menu pop up button"
  • opening it → "Blank project, menu item, 1 of 4, menu, More options Create project" — the menu is labelled by its trigger, and focus lands on the first item

menuLabel is the translation and phrasing hook for the leading part.

Keyboard.

KeyBehaviour
Tab / Shift+TabMove between the action and the menu button, then out of the control
Enter / SpaceOn the action, perform it. On the menu button, open the menu
↓ / ↑On the menu button, open the menu at the first / last item
↓ / ↑In the menu, move between items
Home / EndIn the menu, jump to the first / last item
EnterIn the menu, activate the item and close
EscClose the menu and return focus to the menu button

Busy state. loading sets aria-busy and aria-disabled rather than the native disabled attribute, so the control keeps focus — a button that natively disables itself the moment it is clicked throws focus to the page. The label stays visible and in the accessible name while the spinner shows, so the control never loses its name mid-action. The menu button is untouched by loading: only disabled takes both halves.

Contrast. Both halves use the button family's colour rows, which are audited for WCAG AA in both themes on every supported surface. The seam is deliberately soft — measured 1.95–2.64:1 against its own segment fill across every variant and both themes — and treated as decorative reinforcement: each half is identifiable by its own label or chevron, which is the position Fluent and Atlassian ship. The chevron itself sits at 60% ink and still clears the 3:1 non-text threshold on every fill. The wrapper also publishes --ring-offset-surface, so the focus ring's offset gap is painted in the control's own fill rather than the page's.

Screen readers. Verified with VoiceOver on Safari (macOS) — the announced strings above are captured from a real run, not inferred. NVDA and JAWS are untested: no Windows machine is available to this project, and an untested combination is not claimed as supported.

SpinnerStack

On this page

PlaygroundInstallationUsageRulesExamplesDefaultVariantsIntentsSizesRoundedWith iconMenu featuresDestructive optionRemembered defaultPending actionStatesControlledPlacementOn a coloured surfaceAPI ReferenceSplitButtonMenu partsSplitButtonSkeletonSizesData attributesAccessibility