Split Actions

A generic add button hides what you can actually add, and a dropdown costs a click and covers the content underneath.

DisclosureProductivityDashboardbeginner
  • Keyboard
  • Touch
  • Responsive
  • Reduced motion
npx shadcn@latest add https://design-recreation.vercel.app/r/split-actions.json

The problem

A generic "+" button hides what you can actually add, so people press it to find out. A dropdown answers that but costs a second click and covers the content underneath — which is often the thing you were about to act on. When there are only two or three options, the button can simply become them.

Use cases

  • A floating create button with two or three real actions behind it
  • A compose control that splits into "message" and "meeting"
  • Any compact trigger whose options are worth showing rather than naming

Not for: more than three actions. The row stops fitting on a phone and the convergence animation turns into noise — use a menu, which is what options-menu is for.

Installation

bash
npm install motion lucide-react

Then copy SplitActions.tsx. It depends only on @/lib/cn and @/lib/motion.

Usage

tsx
import SplitActions from "@/patterns/split-actions/SplitActions";

<SplitActions
  triggerLabel="Add"
  actions={[
    { id: "schedule", label: "Schedule", onSelect: () => openScheduler() },
    { id: "remind", label: "Remind", onSelect: () => openReminder() },
  ]}
/>;

API

PropTypeDefaultDescription
actionsreadonly SplitAction[]—Two or three. Empty renders nothing.
triggerLabelstring"Show actions"Accessible name for the collapsed trigger.
triggerIconReactNodea plusIcon inside the trigger.
defaultOpenbooleanfalseUncontrolled initial state.
openbooleanuncontrolledControlled state. Pass with onOpenChange.
onOpenChange(open: boolean) => void—Fires on every toggle.
classNamestring—Merged onto the root.

SplitAction: { id: string; label: string; icon?: ReactNode; onSelect: () => void }.

Keyboard

KeyAction
TabFocus the trigger, then move through the actions once open
Enter / SpaceExpand, or run the focused action
EscapeCollapse back to the trigger

Accessibility

The trigger carries aria-expanded and aria-controls, and an accessible name — it was previously a button containing only an icon, so it announced as "button".

Focus is handed over explicitly. The trigger unmounts when the row expands, which destroys focus and drops a keyboard user at the top of the document. Focus moves to the first action on open and returns to the trigger on close. A defaultOpen mount does not steal focus, because that is a render rather than an interaction.

Under prefers-reduced-motion the travel and blur are dropped and the pills cross-fade; the reveal still happens, it just does not fly.

Known limitation: the actions are a row, not a menu, so there is no roving-tabindex or type-ahead. That is correct at two or three items and wrong at ten — which is the point at which you should be using a menu.

Performance

Animates transform, opacity and filter only. Each pill enters from the offset where the trigger was, computed symmetrically so the outermost travel furthest and a middle one does not move at all — the row appears to unpack out of the button rather than materialise around it.

Blur is capped at 12px on elements well under 200px, so paint stays cheap. mode="popLayout" takes the exiting trigger out of flow immediately, so the pills do not wait for it to finish before settling.

Source

The whole component. In a copy-paste library the source is the delivery mechanism, not an appendix — so it is read straight off disk at build time and cannot drift from what ships.

SplitActions.tsx
"use client";

import { useCallback, useEffect, useId, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Plus } from "lucide-react";

import { cn } from "@/lib/cn";
import { blurTransition, spring } from "@/lib/motion";

/* -------------------------------------------------------------------------- */
/* Types                                                                       */
/* -------------------------------------------------------------------------- */

export interface SplitAction {
  id: string;
  label: string;
  icon?: React.ReactNode;
  onSelect: () => void;
}

export interface SplitActionsProps extends Omit<
  React.ComponentPropsWithoutRef<"div">,
  "onSelect"
> {
  /** Two or three. Past that the row stops fitting and wants a menu. */
  actions: readonly SplitAction[];
  /** Accessible name for the collapsed trigger. @default "Show actions" */
  triggerLabel?: string;
  /** Icon inside the trigger. @default a plus */
  triggerIcon?: React.ReactNode;
  defaultOpen?: boolean;
  open?: boolean;
  onOpenChange?: (open: boolean) => void;
}

/* -------------------------------------------------------------------------- */
/* Motion                                                                      */
/* -------------------------------------------------------------------------- */

/**
 * Each pill enters from where the trigger was, so the row appears to unpack
 * out of the button rather than materialise around it. Entering from a fixed
 * direction regardless of position would read as scripted.
 */
function pillVariants(offset: number) {
  return {
    hidden: { x: offset, opacity: 0, filter: "blur(12px)" },
    show: { x: 0, opacity: 1, filter: "blur(0px)" },
  };
}

const reducedPillVariants = {
  hidden: { opacity: 0 },
  show: { opacity: 1 },
} as const;

/** Symmetric spread: outermost pills travel furthest, the centre one not at all. */
function offsetFor(index: number, count: number): number {
  if (count < 2) return 0;
  return ((count - 1) / 2 - index) * (112 / (count - 1));
}

/* -------------------------------------------------------------------------- */
/* Component                                                                   */
/* -------------------------------------------------------------------------- */

/**
 * A compact trigger that splits into the actions it stands for.
 *
 * A generic "+" hides what you can actually add, and a dropdown costs a click
 * and covers the content underneath. Splitting the button into its real
 * actions shows them in place, at the cost of only being viable for two or
 * three.
 */
export function SplitActions({
  actions,
  triggerLabel = "Show actions",
  triggerIcon,
  defaultOpen = false,
  open: controlledOpen,
  onOpenChange,
  className,
  ...rest
}: SplitActionsProps) {
  const reduce = useReducedMotion();
  const uid = useId();
  const groupId = `${uid}-actions`;

  const triggerRef = useRef<HTMLButtonElement>(null);
  const firstActionRef = useRef<HTMLButtonElement>(null);
  // Distinguishes a user-driven toggle from the first render, so focus is not
  // stolen on mount when `defaultOpen` is true.
  const interacted = useRef(false);

  const [uncontrolled, setUncontrolled] = useState(defaultOpen);
  const isControlled = controlledOpen !== undefined;
  const open = isControlled ? controlledOpen : uncontrolled;

  const setOpen = useCallback(
    (next: boolean) => {
      interacted.current = true;
      if (!isControlled) setUncontrolled(next);
      onOpenChange?.(next);
    },
    [isControlled, onOpenChange],
  );

  /*
   * The trigger unmounts when the row expands, which destroys focus and drops
   * a keyboard user at the top of the document. Focus has to be handed to the
   * first action on open and back to the trigger on close.
   */
  useEffect(() => {
    if (!interacted.current) return;
    if (open) firstActionRef.current?.focus();
    else triggerRef.current?.focus();
  }, [open]);

  const handleKeyDown = useCallback(
    (event: React.KeyboardEvent<HTMLDivElement>) => {
      if (event.key === "Escape" && open) {
        event.stopPropagation();
        setOpen(false);
      }
    },
    [open, setOpen],
  );

  if (actions.length === 0) return null;

  const transition = reduce
    ? { duration: 0.01 }
    : { ...spring.snappy, filter: blurTransition, opacity: blurTransition };

  return (
    <div
      onKeyDown={handleKeyDown}
      className={cn("flex items-center justify-center", className)}
      {...rest}
    >
      <AnimatePresence mode="popLayout" initial={false}>
        {!open ? (
          <motion.button
            key="trigger"
            ref={triggerRef}
            type="button"
            aria-label={triggerLabel}
            aria-expanded={false}
            aria-controls={open ? groupId : undefined}
            onClick={() => setOpen(true)}
            initial={
              reduce
                ? { opacity: 0 }
                : { scale: 0.5, opacity: 0, filter: "blur(12px)" }
            }
            animate={{ scale: 1, opacity: 1, filter: "blur(0px)" }}
            exit={
              reduce
                ? { opacity: 0 }
                : { scale: 0.5, opacity: 0, filter: "blur(12px)" }
            }
            transition={transition}
            style={{ willChange: "transform" }}
            className={cn(
              "focus-ring grid size-11 cursor-pointer place-items-center",
              "rounded-full bg-accent text-accent-content",
            )}
          >
            {triggerIcon ?? <Plus className="size-5" strokeWidth={2.5} />}
          </motion.button>
        ) : (
          <motion.div
            key="actions"
            id={groupId}
            className="flex items-center gap-3"
            initial="hidden"
            animate="show"
            exit="hidden"
          >
            {actions.map((action, index) => (
              <motion.button
                key={action.id}
                ref={index === 0 ? firstActionRef : undefined}
                type="button"
                variants={
                  reduce
                    ? reducedPillVariants
                    : pillVariants(offsetFor(index, actions.length))
                }
                transition={transition}
                style={{ willChange: "transform" }}
                onClick={() => {
                  action.onSelect();
                  setOpen(false);
                }}
                className={cn(
                  "focus-ring flex cursor-pointer items-center gap-2",
                  "rounded-full bg-surface-subtle px-4 py-2.5",
                  "hover:bg-surface-hover",
                )}
              >
                {action.icon ? (
                  <span
                    aria-hidden="true"
                    className="grid size-6 shrink-0 place-items-center rounded-lg bg-accent text-accent-content [&>svg]:size-4"
                  >
                    {action.icon}
                  </span>
                ) : null}
                <span className="text-[15px] font-semibold text-content select-none">
                  {action.label}
                </span>
              </motion.button>
            ))}
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}

export default SplitActions;

Technologies

FrameworkReact 19
MotionMotion 12 (motion/react)
StylingTailwind CSS v4
Iconslucide-react
TypesTypeScript 5.9, strict

Credits

Original interaction by @nitishkmrk. Rebuilt with a props API, an accessible trigger, explicit focus handover, Escape to collapse, and reduced-motion handling.