Split Button
Buttons

Split Button

A primary action with an attached dropdown of alternatives, split by an inset hairline seam — each half presses independently toward the seam.

Install

npx shadcn@latest add @paragon/split-button

Also installs: button, dropdown-menu

split-button.tsx

"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>
  );
}