Building an Accessible React Split Button Dropdown in React + TypeScript

Build an accessible React split button dropdown with ARIA, keyboard support, and TypeScript—complete code, styling, and testing tips.

ASOasis
9 min read
Building an Accessible React Split Button Dropdown in React + TypeScript

Image used for representation purposes only.

Overview

A split button combines two controls: a primary action button and an adjacent toggle that opens a menu of alternative actions. It’s perfect when one action is the most common (e.g., “Export PDF”) but users sometimes need a different variant (“Export CSV,” “Export DOCX”). In this article, we’ll build an accessible, keyboard-friendly React split button dropdown using TypeScript. We’ll cover API design, ARIA semantics, keyboard support, focus management, and testing.

Goals and requirements

A robust split button should:

  • Offer a clear primary action that never requires opening the menu.
  • Provide a discoverable toggle for alternates.
  • Be fully keyboard-navigable (no mouse required).
  • Announce state and structure to assistive technologies with correct ARIA roles/attributes.
  • Dismiss predictably: click outside, Escape key, or selecting an item.
  • Support controlled/uncontrolled open state.
  • Allow disabled items and separators.

Component API design

We’ll aim for a small, expressive API.

export type SplitButtonItem = {
  id: string;
  label: string;
  onSelect: () => void;
  disabled?: boolean;
  icon?: React.ReactNode;
  shortcut?: string; // optional UI hint, not a real keybinding
  separator?: boolean; // when true, renders a visual separator
};

export type SplitButtonProps = {
  label: string;                // primary action label (e.g., "Export")
  onPrimaryAction: () => void;  // invoked when main button is clicked
  items: SplitButtonItem[];     // dropdown items
  disabled?: boolean;
  open?: boolean;               // controlled open state
  defaultOpen?: boolean;        // uncontrolled initial state
  onOpenChange?: (open: boolean) => void;
  ariaLabel?: string;           // label for the button group
  className?: string;           // custom styling hook
  size?: 'sm' | 'md' | 'lg';
  intent?: 'neutral' | 'primary' | 'danger';
};

Accessibility and behavior

  • Grouping: Wrap the two buttons in a container with role=“group” and an accessible name (aria-label or aria-labelledby).
  • Toggle semantics: The caret button has aria-haspopup=“menu”, aria-expanded to reflect state, and aria-controls referencing the menu id.
  • Menu: Use role=“menu” on the wrapper and role=“menuitem” for actionable items. Non-actionable separators use role=“separator”.
  • Focus: When the menu opens via keyboard, send focus to the first (or last) enabled item depending on the arrow key used. On close, restore focus to the toggle.
  • Keyboard map:
    • Toggle: Enter/Space toggles menu. ArrowDown opens and focuses first item; ArrowUp opens and focuses last. Escape closes.
    • Menu: ArrowDown/ArrowUp moves focus; Home/End jumps; Enter/Space activates; Escape closes and returns focus to toggle; Tab closes and lets the browser move focus.

Implementation (TypeScript + React)

Below is a headless, framework-agnostic component that you can style as needed.

import React, {useEffect, useId, useLayoutEffect, useRef, useState} from 'react';

export type SplitButtonItem = {
  id: string;
  label: string;
  onSelect: () => void;
  disabled?: boolean;
  icon?: React.ReactNode;
  shortcut?: string;
  separator?: boolean;
};

export type SplitButtonProps = {
  label: string;
  onPrimaryAction: () => void;
  items: SplitButtonItem[];
  disabled?: boolean;
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  ariaLabel?: string;
  className?: string;
  size?: 'sm' | 'md' | 'lg';
  intent?: 'neutral' | 'primary' | 'danger';
};

function useControllableState({
  value,
  defaultValue,
  onChange,
}: {
  value?: boolean;
  defaultValue?: boolean;
  onChange?: (v: boolean) => void;
}) {
  const [internal, setInternal] = useState<boolean>(!!defaultValue);
  const isControlled = value !== undefined;
  const state = isControlled ? (value as boolean) : internal;
  const setState = (v: boolean) => {
    if (!isControlled) setInternal(v);
    onChange?.(v);
  };
  return [state, setState] as const;
}

function useOutsideClick(refs: React.RefObject<HTMLElement>[], handler: () => void) {
  useEffect(() => {
    function onPointerDown(e: MouseEvent | PointerEvent | TouchEvent) {
      const target = e.target as Node;
      const clickedInside = refs.some(r => r.current?.contains(target));
      if (!clickedInside) handler();
    }
    document.addEventListener('mousedown', onPointerDown, {capture: true});
    document.addEventListener('touchstart', onPointerDown, {capture: true});
    return () => {
      document.removeEventListener('mousedown', onPointerDown, {capture: true});
      document.removeEventListener('touchstart', onPointerDown, {capture: true});
    };
  }, [refs, handler]);
}

export function SplitButton({
  label,
  onPrimaryAction,
  items,
  disabled,
  open: openProp,
  defaultOpen,
  onOpenChange,
  ariaLabel = 'Actions',
  className,
  size = 'md',
  intent = 'primary',
}: SplitButtonProps) {
  const [open, setOpen] = useControllableState({
    value: openProp,
    defaultValue: defaultOpen,
    onChange: onOpenChange,
  });

  const groupRef = useRef<HTMLDivElement>(null);
  const toggleRef = useRef<HTMLButtonElement>(null);
  const menuRef = useRef<HTMLUListElement>(null);
  const menuId = useId();
  const focusIndexRef = useRef<number>(-1);
  const lastFocusRef = useRef<HTMLElement | null>(null);

  // Close on outside click
  useOutsideClick([groupRef, menuRef], () => setOpen(false));

  // When opening via keyboard, move focus to the right item
  useLayoutEffect(() => {
    if (!open) return;
    const itemsEls = getEnabledItemButtons(menuRef.current);
    if (!itemsEls.length) return;

    const targetIdx = focusIndexRef.current >= 0 ? focusIndexRef.current : 0;
    itemsEls[targetIdx]?.focus();
  }, [open]);

  // Restore focus to toggle on close if menu had focus
  useEffect(() => {
    if (!open && document.activeElement && menuRef.current?.contains(document.activeElement)) {
      toggleRef.current?.focus();
    }
  }, [open]);

  function getEnabledItemButtons(menu: HTMLUListElement | null): HTMLButtonElement[] {
    if (!menu) return [];
    return Array.from(menu.querySelectorAll('button[role="menuitem"]:not([disabled])')) as HTMLButtonElement[];
  }

  function openMenuAndFocus(index: number) {
    focusIndexRef.current = index;
    lastFocusRef.current = (document.activeElement as HTMLElement) || null;
    setOpen(true);
  }

  function handleToggleKeyDown(e: React.KeyboardEvent) {
    if (disabled) return;
    if (e.key === 'ArrowDown') {
      e.preventDefault();
      openMenuAndFocus(0);
    } else if (e.key === 'ArrowUp') {
      e.preventDefault();
      // focus the last enabled item
      const enabled = getEnabledItemButtons(menuRef.current);
      openMenuAndFocus(Math.max(0, enabled.length - 1));
    } else if (e.key === 'Escape') {
      setOpen(false);
    }
  }

  function handleItemKeyDown(e: React.KeyboardEvent<HTMLButtonElement>) {
    const enabled = getEnabledItemButtons(menuRef.current);
    const currentIndex = enabled.indexOf(e.currentTarget);
    if (e.key === 'ArrowDown') {
      e.preventDefault();
      enabled[(currentIndex + 1) % enabled.length]?.focus();
    } else if (e.key === 'ArrowUp') {
      e.preventDefault();
      enabled[(currentIndex - 1 + enabled.length) % enabled.length]?.focus();
    } else if (e.key === 'Home') {
      e.preventDefault();
      enabled[0]?.focus();
    } else if (e.key === 'End') {
      e.preventDefault();
      enabled[enabled.length - 1]?.focus();
    } else if (e.key === 'Escape') {
      e.preventDefault();
      setOpen(false);
    } else if (e.key === 'Tab') {
      setOpen(false); // let the browser move focus
    }
  }

  function selectAndClose(action: () => void) {
    action();
    setOpen(false);
  }

  const cls = `sb sb-${size} sb-${intent} ${className ?? ''}`.trim();

  return (
    <div ref={groupRef} role="group" aria-label={ariaLabel} className={cls}>
      <button
        type="button"
        className="sb-btn sb-main"
        onClick={onPrimaryAction}
        disabled={disabled}
      >
        {label}
      </button>

      <button
        ref={toggleRef}
        type="button"
        className="sb-btn sb-caret"
        aria-haspopup="menu"
        aria-expanded={open}
        aria-controls={menuId}
        disabled={disabled}
        onClick={() => setOpen(!open)}
        onKeyDown={handleToggleKeyDown}
        aria-label={`${label} options`}
      >
        
      </button>

      {open && (
        <ul
          ref={menuRef}
          id={menuId}
          role="menu"
          className="sb-menu"
          aria-label={`${label} menu`}
        >
          {items.map((it, i) => {
            if (it.separator) {
              return <li key={`sep-${i}`} role="separator" className="sb-sep" aria-hidden="true" />;
            }
            const disabledItem = !!it.disabled;
            return (
              <li key={it.id} role="none">
                <button
                  role="menuitem"
                  type="button"
                  disabled={disabledItem}
                  className="sb-menuitem"
                  onKeyDown={handleItemKeyDown}
                  onClick={() => selectAndClose(it.onSelect)}
                >
                  {it.icon && <span className="sb-icn" aria-hidden>{it.icon}</span>}
                  <span className="sb-label">{it.label}</span>
                  {it.shortcut && <span className="sb-kbd" aria-hidden>{it.shortcut}</span>}
                </button>
              </li>
            );
          })}
        </ul>
      )}
    </div>
  );
}

Minimal styling (optional)

These styles are intentionally simple. Replace with your design system tokens or CSS-in-JS.

/* container */
.sb { position: relative; display: inline-flex; gap: 1px; }

/* buttons */
.sb-btn { appearance: none; border: 1px solid #c9ced6; background: #fff; color: #1f2937; padding: 0.5rem 0.75rem; cursor: pointer; }
.sb-btn:disabled { opacity: 0.6; cursor: not-allowed; }
.sb-main { border-radius: 6px 0 0 6px; }
.sb-caret { border-radius: 0 6px 6px 0; width: 2.25rem; display: grid; place-items: center; }

/* states */
.sb-btn:hover:not(:disabled) { background: #f3f4f6; }
.sb-btn:focus-visible { outline: 2px solid #2563eb; outline-offset: 1px; }

/* menu */
.sb-menu { position: absolute; top: calc(100% + 4px); left: 0; min-width: 220px; background: #fff; border: 1px solid #e5e7eb; border-radius: 8px; padding: 4px; box-shadow: 0 8px 24px rgba(0,0,0,0.12); z-index: 1000; }
.sb-menuitem { width: 100%; display: grid; grid-template-columns: 20px 1fr auto; align-items: center; gap: 8px; padding: 8px 10px; background: transparent; border: 0; text-align: left; color: #111827; border-radius: 6px; }
.sb-menuitem:hover:not(:disabled), .sb-menuitem:focus { background: #f3f4f6; }
.sb-menuitem:disabled { opacity: 0.5; }
.sb-sep { height: 1px; background: #e5e7eb; margin: 4px 6px; }
.sb-icn { width: 20px; height: 20px; display: inline-grid; place-items: center; }
.sb-kbd { color: #6b7280; font-size: 12px; }

/* sizes */
.sb-sm .sb-btn { padding: 0.375rem 0.6rem; }
.sb-lg .sb-btn { padding: 0.65rem 0.9rem; }

/* intents (simplified) */
.sb-primary .sb-btn { border-color: #2563eb; background: #2563eb; color: #fff; }
.sb-primary .sb-btn:hover:not(:disabled) { background: #1d4ed8; }

Usage example

Let’s build a typical “Export” split button: primary action exports to PDF; the menu offers variants.

import { SplitButton, SplitButtonItem } from './SplitButton';
import { useState } from 'react';

function ExportPanel() {
  const [lastExport, setLastExport] = useState<string | null>(null);

  const items: SplitButtonItem[] = [
    { id: 'pdf', label: 'Export as PDF', onSelect: () => doExport('pdf') },
    { id: 'csv', label: 'Export as CSV', onSelect: () => doExport('csv'), shortcut: '⌘⇧C' },
    { id: 'docx', label: 'Export as DOCX', onSelect: () => doExport('docx') },
    { separator: true, id: 'sep-1', label: '', onSelect: () => {} },
    { id: 'settings', label: 'Export settings…', onSelect: () => openExportSettings() },
  ];

  function doExport(fmt: string) {
    // your export logic
    setLastExport(fmt);
    console.log('Exported as', fmt);
  }

  function openExportSettings() {
    console.log('Open settings');
  }

  return (
    <div>
      <SplitButton
        label="Export"
        onPrimaryAction={() => doExport('pdf')}
        items={items}
        ariaLabel="Export actions"
      />
      {lastExport && <p style={{marginTop: 12}}>Last export: {lastExport.toUpperCase()}</p>}
    </div>
  );
}

Advanced tips

  • Typeahead: For large menus, implement a simple typeahead buffer that jumps focus to items by first character(s). Debounce the buffer with a short timeout.
  • Positioning: For portal-based, collision-aware positioning, integrate with a popper/overlay library. Keep aria-controls pointing at the real menu id even if portaled.
  • Focus restoration: When opening via mouse, you might avoid auto-focusing the first item (to prevent unexpected scroll on mobile). A heuristic: only auto-focus after keyboard events.
  • Controlled state: If you need to manage open state globally (e.g., analytics, close on route change), use the controlled props open/onOpenChange and drive it from above.
  • Accessibility labels: If multiple split buttons appear, make aria-label specific (e.g., “Export actions”, “Share actions”).
  • Disabled states: Disabling the whole control should disable both primary and caret buttons. Individual items can be disabled while the menu remains usable.

Testing the component

Use React Testing Library and user-event to validate accessibility and behavior.

import {render, screen} from '@testing-library/react';
import user from '@testing-library/user-event';
import {SplitButton} from './SplitButton';

it('opens with ArrowDown and focuses first item', async () => {
  const u = user.setup();
  render(
    <SplitButton
      label="Export"
      onPrimaryAction={jest.fn()}
      items={[
        {id: 'pdf', label: 'Export as PDF', onSelect: jest.fn()},
        {id: 'csv', label: 'Export as CSV', onSelect: jest.fn()},
      ]}
    />
  );

  const toggle = screen.getByRole('button', {name: /export options/i});
  await u.click(toggle); // or: await u.keyboard('{ArrowDown}') to test keyboard open
  const menu = screen.getByRole('menu', {name: /export menu/i});
  expect(menu).toBeVisible();
  const firstItem = screen.getByRole('menuitem', {name: /export as pdf/i});
  expect(firstItem).toHaveFocus();
});

Common pitfalls and how to avoid them

  • Missing ARIA: Using a plain div for the menu prevents screen readers from understanding its purpose. Use role=“menu” and role=“menuitem” consistently.
  • Focus traps: Don’t trap focus inside the menu; menus should close on Tab so users can continue through the page.
  • Click vs key activation: Ensure Enter and Space activate items just like a mouse click.
  • Ambiguous labels: The group should have an explicit aria-label so the pairing of the two buttons is clear.
  • Menu placement bugs: If the menu can overflow the viewport, consider flipping to the top or right with a positioning library.

Variations and extensions

  • Radio/checkbox items: For persistent settings, add role=“menuitemradio” or role=“menuitemcheckbox” with aria-checked.
  • Icons and shortcuts: Render icons for scannability and shortcuts for expert users. Remember shortcuts must be implemented separately in your app’s key handling.
  • Theming: Wrap base class names with your token system. Expose size and intent props as shown.
  • Internationalization: Labels should come from i18n dictionaries; avoid hardcoding English strings.
  • Server rendering: Keep menu closed by default to avoid hydration mismatches.

Conclusion

A split button gives users a fast primary action and convenient alternatives, but it only shines when it’s accessible and predictable. With clear ARIA semantics, robust keyboard support, and careful focus management, the component above is production-ready and easy to theme or extend. Drop it into a design system and you’ll have a versatile control for power workflows like exporting, sharing, or creating with presets.

Related Posts