Skip to content
goodtabs UI
BrowseBrowse components

Split button

A primary action with a chevron that opens a menu of alternatives.

npx shadcn@latest add https://ui.goodtabs.co/r/split-button.json

With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/split-button

Save, with Save as draft and Save and close behind the chevron.

Outline variant

The outline look at both sizes. The halves share one border.

With a destructive item

The last item is marked destructive and reads in the destructive tone.

Usage

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

<SplitButton
  label="Save"
  onClick={save}
  items={[
    { label: "Save as draft", onSelect: saveDraft },
    { label: "Save and close", onSelect: saveAndClose },
  ]}
/>

Props

PropTypeDefaultDescription
labelstringText of the main button.
onClick() => voidRuns when the main button is clicked.
items{ label: string; onSelect: () => void; destructive?: boolean }[]The alternatives in the menu, in order.
variant"default" | "outline" | "secondary""default"Look of both halves.
size"sm" | "default""default"Height of both halves: 28px or 32px.
menuLabelstring"More options"Accessible name of the chevron button.
disabledbooleanfalseDisables both halves.

Source

split-button/split-button.tsx
"use client";

import * as React from "react";
import { ChevronDownIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";

export type SplitButtonItem = {
  /** Menu item text. */
  label: string;
  /** Runs when the item is chosen. */
  onSelect: () => void;
  /** Styles the item for actions that cannot be undone. */
  destructive?: boolean;
};

type SplitButtonProps = Omit<React.ComponentProps<"div">, "onClick"> & {
  /** Text of the main button. */
  label: string;
  /** Runs when the main button is clicked. */
  onClick?: () => void;
  /** The alternatives in the menu, in order. */
  items: SplitButtonItem[];
  /** Look of both halves. */
  variant?: "default" | "outline" | "secondary";
  /** Height of both halves: 28px or 32px. */
  size?: "sm" | "default";
  /** Accessible name of the chevron button. */
  menuLabel?: string;
  /** Disables both halves. */
  disabled?: boolean;
};

const dividers: Record<NonNullable<SplitButtonProps["variant"]>, string> = {
  default: "bg-clip-border border-l-primary-foreground/20",
  secondary: "bg-clip-border border-l-foreground/10",
  outline: "-ml-px",
};

/**
 * A primary action with a chevron that opens a menu of alternatives.
 * Two buttons share one pill: the main one runs onClick, the chevron opens the menu.
 */
export function SplitButton({
  label,
  onClick,
  items,
  variant = "default",
  size = "default",
  menuLabel = "More options",
  disabled,
  className,
  ...props
}: SplitButtonProps) {
  return (
    <div
      role="group"
      data-slot="split-button"
      data-variant={variant}
      className={cn("inline-flex items-center", className)}
      {...props}
    >
      <Button
        type="button"
        variant={variant}
        size={size}
        disabled={disabled}
        onClick={onClick}
        className="relative rounded-r-none focus-visible:z-10"
      >
        {label}
      </Button>
      <DropdownMenu>
        <DropdownMenuTrigger asChild>
          <Button
            type="button"
            variant={variant}
            size={size === "sm" ? "icon-sm" : "icon"}
            disabled={disabled}
            aria-label={menuLabel}
            className={cn("relative rounded-l-none focus-visible:z-10", dividers[variant])}
          >
            <ChevronDownIcon aria-hidden="true" />
          </Button>
        </DropdownMenuTrigger>
        <DropdownMenuContent align="end" className="min-w-40">
          {items.map((item) => (
            <DropdownMenuItem
              key={item.label}
              variant={item.destructive ? "destructive" : "default"}
              onSelect={() => item.onSelect()}
            >
              {item.label}
            </DropdownMenuItem>
          ))}
        </DropdownMenuContent>
      </DropdownMenu>
    </div>
  );
}

For AI agents

Hand your agent this page, the markdown at /components/split-button/prompt.md, or the Copy AI prompt button above.

When to use

  • One obvious action with two to five close alternatives: save, merge, export, run
  • Dialog and editor footers where a second full button would compete with the first
  • When the alternatives are variations of the main action, not unrelated commands

When not to use

  • When the alternatives are as common as the main action; show them as separate buttons
  • For navigation or settings; use a dropdown menu or a select
  • For a single action with no alternatives; use Button

Agent notes

  • It takes functions (onClick, items[].onSelect), so the component rendering it must be a client component
  • The chevron is a real button named by menuLabel with aria-haspopup and aria-expanded from radix
  • Items with destructive: true use the dropdown menu's destructive item variant; keep that for actions that cannot be undone
  • size and variant apply to both halves; the main button has no icon slot, keep the label short