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
| Prop | Type | Default | Description |
|---|---|---|---|
| label | string | Text of the main button. | |
| onClick | () => void | Runs 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. |
| menuLabel | string | "More options" | Accessible name of the chevron button. |
| disabled | boolean | false | Disables 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