"use client";
import * as React from "react";
import { AnimatePresence, motion } from "motion/react";
type Spring = { stiffness: number; damping: number; mass: number };
export interface SplitButtonAction {
id: string;
label: string;
/** Secondary line shown under the label in the menu. */
description?: string;
/** Decorative leading icon (menu and primary button). */
icon?: React.ReactNode;
disabled?: boolean;
/** Runs when this action is triggered, from the primary button or the menu. */
onSelect?: () => void;
}
export interface SplitButtonProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onSelect"> {
/** Every action. The primary one is shown on the main button; the rest are listed in the menu. */
actions: SplitButtonAction[];
/** Controlled id of the primary action. */
primaryId?: string;
/** Initial primary action when uncontrolled (defaults to the first action). */
defaultPrimaryId?: string;
/** Called when a menu choice becomes the new primary action (`rememberLast`). */
onPrimaryChange?: (id: string) => void;
/** Called with the id of every triggered action. */
onAction?: (id: string) => void;
/** Make the action chosen from the menu the new primary action. */
rememberLast?: boolean;
variant?: "solid" | "outline";
size?: "sm" | "md" | "lg";
/** Show each action's description in the menu. */
showDescriptions?: boolean;
/** Minimum menu width in px. */
menuWidth?: number;
/** Which edge of the button the menu lines up with. */
align?: "start" | "end";
/** Brand colour: solid fill, highlighted items and focus rings. */
accent?: string;
/** Spring for the menu scale-in. */
spring?: Spring;
/** Accessible name of the chevron button and its menu. */
menuLabel?: string;
/** Disable both halves. */
disabled?: boolean;
}
function subscribeReducedMotion(cb: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", cb);
return () => mq.removeEventListener("change", cb);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
const GAP = 6; // px between the button and the menu
const EDGE = 8; // px kept from the viewport edge
const ITEM_SELECTOR = "[data-sb-item]";
const SIZES = {
sm: { height: "h-8", text: "text-[13px]", pad: "pl-3 pr-3", gap: "gap-1.5", chevron: "w-8", icon: "[&_svg]:size-3.5" },
md: { height: "h-10", text: "text-sm", pad: "pl-4 pr-4", gap: "gap-2", chevron: "w-10", icon: "[&_svg]:size-4" },
lg: { height: "h-12", text: "text-[15px]", pad: "pl-5 pr-5", gap: "gap-2", chevron: "w-12", icon: "[&_svg]:size-[18px]" },
} as const;
const VARIANTS = {
solid: {
segment:
"text-zinc-950 hover:bg-zinc-950/[0.07] data-[open]:bg-zinc-950/[0.12] disabled:hover:bg-transparent",
divider: "bg-zinc-950/20",
shell:
"bg-[var(--sb-accent)] after:ring-zinc-950/10 shadow-[inset_0_1px_0_rgb(255_255_255/0.3),0_8px_18px_-10px_color-mix(in_oklab,var(--sb-accent)_80%,transparent)]",
},
outline: {
segment:
"text-zinc-900 hover:bg-zinc-900/[0.04] data-[open]:bg-zinc-900/[0.07] disabled:hover:bg-transparent dark:text-zinc-100 dark:hover:bg-white/[0.05] dark:data-[open]:bg-white/[0.08]",
divider: "bg-zinc-900/12 dark:bg-white/12",
shell: "bg-white after:ring-zinc-900/15 shadow-[0_1px_2px_rgb(0_0_0/0.06)] dark:bg-[#16191c] dark:after:ring-white/15",
},
} as const;
/** Enabled items of the menu, in order. */
function menuItems(panel: HTMLElement | null) {
if (!panel) return [];
return Array.from(panel.querySelectorAll<HTMLElement>(ITEM_SELECTOR)).filter((el) => el.getAttribute("aria-disabled") !== "true");
}
/**
* A primary action joined to a chevron that opens a menu of alternative actions. The chevron is
* an APG menu button (aria-haspopup / aria-expanded, arrows, Home / End, typeahead, Escape returns
* focus). The menu renders in the top layer, flips above the button when there is no room below
* and stays inside the viewport. With `rememberLast`, the chosen alternative becomes the primary.
*/
export function SplitButton({
actions,
primaryId: primaryIdProp,
defaultPrimaryId,
onPrimaryChange,
onAction,
rememberLast = true,
variant = "solid",
size = "md",
showDescriptions = true,
menuWidth = 260,
align = "end",
accent = "#FF7448",
spring = { stiffness: 560, damping: 34, mass: 0.7 },
menuLabel = "More actions",
disabled = false,
className = "",
style,
...props
}: SplitButtonProps) {
const uid = React.useId();
const reduceMotion = usePrefersReducedMotion();
const rootRef = React.useRef<HTMLDivElement>(null);
const chevronRef = React.useRef<HTMLButtonElement>(null);
const panelRef = React.useRef<HTMLDivElement | null>(null);
const focusOnOpen = React.useRef<"first" | "last" | "panel">("panel");
const typeahead = React.useRef({ buffer: "", timer: 0 });
const [open, setOpen] = React.useState(false);
const [primaryInternal, setPrimaryInternal] = React.useState(defaultPrimaryId);
const requestedId = primaryIdProp ?? primaryInternal;
const primary = actions.find((a) => a.id === requestedId) ?? actions[0];
const alternatives = actions.filter((a) => a !== primary);
const s = SIZES[size] ?? SIZES.md;
const v = VARIANTS[variant] ?? VARIANTS.solid;
const menuId = `${uid}-menu`;
const chevronId = `${uid}-chevron`;
const run = (action: SplitButtonAction) => {
if (action.disabled) return;
action.onSelect?.();
onAction?.(action.id);
};
const close = React.useCallback((returnFocus: boolean) => {
setOpen(false);
if (returnFocus) chevronRef.current?.focus({ preventScroll: true });
}, []);
const openMenu = (focus: "first" | "last" | "panel") => {
focusOnOpen.current = focus;
setOpen(true);
};
const choose = (action: SplitButtonAction) => {
if (action.disabled) return;
run(action);
if (rememberLast && action.id !== primary?.id) {
if (primaryIdProp === undefined) setPrimaryInternal(action.id);
onPrimaryChange?.(action.id);
}
close(true);
};
/** Places the menu under (or above) the button, aligned to one edge and clamped to the viewport. */
const position = React.useCallback(() => {
const panel = panelRef.current;
const root = rootRef.current;
if (!panel || !root) return;
const r = root.getBoundingClientRect();
const vw = document.documentElement.clientWidth;
const vh = window.innerHeight;
const w = panel.offsetWidth;
const h = panel.offsetHeight;
const spaceBelow = vh - r.bottom - GAP - EDGE;
const spaceAbove = r.top - GAP - EDGE;
const above = h > spaceBelow && spaceAbove > spaceBelow;
const preferredLeft = align === "end" ? r.right - w : r.left;
const left = Math.max(EDGE, Math.min(preferredLeft, vw - w - EDGE));
const top = above ? Math.max(EDGE, r.top - GAP - h) : Math.min(r.bottom + GAP, Math.max(EDGE, vh - h - EDGE));
panel.style.left = `${Math.round(left)}px`;
panel.style.top = `${Math.round(top)}px`;
// Grow out of the chevron corner.
const originX = Math.round(Math.min(w, Math.max(0, r.right - left - 16)));
panel.style.transformOrigin = `${originX}px ${above ? "100%" : "0%"}`;
}, [align]);
/** Mounts the panel in the top layer, positions it and moves focus into it. */
// Motion may re-invoke callback refs on re-render: mount, position and focus once per node.
const mountPanel = React.useCallback(
(node: HTMLDivElement | null) => {
if (!node || node === panelRef.current) return;
panelRef.current = node;
if (typeof node.showPopover === "function" && !node.matches(":popover-open")) {
try {
node.showPopover();
} catch {
// Unsupported: position: fixed still applies.
}
}
position();
const items = menuItems(node);
const target = focusOnOpen.current === "first" ? items[0] : focusOnOpen.current === "last" ? items[items.length - 1] : node;
(target ?? node).focus({ preventScroll: true });
},
[position],
);
// While open: outside pointer and window blur close; scroll and resize re-position.
React.useEffect(() => {
if (!open) return;
let frame = 0;
const onPointer = (event: PointerEvent) => {
const t = event.target as Node;
if (panelRef.current?.contains(t) || chevronRef.current?.contains(t)) return;
close(false);
};
const onMove = () => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(position);
};
const onBlur = () => close(false);
document.addEventListener("pointerdown", onPointer, true);
window.addEventListener("scroll", onMove, { capture: true, passive: true });
window.addEventListener("resize", onMove);
window.addEventListener("blur", onBlur);
return () => {
cancelAnimationFrame(frame);
document.removeEventListener("pointerdown", onPointer, true);
window.removeEventListener("scroll", onMove, { capture: true });
window.removeEventListener("resize", onMove);
window.removeEventListener("blur", onBlur);
};
}, [open, close, position]);
React.useEffect(() => {
const t = typeahead.current;
return () => window.clearTimeout(t.timer);
}, []);
const onMenuKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
const items = menuItems(event.currentTarget);
const active = document.activeElement as HTMLElement | null;
const index = active ? items.indexOf(active) : -1;
const focusAt = (i: number) => items[(i + items.length) % items.length]?.focus({ preventScroll: true });
switch (event.key) {
case "ArrowDown":
event.preventDefault();
focusAt(index + 1);
return;
case "ArrowUp":
event.preventDefault();
focusAt(index < 0 ? -1 : index - 1);
return;
case "Home":
case "PageUp":
event.preventDefault();
focusAt(0);
return;
case "End":
case "PageDown":
event.preventDefault();
focusAt(-1);
return;
case "Escape":
case "Tab":
event.preventDefault();
close(true);
return;
case "Enter":
case " ":
if (active && index >= 0) {
event.preventDefault();
active.click();
}
return;
}
// Typeahead: focus the next item whose label starts with the typed characters.
if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
const t = typeahead.current;
window.clearTimeout(t.timer);
t.buffer += event.key.toLowerCase();
t.timer = window.setTimeout(() => (t.buffer = ""), 600);
const ordered = [...items.slice(index + 1), ...items.slice(0, index + 1)];
const repeated = t.buffer.split("").every((c) => c === t.buffer[0]);
const match =
ordered.find((el) => (el.dataset.label ?? "").startsWith(t.buffer)) ??
(repeated ? ordered.find((el) => (el.dataset.label ?? "").startsWith(t.buffer[0])) : undefined);
match?.focus({ preventScroll: true });
}
};
const segment = `relative inline-flex ${s.height} items-center justify-center font-medium tracking-tight outline-hidden transition-[background-color,translate] duration-150 select-none focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--sb-accent)] active:translate-y-px disabled:cursor-not-allowed disabled:active:translate-y-0 ${v.segment}`;
const menuMotion = reduceMotion
? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 }, transition: { duration: 0.1 } }
: {
initial: { opacity: 0, scale: 0.92 },
animate: { opacity: 1, scale: 1 },
exit: { opacity: 0, scale: 0.97, transition: { duration: 0.1 } },
transition: { type: "spring" as const, ...spring },
};
return (
<div
ref={rootRef}
role="group"
className={`relative isolate inline-flex rounded-[10px] after:pointer-events-none after:absolute after:inset-0 after:rounded-[10px] after:ring-1 after:ring-inset ${s.text} ${disabled ? "opacity-50" : ""} ${v.shell} ${className}`}
style={{ ["--sb-accent" as string]: accent, ...style }}
{...props}
>
<button
type="button"
disabled={disabled || !primary || primary.disabled}
className={`${segment} ${s.pad} ${s.icon} rounded-l-[10px]`}
onClick={() => primary && run(primary)}
>
<span className="relative inline-grid">
<AnimatePresence initial={false} mode="popLayout">
<motion.span
key={primary?.id}
className={`inline-flex items-center whitespace-nowrap [grid-area:1/1] ${s.gap}`}
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 10, filter: "blur(2px)" }}
animate={reduceMotion ? { opacity: 1 } : { opacity: 1, y: 0, filter: "blur(0px)" }}
exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: -10, filter: "blur(2px)" }}
transition={reduceMotion ? { duration: 0.12 } : { type: "spring", stiffness: 420, damping: 32 }}
>
{primary?.icon ? (
<span aria-hidden="true" className="inline-flex shrink-0">
{primary.icon}
</span>
) : null}
{primary?.label}
</motion.span>
</AnimatePresence>
</span>
</button>
<span aria-hidden="true" className={`relative z-[1] my-auto h-[55%] w-px ${v.divider}`} />
<button
ref={chevronRef}
id={chevronId}
type="button"
disabled={disabled || alternatives.length === 0}
aria-label={menuLabel}
aria-haspopup="menu"
aria-expanded={open}
aria-controls={open ? menuId : undefined}
data-open={open || undefined}
className={`${segment} ${s.chevron} rounded-r-[10px]`}
onClick={() => (open ? close(false) : openMenu("panel"))}
onKeyDown={(event) => {
if (event.key === "ArrowDown" || event.key === "Enter" || event.key === " ") {
event.preventDefault();
openMenu("first");
} else if (event.key === "ArrowUp") {
event.preventDefault();
openMenu("last");
}
}}
>
<motion.svg
viewBox="0 0 16 16"
fill="none"
aria-hidden="true"
className="size-4"
initial={false}
animate={{ rotate: open ? 180 : 0 }}
transition={reduceMotion ? { duration: 0 } : { type: "spring", stiffness: 500, damping: 30 }}
>
<path d="M4 6.25 8 10.25l4-4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
</motion.svg>
</button>
<AnimatePresence>
{open ? (
<motion.div
key="menu"
ref={mountPanel}
id={menuId}
role="menu"
aria-labelledby={chevronId}
aria-orientation="vertical"
tabIndex={-1}
popover="manual"
onKeyDown={onMenuKeyDown}
{...menuMotion}
className="fixed inset-auto m-0 overflow-hidden rounded-xl border border-zinc-900/10 bg-white p-1 text-[13px] text-zinc-800 shadow-[0_24px_48px_-20px_rgb(0_0_0/0.4),0_2px_6px_-2px_rgb(0_0_0/0.08)] outline-hidden dark:border-white/10 dark:bg-[#1a1d21] dark:text-zinc-200 dark:shadow-[0_24px_48px_-16px_rgb(0_0_0/0.75)]"
style={{ minWidth: menuWidth, maxWidth: `min(${Math.max(menuWidth, 320)}px, calc(100vw - ${EDGE * 2}px))`, ["--sb-accent" as string]: accent }}
>
{rememberLast ? (
<div aria-hidden="true" className="flex items-center justify-between px-2.5 pb-1.5 pt-2 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-400 dark:text-zinc-500">
<span>Run & set default</span>
<span className="text-zinc-300 dark:text-zinc-600">↵</span>
</div>
) : null}
{alternatives.map((action) => (
<div
key={action.id}
role="menuitem"
tabIndex={-1}
data-sb-item=""
data-label={action.label.toLowerCase()}
aria-disabled={action.disabled || undefined}
aria-describedby={showDescriptions && action.description ? `${uid}-${action.id}-desc` : undefined}
className="group/item flex w-full cursor-default items-start gap-2.5 rounded-lg px-2.5 py-2 text-left outline-hidden transition-colors duration-75 focus:bg-[color-mix(in_oklab,var(--sb-accent)_14%,transparent)] focus:text-zinc-950 aria-disabled:opacity-40 dark:focus:text-white [&_svg]:size-4"
onClick={() => choose(action)}
onPointerMove={(event) => {
if (event.pointerType !== "mouse" || action.disabled) return;
if (document.activeElement !== event.currentTarget) event.currentTarget.focus({ preventScroll: true });
}}
onPointerLeave={(event) => {
if (event.pointerType === "mouse" && document.activeElement === event.currentTarget) panelRef.current?.focus({ preventScroll: true });
}}
>
{actions.some((a) => a.icon) ? (
<span aria-hidden="true" className="mt-px flex h-[18px] w-4 shrink-0 items-center justify-center text-zinc-500 group-focus/item:text-[var(--sb-accent)] dark:text-zinc-400">
{action.icon}
</span>
) : null}
<span className="min-w-0 flex-1">
<span className="block truncate font-medium leading-[18px] tracking-tight">{action.label}</span>
{showDescriptions && action.description ? (
<span id={`${uid}-${action.id}-desc`} className="mt-0.5 block text-[12px] leading-4 text-zinc-500 dark:text-zinc-400">
{action.description}
</span>
) : null}
</span>
</div>
))}
</motion.div>
) : null}
</AnimatePresence>
</div>
);
}