Components

Getting started

Components117

More

Dual-action trigger: primary action on the left, trailing chevron on the right opening an aligned dropdown menu.

Used forPublish & deploySave & exportGit & release

Install with the shadcn CLI

$ npx shadcn@latest add https://hairlineui.com/r/split-button.json

Where it’s used

  • Publish & deploy

    Primary "Publish" action with dropdown options for "Schedule" or "Publish as draft".

  • Save & export

    "Save" main action paired with "Export as PDF" or "Export as JSON".

  • Git & release

    "Commit changes" with "Commit and push" or "Create PR" secondary options.

What it handles for you

Apps frequently need a primary default action alongside secondary choices. Disconnected buttons waste space; standard dropdowns hide the main action behind an extra click. Split button solves both in one cohesive hairline control.

Instead of wiring upseparate action + dropdown buttonsextra click menus for default actions
  1. Dual-action trigger

    Independent click handlers for the primary action and the secondary chevron trigger.

  2. Aligned floating menu

    Floating menu opens directly below the button’s right edge with hairline borders and zero fuzzy shadows.

  3. 180° chevron rotation

    Chevron smoothly rotates with organic spring easing when opened.

  4. Keyboard accessible

    Navigate open menu items with ArrowUp, ArrowDown, Home, End, and dismiss with Escape.

Overview

A unified hairline action button with an integrated chevron dropdown trigger. Clicking the main button triggers the default action; clicking the chevron reveals secondary actions in an aligned floating menu with smooth rotation, keyboard arrow navigation, and zero drop shadows.

States

idle
Resting dual-action capsule with vertical hairline divider.
open
Menu open with rotated chevron and focused menu item.
loading
Spinner inside primary action with clicks swallowed.

Usage

usage.tsx
import { SplitButton } from "@/components/ui/split-button";<SplitButton  label="Save changes"  onClick={() => save()}  menuItems={[    { id: "save-draft", label: "Save as draft", onSelect: () => saveDraft() },    { id: "export", label: "Export backup", onSelect: () => exportBackup() },  ]}/>

Depends on lucide-react, installed for you by the CLI along with the keyframes it needs.

Works with

Props

PropTypeDescription
labelstringText of the primary action.
onClick() => voidCalled when the primary action button is clicked.
menuItemsSplitMenuItem[]List of secondary actions shown in the dropdown menu.
variant"primary" | "secondary" | "destructive"default "primary"Color scheme and visual tone.
size"sm" | "md" | "lg"default "md"Height and typography scale.
loadingbooleandefault falseShows a spinner inside the primary button.
disabledbooleandefault falseDisables both primary and dropdown actions.

Accessibility and motion

  • No external dropdown library dependency: lightweight self-contained menu logic with full keyboard handling.
  • Clicking outside or pressing Escape cleanly closes the menu.

Requirements

React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style, light and dark). Icons from lucide-react.

The CLI also installs ui-interaction-motion.

Source

components/split-button.tsx
"use client";import * as React from "react";import { ChevronDown } from "lucide-react";import { EASE_OUT, EASE_SETTLE, INTERACTION_FOCUS } from "./ui-interaction-motion";export interface SplitMenuItem {  id: string;  label: string;  icon?: React.ReactNode;  onSelect: () => void;  destructive?: boolean;  disabled?: boolean;}export interface SplitButtonProps {  label: string;  icon?: React.ReactNode;  onClick?: () => void;  menuItems: SplitMenuItem[];  variant?: "primary" | "secondary" | "destructive";  size?: "sm" | "md" | "lg";  disabled?: boolean;  loading?: boolean;  menuLabel?: string;  className?: string;}const variantStyles = {  primary: {    container:      "bg-primary text-primary-foreground shadow-[0_1px_2px_rgba(48,93,222,0.18)] border-transparent",    action: "hover:bg-primary/90 active:opacity-90 text-primary-foreground",    trigger: "hover:bg-primary/90 active:opacity-90 text-primary-foreground",    divider: "bg-primary-foreground/25",  },  secondary: {    container:      "bg-card text-foreground shadow-[inset_0_0_0_1px_var(--border)] border-transparent",    action: "hover:bg-accent active:bg-accent/80 text-foreground",    trigger: "hover:bg-accent active:bg-accent/80 text-foreground",    divider: "bg-border",  },  destructive: {    container:      "bg-red-500/10 text-red-600 shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--color-red-500)_30%,transparent)] dark:text-red-400 border-transparent",    action: "hover:bg-red-500/15 active:bg-red-500/20 text-red-600 dark:text-red-400",    trigger: "hover:bg-red-500/15 active:bg-red-500/20 text-red-600 dark:text-red-400",    divider: "bg-red-500/25",  },};const sizeStyles = {  sm: {    height: "h-7 text-[12px] rounded-full",    actionPad: "pl-3 pr-2 gap-1.5",    triggerPad: "px-2",    iconSize: 13,  },  md: {    height: "h-9 text-[13px] rounded-full",    actionPad: "pl-4 pr-2.5 gap-2",    triggerPad: "px-2.5",    iconSize: 14,  },  lg: {    height: "h-10 text-[14px] rounded-full",    actionPad: "pl-5 pr-3 gap-2.5",    triggerPad: "px-3",    iconSize: 16,  },};export function SplitButton({  label,  icon,  onClick,  menuItems,  variant = "primary",  size = "md",  disabled = false,  loading = false,  menuLabel = "More actions",  className = "",}: SplitButtonProps) {  const [open, setOpen] = React.useState(false);  const [pressedAction, setPressedAction] = React.useState(false);  const containerRef = React.useRef<HTMLDivElement | null>(null);  const menuRef = React.useRef<HTMLDivElement | null>(null);  const triggerRef = React.useRef<HTMLButtonElement | null>(null);  const v = variantStyles[variant];  const s = sizeStyles[size];  const inert = disabled || loading;  // Close on outside click or Escape  React.useEffect(() => {    if (!open) return;    const handlePointerDown = (e: PointerEvent) => {      if (        containerRef.current &&        !containerRef.current.contains(e.target as Node) &&        menuRef.current &&        !menuRef.current.contains(e.target as Node)      ) {        setOpen(false);      }    };    const handleKeyDown = (e: KeyboardEvent) => {      if (e.key === "Escape") {        setOpen(false);        triggerRef.current?.focus();      }    };    document.addEventListener("pointerdown", handlePointerDown);    document.addEventListener("keydown", handleKeyDown);    return () => {      document.removeEventListener("pointerdown", handlePointerDown);      document.removeEventListener("keydown", handleKeyDown);    };  }, [open]);  // Keyboard navigation for dropdown menu  const handleMenuKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {    const items = Array.from(menuRef.current?.querySelectorAll<HTMLButtonElement>("[data-menu-item]") ?? []);    const currentIndex = items.findIndex((item) => item === document.activeElement);    if (e.key === "ArrowDown") {      e.preventDefault();      const nextIndex = currentIndex < items.length - 1 ? currentIndex + 1 : 0;      items[nextIndex]?.focus();    } else if (e.key === "ArrowUp") {      e.preventDefault();      const prevIndex = currentIndex > 0 ? currentIndex - 1 : items.length - 1;      items[prevIndex]?.focus();    } else if (e.key === "Home") {      e.preventDefault();      items[0]?.focus();    } else if (e.key === "End") {      e.preventDefault();      items[items.length - 1]?.focus();    }  };  const handleActionClick = (e: React.MouseEvent<HTMLButtonElement>) => {    if (inert) {      e.preventDefault();      return;    }    onClick?.();  };  const toggleMenu = (e: React.MouseEvent<HTMLButtonElement>) => {    e.stopPropagation();    if (inert) return;    setOpen((prev) => !prev);  };  return (    <div      ref={containerRef}      className={`relative inline-flex items-stretch select-none transition-colors isolate ${s.height} ${v.container} ${        disabled ? "opacity-45 cursor-not-allowed" : ""      } ${className}`}    >      {/* Primary Action Button */}      <button        type="button"        disabled={inert}        onClick={handleActionClick}        onPointerDown={() => !inert && setPressedAction(true)}        onPointerUp={() => setPressedAction(false)}        onPointerLeave={() => setPressedAction(false)}        style={{          transform: pressedAction ? "scale(0.98)" : undefined,          transition: `transform 160ms ${EASE_SETTLE}, opacity 160ms ${EASE_OUT}`,        }}        className={`relative inline-flex items-center justify-center font-medium rounded-l-full transition-colors cursor-pointer disabled:cursor-not-allowed ${s.actionPad} ${v.action} ${INTERACTION_FOCUS}`}      >        {loading ? (          <span className="inline-flex size-3 animate-spin rounded-full border-[1.5px] border-current border-r-transparent mr-1.5" />        ) : (          icon && <span className="inline-flex shrink-0 mr-1.5">{icon}</span>        )}        <span className="whitespace-nowrap">{label}</span>      </button>      {/* Hairline Divider */}      <span aria-hidden="true" className={`w-px my-1.5 self-stretch ${v.divider}`} />      {/* Dropdown Chevron Trigger */}      <button        ref={triggerRef}        type="button"        disabled={inert}        onClick={toggleMenu}        aria-label={menuLabel}        aria-haspopup="menu"        aria-expanded={open}        className={`relative inline-flex items-center justify-center rounded-r-full cursor-pointer transition-colors disabled:cursor-not-allowed ${s.triggerPad} ${v.trigger} ${INTERACTION_FOCUS}`}      >        <ChevronDown          size={s.iconSize}          strokeWidth={1.75}          className="transition-transform duration-200 motion-reduce:transition-none"          style={{            transform: open ? "rotate(180deg)" : "rotate(0deg)",            transitionTimingFunction: EASE_SETTLE,          }}        />      </button>      {/* Floating Aligned Dropdown Menu */}      {open && (        <div          ref={menuRef}          role="menu"          aria-label={menuLabel}          onKeyDown={handleMenuKeyDown}          style={{            transformOrigin: "top right",            animation: `ui-fade-up 160ms ${EASE_OUT} forwards`,          }}          className="absolute right-0 top-[calc(100%+6px)] z-50 min-w-[170px] max-w-[calc(100vw-32px)] rounded-[12px] border border-border bg-popover p-1.5 text-popover-foreground shadow-[0_2px_12px_rgba(0,0,0,0.06)]"        >          {menuItems.map((item) => (            <button              key={item.id}              role="menuitem"              data-menu-item=""              type="button"              disabled={item.disabled}              onClick={() => {                setOpen(false);                item.onSelect();              }}              className={`flex w-full items-center gap-2 rounded-[8px] px-2.5 py-1.5 text-[12.5px] font-medium transition-colors cursor-pointer disabled:cursor-not-allowed disabled:opacity-45 ${                item.destructive                  ? "text-red-600 dark:text-red-400 hover:bg-red-500/10"                  : "text-foreground hover:bg-muted"              } ${INTERACTION_FOCUS}`}            >              {item.icon && (                <span className="inline-flex size-3.5 shrink-0 items-center justify-center text-muted-foreground">                  {item.icon}                </span>              )}              <span className="truncate">{item.label}</span>            </button>          ))}        </div>      )}    </div>  );}export default SplitButton;

Registry item: /r/split-button.json