A primary action with an attached dropdown of alternatives, split by an inset hairline seam — each half presses independently toward the seam.
npx shadcn@latest add @paragon/split-buttonAlso installs: button, dropdown-menu
"use client";
import * as React from "react";
import { ChevronDown } from "lucide-react";
import type { VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/registry/paragon/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/registry/paragon/ui/dropdown-menu";
type SplitButtonVariant = VariantProps<typeof buttonVariants>["variant"];
type SplitButtonSize = VariantProps<typeof buttonVariants>["size"];
interface SplitButtonContextValue {
variant: SplitButtonVariant;
size: SplitButtonSize;
disabled: boolean;
isStatic: boolean;
}
const SplitButtonContext = React.createContext<SplitButtonContextValue | null>(
null,
);
function useSplitButton(component: string) {
const context = React.useContext(SplitButtonContext);
if (!context) {
throw new Error(`${component} must be used within a SplitButton`);
}
return context;
}
/**
* Seam hairline between the halves, inset from the rounded edges so it
* reads as a groove rather than a full-height divider.
*/
const seamColor: Record<string, string> = {
default: "before:bg-primary-foreground/25",
secondary: "before:bg-foreground/15",
outline: "before:bg-border",
ghost: "before:bg-border",
destructive: "before:bg-destructive-foreground/25",
link: "before:bg-border",
};
const triggerWidth: Record<string, string> = {
sm: "w-7",
default: "w-8",
lg: "w-9",
icon: "w-9",
};
export interface SplitButtonProps
extends React.ComponentProps<"div">,
VariantProps<typeof buttonVariants> {
/** Disables both halves at once. */
disabled?: boolean;
/** Disables press-scale feedback on both halves. */
static?: boolean;
}
/**
* A primary action with an attached dropdown of alternatives. The two
* halves share one visual body split by an inset hairline; each half
* presses independently, scaling toward the seam so the pair never
* visibly separates.
*/
export function SplitButton({
className,
variant = "default",
size = "default",
disabled = false,
static: isStatic = false,
children,
...props
}: SplitButtonProps) {
const contextValue = React.useMemo(
() => ({ variant, size, disabled, isStatic }),
[variant, size, disabled, isStatic],
);
return (
<div
role="group"
data-slot="split-button"
className={cn("inline-flex w-fit items-stretch", className)}
{...props}
>
<SplitButtonContext.Provider value={contextValue}>
{children}
</SplitButtonContext.Provider>
</div>
);
}
export interface SplitButtonActionProps
extends React.ComponentProps<"button"> {}
/** The primary half. Inherits variant, size, and disabled from the root. */
export function SplitButtonAction({
className,
disabled,
...props
}: SplitButtonActionProps) {
const {
variant,
size,
disabled: groupDisabled,
isStatic,
} = useSplitButton("SplitButtonAction");
return (
<button
type="button"
data-slot="split-button-action"
disabled={disabled || groupDisabled}
className={cn(
buttonVariants({ variant, size }),
"relative rounded-r-none focus-visible:z-10",
// Scale toward the seam so a press never opens a gap between halves.
!isStatic && "origin-right active:not-disabled:scale-[0.97]",
className,
)}
{...props}
/>
);
}
export interface SplitButtonMenuProps
extends Omit<React.ComponentProps<"button">, "children"> {
/** Menu content — DropdownMenuItem, DropdownMenuRadioGroup, etc. */
children: React.ReactNode;
/** Accessible name for the chevron trigger. */
label?: string;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
align?: React.ComponentProps<typeof DropdownMenuContent>["align"];
side?: React.ComponentProps<typeof DropdownMenuContent>["side"];
sideOffset?: number;
/** Class for the dropdown content panel. */
contentClassName?: string;
}
/**
* The attached chevron half. Opens a dropdown of secondary actions; the
* chevron flips while open and the hairline seam sits on this half's
* leading edge.
*/
export function SplitButtonMenu({
children,
label = "More options",
open,
defaultOpen,
onOpenChange,
align = "end",
side,
sideOffset = 6,
contentClassName,
className,
disabled,
...props
}: SplitButtonMenuProps) {
const {
variant,
size,
disabled: groupDisabled,
isStatic,
} = useSplitButton("SplitButtonMenu");
return (
<DropdownMenu
open={open}
defaultOpen={defaultOpen}
onOpenChange={onOpenChange}
>
<DropdownMenuTrigger asChild>
<button
type="button"
data-slot="split-button-menu-trigger"
aria-label={label}
disabled={disabled || groupDisabled}
className={cn(
buttonVariants({ variant, size }),
"group/split relative rounded-l-none px-0 has-[>svg:first-child]:pl-0 focus-visible:z-10",
triggerWidth[(size ?? "default") as string],
"before:absolute before:inset-y-1.5 before:left-0 before:w-px",
seamColor[(variant ?? "default") as string],
!isStatic && "origin-left active:not-disabled:scale-[0.97]",
className,
)}
{...props}
>
<ChevronDown
aria-hidden
className="rotate-0 transition-[rotate] duration-200 ease-(--ease-out) group-data-[state=open]/split:rotate-180 motion-reduce:transition-none"
/>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align={align}
side={side}
sideOffset={sideOffset}
className={contentClassName}
>
{children}
</DropdownMenuContent>
</DropdownMenu>
);
}