v1.0

SplitButton

Preview

Basic

Loading…

Preview

Code

ts
import SplitButton from '@/components/layout/SplitButton';

src/components/layout/SplitButton.tsx

AI prompt

text
Build a split button (primary action + caret menu of secondary actions) component in React + TypeScript + Tailwind CSS.

"Save" beside a caret that opens "Save as draft / Save and close / Discard". Two REAL buttons, not one with a hot zone — the main action and "show more" are different operations, each needing its own focus stop and name.

## Look
- Root `relative inline-flex`. Both halves are the standard button in the chosen variant and size (primary indigo, secondary bordered white, ghost, danger rose; sm/md/lg). Main half `rounded-r-none`; caret half `rounded-l-none`, padding `px-1.5` / `px-2` / `px-2.5` for sm/md/lg, a 14px `ChevronDown` that rotates 180° while open. Both lift on focus (`focus-visible:relative z-10`).
- The seam between halves, per variant: primary and danger get `border-l border-white/25`; secondary drops the caret's left border (`border-l-0`) so the main button's right border is the seam; ghost borrows `border-l border-slate-200 dark:border-slate-700`.
- Optional 14px icon before the label; hidden while `loading` (the spinner takes its place).
- Menu: floating surface, `absolute top-full z-50 mt-1 min-w-44 p-1`, `right-0` or `left-0`. Items `flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-xs`, 14px icon at `opacity-70`, label truncating; normal `text-slate-700 dark:text-slate-200` with `hover:` and `focus:bg-slate-100` (dark `slate-800`); `danger` items `text-rose-600 dark:text-rose-400` with `bg-rose-50` / dark `bg-rose-500/10` highlight; disabled `opacity-40`. Separators `my-1 h-px bg-slate-200 dark:bg-slate-700`.

## Behaviour
- `disabled` disables both halves; `loading` puts a spinner on (and disables) the main half only — the menu stays usable.
- `menuAlign`: `auto` (default) aligns the menu's right edge to the button, then — measured in a layout effect after render, before paint — flips to left-aligned if that would push it past the left edge (+8px) of the nearest ancestor whose `overflow-x` isn't visible (or the window), where it would be clipped or painted under a sidebar. `left` / `right` force it.
- Opening from the keyboard (Enter/Space on the caret — a click with `event.detail === 0` — or ArrowDown) focuses the first enabled item; ArrowUp opens and focuses the last. Opening with the mouse leaves focus on the caret. Focus items on the next animation frame, after they have rendered.
- In the menu: ArrowDown/Up cycle enabled items (wrapping), Home/End jump; Tab closes the menu and lets focus move on (a menu that holds Tab is a trap); Escape closes and returns focus to the caret; outside click closes.
- Choosing an item closes the menu, returns focus to the caret FIRST (so it isn't stranded on an unmounted item, and an action that opens a modal can take it from there), then runs `onClick`.
- The menu is in-flow (absolute), so an `overflow-hidden` ancestor such as a table shell will clip it; portal it there.

## API
`label: ReactNode`, `onClick?`, `items: ({ label: ReactNode; icon?: ComponentType<{ className?: string }>; onClick?: () => void; disabled?: boolean; danger?: boolean } | { separator: true })[]`, `variant?` ('primary'), `size?` ('md'), `icon?`, `disabled?`, `loading?`, `menuAlign?: 'left' | 'right' | 'auto'` ('auto'), `menuLabel?` ('More actions', the caret's aria-label), `className`.

## Accessibility
Caret: `aria-haspopup="menu"`, `aria-expanded`, `aria-controls` (while open). Menu `role="menu"` `aria-orientation="vertical"`; items `role="menuitem"` `tabIndex={-1}`; separators `role="separator"`.

## Demo
Items: Save as draft (FileText), Save and close (Save), Export… (Download, disabled), a separator, Discard changes (Trash2, danger). In a row: primary "Save" with a Save icon, secondary "Export" with `menuAlign="left"`, small ghost "Options", large danger "Delete", and a disabled one.

## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.

Source

tsx
'use client';

import { useCallback, useId, useLayoutEffect, useRef, useState } from 'react';
import { ChevronDown } from 'lucide-react';
import { cn } from '@/lib/cn';
import { useDismiss } from '@/lib/use-dismiss';
import Button from './Button';

export type SplitButtonAction = {
  label: React.ReactNode;
  /** An icon component (a lucide icon, say), sized by the menu. */
  icon?: React.ComponentType<{ className?: string }>;
  onClick?: () => void;
  disabled?: boolean;
  /** Rose text, for the irreversible entry. */
  danger?: boolean;
  separator?: false;
};

/** A rule between groups of actions. */
export type SplitButtonSeparator = { separator: true };

export type SplitButtonItem = SplitButtonAction | SplitButtonSeparator;

/*
 * The seam between the halves, per variant. The filled variants get a
 * translucent white hairline; `secondary` already has a border on both halves,
 * so the caret drops its left one and the main button's right border is the
 * seam; `ghost` has none, so it borrows the slate rule.
 */
const SEAM = {
  primary: 'border-l border-white/25',
  secondary: 'border-l-0',
  ghost: 'border-l border-slate-200 dark:border-slate-700',
  danger: 'border-l border-white/25',
} as const;

const CARET_PAD = { sm: 'px-1.5', md: 'px-2', lg: 'px-2.5' } as const;

export interface SplitButtonProps {
  /** The primary action's label. */
  label: React.ReactNode;
  /** The primary action. */
  onClick?: () => void;
  /** Secondary actions, shown in the caret's menu. */
  items: SplitButtonItem[];
  variant?: keyof typeof SEAM;
  size?: keyof typeof CARET_PAD;
  /** Icon component shown before the label. */
  icon?: React.ComponentType<{ className?: string }>;
  /** Disables both halves. */
  disabled?: boolean;
  /** Spinner on the primary half; the menu stays usable. */
  loading?: boolean;
  /**
   * Which edge of the button the menu lines up with. `auto` (default) lines
   * up with the right edge, and flips to the left when that would push the
   * menu out past the edge of its scrolling container or the window — where
   * it would be clipped, or painted under the sidebar beside it.
   */
  menuAlign?: 'left' | 'right' | 'auto';
  /** Accessible name for the caret button. */
  menuLabel?: string;
  className?: string;
}

/**
 * A primary action joined to a caret that opens its secondary actions —
 * "Save" beside "Save as draft / Save and close".
 *
 * Two real buttons, not one with a hot zone: the main action and "show more"
 * are different operations, and each needs its own focus stop and name.
 *
 * The menu follows the ARIA menu-button pattern. Opening from the keyboard
 * (Enter/Space/ArrowDown, or ArrowUp for the last item) puts focus on an item;
 * opening with the mouse leaves focus on the caret. Arrow keys cycle the
 * enabled items, Escape closes and returns focus to the caret, and Tab closes
 * and lets focus move on — a menu that holds Tab is a trap.
 *
 * In-flow and absolute at z-50, like every other dropdown here. It is exposed
 * to an `overflow-hidden` ancestor; a SplitButton inside a table shell wants a
 * portalled menu instead (see ContextMenu).
 */
export default function SplitButton({
  label,
  onClick,
  items,
  variant = 'primary',
  size = 'md',
  icon: Icon,
  disabled = false,
  loading = false,
  menuAlign = 'auto',
  menuLabel = 'More actions',
  className,
}: SplitButtonProps) {
  const menuId = useId();
  const [open, setOpen] = useState(false);
  const rootRef = useRef<HTMLDivElement>(null);
  const caretRef = useRef<HTMLButtonElement>(null);
  const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
  const menuRef = useRef<HTMLDivElement>(null);
  const [autoAlign, setAutoAlign] = useState<'left' | 'right'>('right');

  // Measured after the menu renders, before paint, against the nearest
  // ancestor that clips (or the window).
  useLayoutEffect(() => {
    if (!open || menuAlign !== 'auto') return;
    const root = rootRef.current;
    const menu = menuRef.current;
    if (!root || !menu) return;
    let bound = 0;
    for (let el = root.parentElement; el; el = el.parentElement) {
      const o = getComputedStyle(el).overflowX;
      if (o !== 'visible') { bound = el.getBoundingClientRect().left; break; }
    }
    const r = root.getBoundingClientRect();
    setAutoAlign(r.right - menu.offsetWidth < bound + 8 ? 'left' : 'right');
  }, [open, menuAlign]);
  const align = menuAlign === 'auto' ? autoAlign : menuAlign;

  const close = useCallback(() => setOpen(false), []);
  const escape = useCallback(() => {
    setOpen(false);
    caretRef.current?.focus();
  }, []);
  useDismiss(rootRef, open, close, escape);

  const enabled = () =>
    items
      .map((it, i) => (!it.separator && !it.disabled ? i : -1))
      .filter((i) => i !== -1);

  // Focus after the menu has rendered — the item buttons do not exist yet on
  // the same tick as `setOpen(true)`.
  const focusItem = (which: 'first' | 'last') => {
    requestAnimationFrame(() => {
      const list = enabled();
      const i = which === 'first' ? list[0] : list[list.length - 1];
      if (i !== undefined) itemRefs.current[i]?.focus();
    });
  };

  const onCaretKey = (e: React.KeyboardEvent) => {
    if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
      e.preventDefault();
      setOpen(true);
      focusItem(e.key === 'ArrowDown' ? 'first' : 'last');
    }
  };

  const onMenuKey = (e: React.KeyboardEvent) => {
    const list = enabled();
    const current = itemRefs.current.findIndex((el) => el === document.activeElement);
    const pos = list.indexOf(current);
    let next: number | undefined;
    if (e.key === 'ArrowDown') next = list[(pos + 1) % list.length];
    else if (e.key === 'ArrowUp') next = list[(pos - 1 + list.length) % list.length];
    else if (e.key === 'Home') next = list[0];
    else if (e.key === 'End') next = list[list.length - 1];
    else if (e.key === 'Tab') setOpen(false);
    if (next === undefined) return;
    e.preventDefault();
    itemRefs.current[next]?.focus();
  };

  const run = (it: SplitButtonAction) => {
    setOpen(false);
    // Back to the caret before the action runs, so focus is not stranded on
    // an unmounted item — and an action that opens a modal can take it from
    // there.
    caretRef.current?.focus();
    it.onClick?.();
  };

  return (
    <div ref={rootRef} className={cn('relative inline-flex', className)}>
      <Button
        type="button"
        variant={variant}
        size={size}
        disabled={disabled}
        loading={loading}
        onClick={onClick}
        className="rounded-r-none focus-visible:relative focus-visible:z-10"
      >
        {Icon && !loading && <Icon className="h-3.5 w-3.5" aria-hidden />}
        {label}
      </Button>
      <Button
        ref={caretRef}
        type="button"
        variant={variant}
        size={size}
        disabled={disabled}
        aria-label={menuLabel}
        aria-haspopup="menu"
        aria-expanded={open}
        aria-controls={open ? menuId : undefined}
        onClick={(e) => {
          const next = !open;
          setOpen(next);
          // `detail === 0` is a click synthesised by Enter/Space: a keyboard
          // user gets focus on the first item, a mouse user keeps it here.
          if (next && e.detail === 0) focusItem('first');
        }}
        onKeyDown={onCaretKey}
        className={cn('rounded-l-none focus-visible:relative focus-visible:z-10', SEAM[variant], CARET_PAD[size])}
      >
        <ChevronDown className={cn('h-3.5 w-3.5 transition-transform', open && 'rotate-180')} aria-hidden />
      </Button>

      {open && (
        <div
          ref={menuRef}
          id={menuId}
          role="menu"
          aria-orientation="vertical"
          onKeyDown={onMenuKey}
          className={cn(
            'panel panel-solid absolute top-full z-50 mt-1 min-w-44 p-1',
            align === 'right' ? 'right-0' : 'left-0',
          )}
        >
          {items.map((it, i) =>
            it.separator ? (
              <div key={i} role="separator" className="my-1 h-px bg-slate-200 dark:bg-slate-700" />
            ) : (
              <button
                key={i}
                ref={(el) => {
                  itemRefs.current[i] = el;
                }}
                type="button"
                role="menuitem"
                tabIndex={-1}
                disabled={it.disabled}
                onClick={() => run(it)}
                className={cn(
                  'flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-xs transition-colors',
                  'focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-40',
                  it.danger
                    ? 'text-rose-600 hover:bg-rose-50 focus:bg-rose-50 dark:text-rose-400 dark:hover:bg-rose-500/10 dark:focus:bg-rose-500/10'
                    : 'text-slate-700 hover:bg-slate-100 focus:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800 dark:focus:bg-slate-800',
                )}
              >
                {it.icon && <it.icon className="h-3.5 w-3.5 shrink-0 opacity-70" aria-hidden />}
                <span className="min-w-0 flex-1 truncate">{it.label}</span>
              </button>
            ),
          )}
        </div>
      )}
    </div>
  );
}

Props

PropTypeDefaultDescription
label*React.ReactNode—The primary action's label.
items*SplitButtonItem[]—Secondary actions, shown in the caret's menu.
onClick() => void—The primary action.
variantkeyof typeof SEAM'primary'
sizekeyof typeof CARET_PAD'md'
iconReact.ComponentType<{ className?: string }>—Icon component shown before the label.
disabledbooleanfalseDisables both halves.
loadingbooleanfalseSpinner on the primary half; the menu stays usable.
menuAlign'left' | 'right' | 'auto''auto'Which edge of the button the menu lines up with. `auto` (default) lines up with the right edge, and flips to the left when that would push the menu out past the edge of its scrolling container or the window — where it would be clipped, or painted under the sidebar beside it.
menuLabelstring'More actions'Accessible name for the caret button.
classNamestring—