Buttons
Split Button
A primary action welded to an arrow segment that opens its own keyboard-navigable action menu.
Preview in your theme
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/split-button.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "SplitButton" component using
class-variance-authority (cva) and lucide-react. No Radix, no headless popover
library — the menu is ~40 lines of local state and two effects.
Contract
- forwardRef<HTMLButtonElement, SplitButtonProps>; SplitButtonProps extends
React.ButtonHTMLAttributes<HTMLButtonElement> plus VariantProps.
- children = the primary action label; onClick = the primary action. Every other
native button prop (type, disabled, form, aria-*) and the ref land on the
primary segment; className styles the group wrapper (a relative inline-flex).
- items: { key, label, icon?, destructive?, disabled?, onSelect }[] — the
dropdown rows. onSelect is the consumer's handler; the component only decides
when to call it.
- align: "start" | "end" (default "end") — which edge of the group the menu
aligns to.
- variant: "default" | "outline" (default "default"), a cva axis applied to both
segments so they always read as one control.
- menuLabel: accessible name for the arrow segment (default "More actions").
- disabled disables both segments, so the menu is unreachable.
Behavior
- The arrow segment toggles the menu; ArrowDown on it opens and focuses the
first row, ArrowUp opens and focuses the last.
- Inside the menu: ArrowDown / ArrowUp move between rows and wrap around,
Home / End jump to first / last, Enter and Space activate the focused row
(native <button> semantics — no key handler needed), Escape closes and returns
focus to the arrow segment.
- Tab is not intercepted at all. The menu closes from a focusout whose
relatedTarget sits outside it, so the browser resolves the next tab stop while
the current row is still mounted and focused. Closing inside the Tab keydown
looks equivalent but is not: the row unmounts first and focus falls to
document.body (Firefox and Safari then restart the tab order from the top of
the document). A null relatedTarget is ignored — it means focus left the
document rather than moving to a sibling, and closing there would eat a
pending click.
- Selecting a row calls item.onSelect(), closes the menu and restores focus to
the trigger, so keyboard users never land on document.body.
- A document "pointerdown" listener closes the menu on an outside click without
stealing focus back; it is registered only while open and removed on close and
on unmount.
- Roving focus queries the live DOM ('[role="menuitem"]:not(:disabled)') instead
of keeping a ref array, so disabled rows are skipped by construction and item
order stays the single source of truth.
- The menu is absolutely positioned under the group (top-full, mt-1) and scales
in from origin-top with a ~120ms keyframe shipped through a React 19 hoisted
<style href precedence> tag; under prefers-reduced-motion the animation and the
chevron rotation are switched off while every interaction stays identical.
Rendering & styling
- Semantic tokens only: bg-primary / text-primary-foreground and hover
bg-primary/90 for the default skin; border-border + bg-background +
hover:bg-accent for outline; bg-popover / text-popover-foreground + border +
shadow-md for the menu surface; text-destructive with a bg-destructive/10 hover
for destructive rows; ring-ring for focus.
- The two segments share one cva base and differ only in rounding
(rounded-l-md / rounded-r-md) plus a divider: a border-l in
primary-foreground/25 for the solid skin, a -ml-px overlap for outline.
- Focused segment gets focus-visible:relative z-10 so its ring is never clipped
by its neighbour; merge consumer className via cn().
- a11y: aria-haspopup="menu", aria-expanded, aria-controls on the trigger;
role="menu" on the surface, role="menuitem" + tabIndex={-1} on rows; icons are
aria-hidden.
Customization levers
- Menu width / density: min-w-48 and the row px-2 py-1.5 — raise both together
for a touch-friendly menu.
- Add a skin: one cva variant entry (e.g. ghost, destructive) — the divider rule
is the only place that branches on the skin.
- Placement: swap top-full/mt-1 for bottom-full/mb-1 to drop the menu upward when
the trigger sits near the viewport bottom; align controls left-0 vs right-0.
- Motion: the keyframe duration (120ms) and origin-top are the whole animation;
origin-top-right reads better with align="end" on wide menus.
- Grouping: render a separator <div role="separator" className="my-1 h-px
bg-border" /> between item groups by splitting items into sections.
- Size: the segments carry px-4 py-2 / px-2 py-2 — add a cva size axis if you
need a documented sm/md/lg scale.Concepts
- Primary plus overflow — the default action stays one click away while its variants live behind the arrow; a plain dropdown would cost every user an extra click for the thing they want 90% of the time.
- Roving focus — only one row is focusable at a time (
tabIndex={-1}everywhere, arrows move focus programmatically), which is what makes a menu feel like a menu rather than a list of tab stops. - Focus restore — Escape and selection both return focus to the arrow segment, so the keyboard journey ends where it started instead of on
document.body. - Dismiss layers — outside
pointerdowncloses silently (focus follows the click), Escape closes loudly (focus comes back), and Tab closes throughfocusoutrather than through the keydown, so the browser can hand focus to the next stop before the row unmounts instead of dropping it ondocument.body; the pointer listener exists only while the menu is open. - Destructive row — the one row that can't be undone is tinted with
text-destructiveinstead of being hidden, so it is recognisable before it is clicked. - Skipped rows — disabled entries are rendered but never focusable, keeping the visual list complete while arrow navigation jumps straight over them.