Skip to content

Split Button

Combines a default action with a dropdown for additional options.

  • Dropdown Button
  • Combo Button

Overview

The Split Button combines a default action button with an adjacent dropdown trigger, creating a compound control that offers a primary action alongside alternative options. Clicking the main area executes the default action immediately; clicking the dropdown arrow reveals additional related actions.

This pattern solves the "too many buttons" problem while preserving one-click access to the most common action. Consider a "Save" split button: the main click saves normally, while the dropdown offers "Save as Draft", "Save and Close", "Save as Template". The user gets speed (one click for the common case) and flexibility (dropdown for alternatives).

When to use a Split Button:

  • When there's a clear primary action with related secondary options (Save / Save As / Save and Close)
  • For "do the last thing I did" patterns where the default action changes based on history
  • In toolbar contexts where space is limited but multiple related actions exist
  • For deployment/publish workflows (Publish / Publish to Staging / Schedule)

When NOT to use a Split Button:

  • When all actions are equally important — use a Button Group instead
  • When there's only one action — use a standard Button
  • When the dropdown options are navigation links — use a Dropdown Menu with a trigger button
  • When actions are unrelated — split buttons imply a family of related actions
  • On mobile where the small dropdown trigger is hard to tap — use a full Dropdown Menu

The split button's divider and dropdown trigger must be visually distinct from the main action. Use the Button Generator to style both segments consistently. Verify the divider and dropdown arrow contrast with the Contrast Checker.

Variants

Common Split Button Variants

VariantPurposeVisual Treatment
PrimaryMain page CTA with alternativesBrand-color filled button, matching dropdown trigger with divider
SecondarySupporting actions with alternativesOutlined button, matching outlined dropdown trigger
GhostLow-emphasis action with alternativesText-only button, minimal dropdown trigger
DestructiveDangerous default with less-dangerous alternativesRed main button, matching red dropdown trigger

Layout Variants

LayoutDropdown TriggerUse Case
StandardSmall arrow-down section (32–40px wide) separated by a vertical dividerDefault. Most common pattern.
Icon-only triggerOnly the dropdown arrow, no visible dividerWhen the split is subtle — appears as one button until interacted
Full-widthMain action fills available space, trigger stays fixed widthForm submit areas, mobile layouts

Dropdown Position

PositionBehavior
Below (default)Menu drops down, aligns to the right edge of the button
AboveMenu opens upward when near the bottom of the viewport
Align leftMenu aligns to the left edge of the entire split button
Align rightMenu aligns to the right edge (default for LTR layouts)

Properties

Split Button Properties

PropertyTypeDefaultDescription
variant'primary' | 'secondary' | 'ghost' | 'destructive''primary'Visual style applied to both segments
size'sm' | 'md' | 'lg''md'Size applied to both segments
labelstring—Main button text
iconReactNode—Optional icon for the main button
onClick() => void—Handler for the main action click
disabledbooleanfalseDisables both segments
loadingbooleanfalseShows spinner on main button, disables both segments
menuItemsMenuItem[][]Array of dropdown menu items: { label, onClick, icon?, disabled?, destructive? }
menuPlacement'bottom-start' | 'bottom-end' | 'top-start' | 'top-end''bottom-end'Dropdown positioning
dropdownAriaLabelstring'More options'Accessible label for the dropdown trigger
fullWidthbooleanfalseStretches the split button to fill container width

Important: The split button is actually two separate buttons sharing a visual container. Each button must be independently focusable and have its own accessible label. The main button's label comes from the visible text. The dropdown trigger needs an explicit aria-label (e.g., "More save options"). See the Button component for additional button prop patterns.

Token Mappings

Design Token Mappings

Token CategoryToken ExampleSplit Button Usage
Color – Fill--color-primary-600Main button background (primary variant)
Color – Text--color-on-primaryMain button and dropdown trigger text/icon
Color – Dividerrgba(255,255,255,0.3) or --color-primary-700Vertical divider between main and dropdown
Color – Hover--color-primary-700Hover state for main button
Color – Dropdown Hover--color-primary-800 or separate hover shadeHover state for dropdown trigger
Color – Menu BG--color-surface-elevatedDropdown menu background
Color – Menu Item Hover--color-action-hoverMenu item hover background
Border – Radius--radius-mdOuter corners only — inner edges are flat
Border – Divider Width1pxWidth of the vertical separator
Spacing – Dropdown Width36px – 40pxFixed width of the dropdown trigger section
Shadow – Menu--shadow-lgDropdown menu elevation
Z-Index – Menu--z-dropdown (1000)Menu layer above content
Transition--duration-fast (150ms)Hover/active state transitions

Divider Styling

The divider between the main button and dropdown trigger is critical. It must be:

  • Visible enough to signal two separate click zones
  • Subtle enough to maintain the "single unit" visual
  • A lighter/darker shade of the button color (not a contrasting color)

For primary buttons: border-left: 1px solid rgba(255,255,255,0.3) For outlined buttons: the existing border serves as the divider — just ensure the trigger area is visually distinct on hover.

States

Split Button States

StateMain ButtonDropdown TriggerNotes
DefaultStandard resting stateArrow-down icon, matching styleBoth segments at rest
Main HoverHover background on main area onlyRemains at restEach segment hovers independently
Dropdown HoverRemains at restHover background on trigger onlyIndependent hover reinforces the two-zone model
Main ActiveActive/pressed on main areaRemains at restClick feedback on main action
Menu OpenMay dim slightlyActive state, arrow may rotate 180°Menu visible below
DisabledBoth segments muted, no interactionBoth segments mutedDisable as a unit, never individually
LoadingSpinner replaces main button textTrigger also disabledLoading state applies to the entire component
Focused (Main)Focus ring around main button areaNo focus ringTab focuses main button first
Focused (Trigger)No focus ringFocus ring around triggerTab (or arrow key) moves to trigger

Dropdown Menu States

StateBehavior
ClosedMenu not visible. Trigger shows arrow-down.
OpeningMenu fades/scales in. Use transform: scaleY or opacity transition.
OpenMenu visible. Focus moves to first menu item.
Item HoverHovered item shows background highlight
Item FocusedFocused item shows visible focus ring (keyboard navigation)
ClosingMenu fades/scales out. Focus returns to trigger.

Accessibility

Accessibility

Split buttons are complex composite controls that require careful ARIA implementation. The primary challenge is communicating the two-button nature of the component.

Component Structure (WCAG 4.1.2 – Name, Role, Value):

  • The split button MUST render as two separate <button> elements — one for the main action, one for the dropdown trigger
  • Do NOT wrap both in a single <button> — this creates a button-within-a-button, which is invalid HTML
  • Wrap both buttons in a role="group" container with aria-label describing the group: "Save options"
  • The main button's accessible name comes from its visible text content
  • The dropdown trigger needs aria-label="More save options" or similar — describe what the dropdown contains, not just "dropdown"

Dropdown Behavior (WCAG 4.1.2):

  • The dropdown trigger must have aria-haspopup="menu" and aria-expanded="true|false"
  • When the menu opens, aria-expanded must update to "true"
  • The dropdown menu must have role="menu" with menu items as role="menuitem"

Keyboard Navigation (WCAG 2.1.1 – Keyboard):

  • Tab stops: the main button and the dropdown trigger are separate tab stops
  • Main button: Enter/Space triggers the default action
  • Dropdown trigger: Enter/Space/ArrowDown opens the menu
  • When menu is open: ArrowDown/ArrowUp navigates items, Enter/Space selects, Escape closes (returning focus to trigger)
  • Home/End jump to first/last menu item

Focus Management (WCAG 2.4.3 – Focus Order):

  • Tab order: Main button → Dropdown trigger → (next element)
  • When menu opens: focus moves to first menu item (or first non-disabled item)
  • When menu closes (selection or Escape): focus returns to the dropdown trigger
  • When menu closes via outside click: focus moves naturally to the clicked element

Contrast (WCAG 1.4.3, 1.4.11):

  • The divider between the two buttons must achieve 3:1 contrast against the button background if it's meaningful for understanding the component (WCAG 1.4.11). Use the Contrast Checker.
  • Both the main text and the dropdown arrow must achieve 4.5:1 (text) and 3:1 (non-text) against their backgrounds
  • Menu items must meet standard text contrast requirements

Touch Targets (WCAG 2.5.8):

  • The dropdown trigger must be at least 24×24 CSS pixels (Level AA)
  • On mobile, consider making the trigger at least 44px wide for comfortable tapping
  • If the trigger is too narrow (<32px), users will accidentally click the main action when intending to open the menu

Usage Guidelines

Usage Guidelines

Do:

  • Make the default action the most commonly used option — the split button pattern assumes most clicks target the main action
  • Use clear, action-oriented labels: "Save", "Send", "Deploy" — not "Options" or "Actions"
  • Include keyboard navigation for the dropdown menu (arrow keys, Enter, Escape)
  • Label the dropdown trigger with a descriptive aria-label: "More save options", not "dropdown"
  • Keep the dropdown menu to 3–7 items — more than that suggests a different pattern (select, dialog)
  • Apply the same visual variant to both segments — they must look like one compound element
  • Use the Button styling for both segments consistently

Don't:

  • Don't put the most destructive action as the default — destructive actions should be in the dropdown with visual warning
  • Don't use split buttons for navigation — the main action should perform an operation, not navigate
  • Don't make the dropdown trigger too narrow (<32px) — it becomes a frustrating click target
  • Don't independently disable just the main button or just the trigger — they operate as a unit
  • Don't use split buttons in dense lists or table rows — the visual complexity is too much for repeated instances
  • Don't change the default action based on the last dropdown selection unless explicitly designed as a "remember my choice" pattern (like a version control commit button)
  • Don't nest split buttons inside other composite controls like Button Groups

Code Snippets

html
<!-- Primary split button -->
<div class="split-btn" role="group" aria-label="Save options">
  <button class="split-btn-main" type="button">
    Save
  </button>
  <button
    class="split-btn-trigger"
    type="button"
    aria-haspopup="menu"
    aria-expanded="false"
    aria-label="More save options"
  >
    <svg class="split-btn-arrow" aria-hidden="true" viewBox="0 0 24 24" width="16" height="16">
      <path d="M7 10l5 5 5-5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
    </svg>
  </button>

  <!-- Dropdown menu (hidden by default) -->
  <div class="split-btn-menu" role="menu" hidden>
    <button class="split-btn-menu-item" role="menuitem">Save as Draft</button>
    <button class="split-btn-menu-item" role="menuitem">Save and Close</button>
    <button class="split-btn-menu-item" role="menuitem">Save as Template</button>
    <hr class="split-btn-menu-divider" role="separator" />
    <button class="split-btn-menu-item split-btn-menu-item--danger" role="menuitem">Discard Changes</button>
  </div>
</div>

<style>
.split-btn {
  display: inline-flex;
  position: relative;
}

.split-btn-main {
  padding: 8px 16px;
  background: var(--color-primary-600);
  color: var(--color-on-primary);
  border: none;
  border-radius: var(--radius-md) 0 0 var(--radius-md);
  font-weight: 500;
  cursor: pointer;
  font-size: 0.875rem;
  transition: background-color 0.15s;
}

.split-btn-main:hover {
  background: var(--color-primary-700);
}

.split-btn-trigger {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 36px;
  background: var(--color-primary-600);
  color: var(--color-on-primary);
  border: none;
  border-left: 1px solid rgba(255, 255, 255, 0.3);
  border-radius: 0 var(--radius-md) var(--radius-md) 0;
  cursor: pointer;
  transition: background-color 0.15s;
}

.split-btn-trigger:hover {
  background: var(--color-primary-800);
}

.split-btn-trigger[aria-expanded="true"] .split-btn-arrow {
  transform: rotate(180deg);
}

.split-btn-menu {
  position: absolute;
  top: 100%;
  right: 0;
  margin-top: 4px;
  min-width: 180px;
  background: var(--color-surface-elevated);
  border-radius: var(--radius-md);
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
  padding: 4px;
  z-index: 1000;
}

.split-btn-menu-item {
  display: block;
  width: 100%;
  padding: 8px 12px;
  background: none;
  border: none;
  text-align: left;
  font-size: 0.875rem;
  cursor: pointer;
  border-radius: var(--radius-sm);
  color: var(--color-text-primary);
}

.split-btn-menu-item:hover,
.split-btn-menu-item:focus {
  background: var(--color-action-hover);
}

.split-btn-menu-item--danger {
  color: var(--color-error-600);
}

.split-btn-menu-divider {
  border: none;
  border-top: 1px solid var(--color-border-default);
  margin: 4px 0;
}

.split-btn-main:focus-visible,
.split-btn-trigger:focus-visible {
  outline: 2px solid var(--color-primary-300);
  outline-offset: 2px;
  z-index: 1;
}
</style>
tsx
import React, { useState, useRef, useEffect, useCallback } from 'react';
import styles from './SplitButton.module.css';
import clsx from 'clsx';

interface MenuItem {
  label: string;
  onClick: () => void;
  icon?: React.ReactNode;
  disabled?: boolean;
  destructive?: boolean;
}

interface SplitButtonProps {
  variant?: 'primary' | 'secondary' | 'ghost' | 'destructive';
  size?: 'sm' | 'md' | 'lg';
  label: string;
  icon?: React.ReactNode;
  onClick: () => void;
  disabled?: boolean;
  loading?: boolean;
  menuItems: MenuItem[];
  menuPlacement?: 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end';
  dropdownAriaLabel?: string;
  fullWidth?: boolean;
}

export function SplitButton({
  variant = 'primary',
  size = 'md',
  label,
  icon,
  onClick,
  disabled = false,
  loading = false,
  menuItems,
  menuPlacement = 'bottom-end',
  dropdownAriaLabel = 'More options',
  fullWidth = false,
}: SplitButtonProps) {
  const [open, setOpen] = useState(false);
  const menuRef = useRef<HTMLDivElement>(null);
  const triggerRef = useRef<HTMLButtonElement>(null);

  const closeMenu = useCallback(() => {
    setOpen(false);
    triggerRef.current?.focus();
  }, []);

  useEffect(() => {
    if (!open) return;
    const handleClickOutside = (e: MouseEvent) => {
      if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
        setOpen(false);
      }
    };
    const handleEscape = (e: KeyboardEvent) => {
      if (e.key === 'Escape') closeMenu();
    };
    document.addEventListener('mousedown', handleClickOutside);
    document.addEventListener('keydown', handleEscape);
    return () => {
      document.removeEventListener('mousedown', handleClickOutside);
      document.removeEventListener('keydown', handleEscape);
    };
  }, [open, closeMenu]);

  const handleMenuKeyDown = (e: React.KeyboardEvent) => {
    const items = menuRef.current?.querySelectorAll('[role="menuitem"]:not(:disabled)');
    if (!items) return;
    const list = Array.from(items) as HTMLElement[];
    const idx = list.indexOf(document.activeElement as HTMLElement);

    if (e.key === 'ArrowDown') {
      e.preventDefault();
      list[(idx + 1) % list.length]?.focus();
    } else if (e.key === 'ArrowUp') {
      e.preventDefault();
      list[(idx - 1 + list.length) % list.length]?.focus();
    } else if (e.key === 'Home') {
      e.preventDefault();
      list[0]?.focus();
    } else if (e.key === 'End') {
      e.preventDefault();
      list[list.length - 1]?.focus();
    }
  };

  return (
    <div
      className={clsx(styles.root, styles[variant], styles[size], {
        [styles.fullWidth]: fullWidth,
      })}
      role="group"
      aria-label={`${label} options`}
    >
      <button
        className={styles.main}
        onClick={onClick}
        disabled={disabled || loading}
        type="button"
      >
        {loading ? (
          <span className={styles.spinner} aria-hidden="true" />
        ) : (
          <>
            {icon && <span className={styles.icon} aria-hidden="true">{icon}</span>}
            {label}
          </>
        )}
      </button>

      <button
        ref={triggerRef}
        className={styles.trigger}
        onClick={() => setOpen(!open)}
        disabled={disabled || loading}
        type="button"
        aria-haspopup="menu"
        aria-expanded={open}
        aria-label={dropdownAriaLabel}
      >
        <svg className={styles.arrow} viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
          <path d="M7 10l5 5 5-5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
        </svg>
      </button>

      {open && (
        <div
          ref={menuRef}
          className={clsx(styles.menu, styles[menuPlacement])}
          role="menu"
          onKeyDown={handleMenuKeyDown}
        >
          {menuItems.map((item, i) => (
            <button
              key={i}
              className={clsx(styles.menuItem, {
                [styles.destructive]: item.destructive,
              })}
              role="menuitem"
              disabled={item.disabled}
              onClick={() => {
                item.onClick();
                closeMenu();
              }}
            >
              {item.icon && <span className={styles.menuIcon} aria-hidden="true">{item.icon}</span>}
              {item.label}
            </button>
          ))}
        </div>
      )}
    </div>
  );
}

Design Systems

Design System Implementations

Material Design (MUI) does not provide a dedicated Split Button component, but the documentation demonstrates one by composing <ButtonGroup> with <Button> and a <Popper>-based menu. The pattern uses <ButtonGroup variant="contained"> with two children: the main action button and a small button with <ArrowDropDown />. The menu uses <Grow>, <Paper>, <ClickAwayListener>, <MenuList>, and <MenuItem>. MUI's example includes full keyboard navigation and ARIA attributes. See the Button page for MUI's button fundamentals.

Ant Design provides <Dropdown.Button> which renders a split button natively. Props include type (primary, default, dashed, text, link), size, icon for the dropdown trigger, onClick for the main action, menu (an Ant menu configuration object), placement, and trigger (click, hover). Ant's implementation handles ARIA attributes and keyboard navigation internally. The loading prop shows a spinner on the main button. The buttonsRender prop allows full customization of both button elements.

Chakra UI does not include a Split Button component. Teams compose one using Chakra's <ButtonGroup isAttached> with two <Button> components and a <Menu> from Chakra's menu primitives. The isAttached prop merges borders for the split appearance. Chakra's menu components (<Menu>, <MenuButton>, <MenuList>, <MenuItem>) handle focus management and ARIA roles automatically.

Bootstrap provides split buttons via .btn-group with a separate .dropdown-toggle .dropdown-toggle-split button. The split button has data-bs-toggle="dropdown" and a <span class="visually-hidden">Toggle Dropdown</span> for accessibility. Bootstrap's JavaScript manages menu toggling, keyboard navigation, and click-outside closing. The .dropdown-menu is positioned via Popper.js.

Apple Human Interface Guidelines does not define a split button component. Apple's pattern for "primary action + alternatives" uses a standard pull-down button (NSPopUpButton / Menu in SwiftUI) where the entire button opens a menu with the default action pre-selected at the top. This differs from the split button pattern where the main area triggers the action directly without opening a menu.

Tailwind CSS split buttons are built manually: two buttons in an inline-flex container, the first with rounded-r-none, the second with rounded-l-none border-l border-white/30 and a chevron-down SVG. The dropdown menu uses Headless UI's <Menu> component for full accessibility, or Radix's <DropdownMenu>. Tailwind UI provides a pre-built split button example in its marketing components.

ActionsDropdown ButtonCombo Button