# Split Button

> A primary action with a menu of alternatives beside it: pick one and it runs, then takes the primary slot, its label springing to the new width.

- Page: https://devl.dev/components/split-button
- Kind: component · Workspace
- Install: `npx shadcn@latest add https://devl.dev/r/split-button.json`
- Raw source: https://devl.dev/raw/split-button.tsx
- npm: lucide-react, motion
- coss ui: button, menu, spinner
- License: MIT. The coss ui components it uses (https://coss.com/ui) are AGPL-3.0.

## Usage

```tsx
"use client";

import { CalendarClockIcon, FlaskConicalIcon, GitCommitHorizontalIcon, RocketIcon, ZapOffIcon } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useState } from "react";
import { SplitButton } from "@/components/split-button";

const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));

export default function SplitButtonDemo() {
  const [log, setLog] = useState<{ id: number; text: string }[]>([{ id: 0, text: "Deployed to production · yesterday" }]);
  return (
    <div className="w-[400px] max-w-full rounded-2xl border bg-card p-4 shadow-xs/5">
      <div className="flex items-center justify-between gap-3">
        <div className="min-w-0">
          <p className="font-medium text-sm">halden-web</p>
          <p className="mt-0.5 flex items-center gap-1.5 text-muted-foreground text-xs">
            <GitCommitHorizontalIcon className="size-3.5" />
            <span className="font-mono">a91f3c2</span>
            <span className="truncate">Fix invoice rounding</span>
          </p>
        </div>
        <span className="flex shrink-0 items-center gap-1.5 rounded-full bg-success/10 px-2 py-0.5 font-medium text-success-foreground text-xs">
          <span className="size-1.5 rounded-full bg-success" />
          Checks passed
        </span>
      </div>
      <div className="mt-4 flex items-center justify-between gap-3 border-t pt-4">
        <div className="relative h-4 min-w-0 flex-1 overflow-hidden text-muted-foreground text-xs">
          <AnimatePresence initial={false} mode="popLayout">
            <motion.p
              animate={{ opacity: 1, y: 0 }}
              className="truncate"
              exit={{ opacity: 0, y: -8 }}
              initial={{ opacity: 0, y: 8 }}
              key={log[0].id}
              transition={{ duration: 0.2, ease: [0.23, 1, 0.32, 1] }}
            >
              {log[0].text}
            </motion.p>
          </AnimatePresence>
        </div>
        <SplitButton
          actions={[
            { id: "prod", label: "Deploy", description: "Ship this commit to production.", icon: <RocketIcon />, done: "Deployed" },
            { id: "preview", label: "Deploy preview", description: "A private link to review first.", icon: <FlaskConicalIcon />, done: "Preview ready" },
            { id: "schedule", label: "Deploy at 6 pm", description: "Ship after hours, when it’s quiet.", icon: <CalendarClockIcon />, done: "Scheduled" },
            { id: "clean", label: "Deploy clean", description: "Rebuild everything, skip the cache.", icon: <ZapOffIcon />, done: "Deployed" },
          ]}
          menuLabel="Deploy a91f3c2"
          onAction={async (a) => {
            await wait(1100);
            const text = { prod: "Deployed to production", preview: "Preview deployed", schedule: "Scheduled for 6:00 pm", clean: "Clean build deployed" }[a.id];
            setLog((l) => [{ id: l[0].id + 1, text: `${text} · just now` }, ...l]);
          }}
        />
      </div>
    </div>
  );
}
```

## Props

| Prop | Type | Default |
| --- | --- | --- |
| `actions` | `SplitAction[]` |  |
| `defaultActionId`? | `string` |  |
| `onAction`? | `(action: SplitAction) => void \| Promise<unknown>` |  |
| `remember`? | `boolean` | `true` |
| `menuLabel`? | `string` | `"Choose an action"` |
| `variant`? | `"default" \| "outline"` | `"default"` |
| `disabled`? | `boolean` |  |
| `className`? | `string` |  |

## Source: components/split-button.tsx

```tsx
"use client";

import { CheckIcon, ChevronDownIcon } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Menu, MenuGroup, MenuGroupLabel, MenuItem, MenuPopup, MenuSeparator, MenuTrigger } from "@/components/ui/menu";
import { Spinner } from "@/components/ui/spinner";
import { cn } from "@/lib/utils";

export interface SplitAction {
  id: string;
  label: string;
  description?: string;
  icon?: ReactNode;
  done?: string;
}

export interface SplitButtonProps {
  actions: SplitAction[];
  defaultActionId?: string;
  onAction?: (action: SplitAction) => void | Promise<unknown>;
  remember?: boolean;
  menuLabel?: string;
  variant?: "default" | "outline";
  disabled?: boolean;
  className?: string;
}

type Phase = "idle" | "running" | "done";

export function SplitButton({
  actions,
  defaultActionId,
  onAction,
  remember = true,
  menuLabel = "Choose an action",
  variant = "default",
  disabled,
  className,
}: SplitButtonProps) {
  const reduce = useReducedMotion();
  const [primaryId, setPrimaryId] = useState(defaultActionId ?? actions[0]?.id);
  const [phase, setPhase] = useState<Phase>("idle");
  const [width, setWidth] = useState<number | "auto">("auto");
  const measureRef = useRef<HTMLSpanElement>(null);
  const doneTimer = useRef<number>(undefined);
  const mounted = useRef(true);
  useEffect(() => {
    mounted.current = true;
    return () => {
      mounted.current = false;
      clearTimeout(doneTimer.current);
    };
  }, []);

  const primary = actions.find((a) => a.id === primaryId) ?? actions[0];
  const label = phase === "done" ? (primary.done ?? primary.label) : primary.label;

  useLayoutEffect(() => {
    if (measureRef.current) setWidth(measureRef.current.offsetWidth);
  }, [label]);

  const run = async (action: SplitAction) => {
    if (phase === "running") return;
    if (remember) setPrimaryId(action.id);
    clearTimeout(doneTimer.current);
    setPhase("running");
    try {
      await onAction?.(action);
      if (!mounted.current) return;
      setPhase("done");
      doneTimer.current = window.setTimeout(() => setPhase("idle"), 1600);
    } catch {
      if (mounted.current) setPhase("idle");
    }
  };

  const ease = [0.23, 1, 0.32, 1] as const;
  const swap = {
    initial: reduce ? { opacity: 0 } : { opacity: 0, y: 6, filter: "blur(2px)" },
    animate: { opacity: 1, y: 0, filter: "blur(0px)" },
    exit: reduce ? { opacity: 0 } : { opacity: 0, y: -6, filter: "blur(2px)" },
    transition: { duration: 0.18, ease },
  };
  const icon = phase === "running" ? <Spinner className="size-4" key="spin" /> : phase === "done" ? <CheckIcon key="done" /> : primary.icon;

  return (
    <div className={cn("inline-flex rounded-lg", className)} role="group">
      <Button
        aria-busy={phase === "running" || undefined}
        className="rounded-e-none before:rounded-e-none"
        disabled={disabled}
        onClick={() => run(primary)}
        variant={variant}
      >
        <span className="relative flex size-4 shrink-0 items-center justify-center [&_svg]:size-4">
          <AnimatePresence initial={false} mode="popLayout">
            <motion.span className="flex" key={phase === "idle" ? primary.id : phase} {...swap}>
              {icon}
            </motion.span>
          </AnimatePresence>
        </span>
        <motion.span
          animate={{ width }}
          className="relative flex h-5 items-center overflow-hidden"
          initial={false}
          transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 520, damping: 42 }}
        >
          <AnimatePresence initial={false} mode="popLayout">
            <motion.span className="whitespace-nowrap" key={label} {...swap}>
              {label}
            </motion.span>
          </AnimatePresence>
          <span aria-hidden className="pointer-events-none invisible absolute whitespace-nowrap" ref={measureRef}>
            {label}
          </span>
        </motion.span>
        <span aria-live="polite" className="sr-only">
          {phase === "running" ? `${primary.label}…` : phase === "done" ? (primary.done ?? `${primary.label} finished`) : ""}
        </span>
      </Button>
      <Menu>
        <MenuTrigger
          disabled={disabled}
          render={
            <Button
              aria-label="More actions"
              className={cn(
                "group/chev rounded-s-none px-2 before:rounded-s-none",
                variant === "default"
                  ? "border-s-primary-foreground/16 dark:border-s-primary-foreground/24"
                  : "-ms-px",
              )}
              variant={variant}
            />
          }
        >
          <ChevronDownIcon className="transition-transform duration-200 ease-out group-data-popup-open/chev:rotate-180" />
        </MenuTrigger>
        <MenuPopup
          align="end"
          className="w-72 transition-[scale,opacity] duration-150 ease-out data-ending-style:scale-[0.98] data-starting-style:scale-[0.96] data-ending-style:opacity-0 data-starting-style:opacity-0"
          sideOffset={6}
        >
          <MenuGroup>
            <MenuGroupLabel>{menuLabel}</MenuGroupLabel>
            {actions.map((a) => (
              <MenuItem className="items-start gap-2.5 py-2" key={a.id} onClick={() => run(a)}>
                {a.icon && (
                  <span className="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-md border bg-background text-muted-foreground shadow-xs/5 [&_svg]:size-3.5">
                    {a.icon}
                  </span>
                )}
                <span className="min-w-0 flex-1">
                  <span className="block font-medium text-sm">{a.label}</span>
                  {a.description && <span className="block text-pretty text-muted-foreground text-xs leading-snug">{a.description}</span>}
                </span>
                <CheckIcon className={cn("mt-1 size-4 shrink-0 transition-opacity", a.id === primary.id ? "opacity-100" : "opacity-0")} />
              </MenuItem>
            ))}
          </MenuGroup>
          {remember && (
            <>
              <MenuSeparator />
              <p className="px-2 py-1.5 text-muted-foreground text-xs">The one you pick becomes the default.</p>
            </>
          )}
        </MenuPopup>
      </Menu>
    </div>
  );
}
```
