컴포넌트
분할 버튼
주요 동작 버튼과 다른 동작을 모은 메뉴가 하나로 붙은 버튼이에요.
- #arc
- #component
- #button
- #menu
- #action
- #dropdown
1280px
설치
이 명령 한 줄이면 필요한 파일과 패키지가 함께 설치돼요.
터미널터미널터미널bash
npx shadcn@latest add https://dsui.vercel.app/r/split-button.json파일
components/arc/split-button/split-button.module.csscomponents/arc/split-button/split-button.tsx
npm 패키지
@radix-ui/react-dropdown-menu@^2.2.1motion@^14.2.0
함께 오는 항목
split-button.module.csssplit-button.module.csssplit-button.module.csscss
/* One pill, two actions. The whole pill presses in for the main action; the menu half answers in color only, because Radix measures it on pointerdown to place the menu. */
.group { display: inline-flex; align-items: stretch; border: 1px solid var(--accent); border-radius: var(--radius-control); background: var(--accent); transition: transform var(--duration-spring) var(--ease-spring); }
.group:has(> .primary:active:not(:disabled)) { transform: scale(.97); transition-duration: var(--duration-instant); transition-timing-function: var(--ease-standard); }
.primary, .trigger { border: 0; background: var(--accent); color: var(--accent-foreground); font: inherit; cursor: pointer; -webkit-tap-highlight-color: transparent; transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), opacity var(--duration-fast) var(--ease-standard); }
.primary { position: relative; display: inline-grid; min-height: var(--control-height-md); place-items: center; padding: 0 var(--space-4); border-radius: calc(var(--radius-control) - 1px) 0 0 calc(var(--radius-control) - 1px); font-size: var(--text-sm); font-weight: 500; }
/* The slot springs to the width of the next label; the content leads from the start edge and is clipped only while it morphs. */
.primarySlot { display: inline-flex; min-width: 0; align-items: center; }
.primarySlot[data-morphing] { clip-path: inset(-50% calc(var(--space-2) * -1)); }
.primaryContent { display: inline-flex; flex: none; align-items: center; gap: var(--space-2); white-space: nowrap; }
.mainIcon { display: inline-grid; flex: none; place-items: center; }
.iconPhase { grid-area: 1 / 1; display: inline-flex; align-items: center; justify-content: center; }
.glyphs { position: relative; display: inline-flex; flex: none; white-space: pre; }
.glyph { display: inline-block; }
.trigger { display: grid; width: 38px; min-height: var(--control-height-md); place-items: center; border-left: 1px solid color-mix(in oklab, var(--accent-foreground) 22%, var(--accent)); border-radius: 0 calc(var(--radius-control) - 1px) calc(var(--radius-control) - 1px) 0; }
.chevron { transition: transform var(--duration-spring) var(--ease-spring); }
.trigger[data-state="open"] .chevron { transform: rotate(180deg); }
@media (hover: hover) and (pointer: fine) {
.primary:hover:not(:disabled), .trigger:hover:not(:disabled) { background: color-mix(in oklab, var(--accent) 91%, var(--background)); }
.secondary .primary:hover:not(:disabled), .secondary .trigger:hover:not(:disabled) { background: var(--surface-muted); }
}
.primary:active:not(:disabled), .trigger:active:not(:disabled), .trigger[data-state="open"] { background: var(--accent-strong); }
.primary:disabled, .trigger:disabled { cursor: not-allowed; opacity: .52; }
.secondary { border-color: var(--border); border-radius: var(--radius-pill); background: var(--surface); }
.secondary .primary, .secondary .trigger { min-height: var(--control-height-sm); background: var(--surface); color: var(--foreground); }
.secondary .primary { min-width: 142px; border-radius: var(--radius-pill) 0 0 var(--radius-pill); padding-inline: var(--space-3); font-size: var(--text-xs); }
.secondary .trigger { width: 32px; border-left-color: var(--border-subtle); border-radius: 0 var(--radius-pill) var(--radius-pill) 0; }
.secondary .primary:active:not(:disabled), .secondary .trigger:active:not(:disabled), .secondary .trigger[data-state="open"] { background: color-mix(in oklab, var(--surface-muted), var(--border) 55%); }
.icon { display: inline-flex; }
.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
/* The menu grows from the trigger edge: offset toward the trigger, scale from the Radix origin, fade quickly; it leaves faster than it arrives.
Transitions instead of keyframes, so a close that interrupts the open reverses from where the menu is instead of snapping to rest first. */
.menu { --menu-x: 0px; --menu-y: -5px; z-index: 60; min-width: min(12rem, var(--radix-dropdown-menu-content-available-width)); max-width: var(--radix-dropdown-menu-content-available-width); padding: 5px; border: 1px solid var(--float-edge, var(--surface-raised)); border-radius: var(--radius-panel); background: var(--surface-raised); color: var(--foreground); box-shadow: var(--shadow-floating); transform-origin: var(--radix-dropdown-menu-content-transform-origin); transition: opacity var(--duration-fast) var(--ease-enter), transform var(--duration-spring) var(--ease-spring); }
.menu[data-side="top"] { --menu-y: 5px; }
.menu[data-side="left"] { --menu-x: 5px; --menu-y: 0px; }
.menu[data-side="right"] { --menu-x: -5px; --menu-y: 0px; }
@starting-style {
.menu[data-state="open"] { opacity: 0; transform: translate(var(--menu-x), var(--menu-y)) scale(.97); }
.menu[data-state="open"] .item { opacity: 0; transform: translate(calc(var(--menu-x) * .4), calc(var(--menu-y) * .4)); }
}
/* Radix unmounts when the exit animation ends, so a no-op keyframe times the exit while the transition does the visual work. */
.menu[data-state="closed"] { opacity: 0; transform: translate(calc(var(--menu-x) * .5), calc(var(--menu-y) * .5)) scale(.985); transition: opacity var(--duration-instant) var(--ease-standard), transform var(--duration-instant) var(--ease-standard); animation: menu-exit var(--duration-instant) linear both; pointer-events: none; }
/* Highlight follows the pointer and arrow keys instantly, like a native menu. */
.item { display: flex; min-height: var(--control-height-sm); align-items: center; gap: 10px; padding: 0 11px; border-radius: calc(var(--radius-panel) - 6px); font-size: var(--text-sm); cursor: pointer; outline: none; transition: opacity var(--duration-standard) var(--ease-enter) calc(min(var(--i, 0), 4) * 35ms), transform var(--duration-standard) var(--ease-enter) calc(min(var(--i, 0), 4) * 35ms); }
.item[data-highlighted] { background: var(--surface-muted); }
.item[data-disabled] { cursor: default; opacity: .45; }
.destructive { color: var(--danger); }
.destructive[data-highlighted] { background: color-mix(in oklab, var(--danger) 8%, var(--surface)); }
@keyframes menu-exit { to { --menu-exit: 1; } }
@media (prefers-reduced-motion: reduce) {
.group, .primary, .trigger, .chevron { transition: none; }
.group:has(> .primary:active:not(:disabled)) { transform: none; }
.item { transition: none; }
.menu, .menu[data-state="closed"] { transform: none; transition: opacity var(--duration-instant) linear; }
}
split-button.tsxsplit-button.tsxsplit-button.tsxtsx
"use client";
import { isValidElement, useEffect, useLayoutEffect, useRef, useState } from "react";
import type { CSSProperties, ReactNode, RefObject } from "react";
import * as DropdownPrimitive from "@radix-ui/react-dropdown-menu";
import { AnimatePresence, animate, motion, useMotionValue } from "motion/react";
import { useReducedMotion } from "../lib/use-reduced-motion";
import type { TargetAndTransition } from "motion/react";
import { motionTokens } from "../lib/motion-tokens";
import styles from "./split-button.module.css";
import { playUiSound } from "../sound/play";
import { UiIcon } from "../theme/ui-icon";
export interface SplitButtonAction { label: string; onSelect?: () => void; disabled?: boolean; destructive?: boolean; icon?: ReactNode; }
export interface SplitButtonProps { label: string; actions: SplitButtonAction[]; onClick?: () => void; disabled?: boolean; icon?: ReactNode; variant?: "primary" | "secondary"; }
const rest: TargetAndTransition = { opacity: 1, y: 0, scale: 1, filter: "blur(0px)" };
const fadeIn: TargetAndTransition = { ...rest, opacity: 0 };
const fadeOut: TargetAndTransition = { opacity: 0, transition: { duration: motionTokens.duration.instant } };
const glyphIn: TargetAndTransition = { opacity: 0, y: 5, filter: `blur(${motionTokens.blur.soft}px)` };
const glyphOut: TargetAndTransition = { opacity: 0, y: -4, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] } };
const iconIn: TargetAndTransition = { opacity: 0, scale: .6, filter: `blur(${motionTokens.blur.subtle}px)` };
const iconOut: TargetAndTransition = { ...iconIn, transition: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] } };
/** Scale rides the spring; opacity and blur tween so blur never overshoots below zero. */
const iconEnter = { ...motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.enter] }, filter: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.enter] } } as const;
/** Names the icon element, so swapping Copy for Check morphs while a re-render of the same icon stays still. */
function iconKey(node: ReactNode): string {
if (!isValidElement(node)) return node == null || typeof node === "boolean" ? "" : String(node);
const type = node.type as string | { displayName?: string; name?: string };
return typeof type === "string" ? type : type?.displayName ?? type?.name ?? "icon";
}
/** Springs the slot to the natural width of its content when the label changes; other resizes (a late web font) jump. */
function useMorphWidth(content: RefObject<HTMLElement | null>, key: string, reduced: boolean) {
const width = useMotionValue<number | "auto">("auto");
const lastKey = useRef(key), armedUntil = useRef(0);
useLayoutEffect(() => {
if (lastKey.current === key) return;
lastKey.current = key;
armedUntil.current = performance.now() + 700;
}, [key]);
useEffect(() => {
const node = content.current, slot = node?.parentElement;
if (!node || !slot || typeof ResizeObserver === "undefined") return;
let measured = false;
const observer = new ResizeObserver(([entry]) => {
if (!entry) return;
const next = entry.contentRect.width;
if (!next || !measured || reduced || performance.now() > armedUntil.current) { measured = next > 0; width.jump(next || "auto"); delete slot.dataset.morphing; return; }
slot.dataset.morphing = "";
animate(width, next, { ...motionTokens.spring.morph, onComplete: () => { delete slot.dataset.morphing; } });
});
observer.observe(node);
return () => observer.disconnect();
}, [content, reduced, width]);
return width;
}
type Glyph = { id: string; char: string; order: number };
const toGlyphs = (chars: string[], seq: number): Glyph[] => chars.map((char, order) => ({ id: `${seq}:${order}`, char, order }));
/** Shared leading and trailing characters keep their identity, so "복사하기" to "복사했어요" only replaces the changed letters. */
function useGlyphs(text: string) {
const [state, setState] = useState(() => ({ text, seq: 0, glyphs: toGlyphs([...text], 0) }));
if (state.text === text) return state.glyphs;
const prev = [...state.text], next = [...text];
let start = 0, end = 0;
while (start < prev.length && start < next.length && prev[start] === next[start]) start++;
while (end < prev.length - start && end < next.length - start && prev[prev.length - 1 - end] === next[next.length - 1 - end]) end++;
if (start < 2) start = 0;
if (end < 2) end = 0;
const seq = state.seq + 1;
const glyphs = [...state.glyphs.slice(0, start), ...toGlyphs(next.slice(start, next.length - end), seq), ...state.glyphs.slice(state.glyphs.length - end)];
setState({ text, seq, glyphs });
return glyphs;
}
function MorphText({ text, reduced }: { text: string; reduced: boolean }) {
const glyphs = useGlyphs(text);
return <span className={styles.glyphs}>
<AnimatePresence mode="popLayout" initial={false}>
{glyphs.map(glyph => <motion.span key={glyph.id} className={styles.glyph} layout={reduced ? false : "position"} layoutDependency={text} initial={reduced ? fadeIn : glyphIn} animate={rest} exit={reduced ? fadeOut : glyphOut} transition={reduced ? { duration: motionTokens.duration.instant } : { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter], delay: Math.min(glyph.order * motionTokens.stagger.char, .1), layout: motionTokens.spring.morph }}>{glyph.char}</motion.span>)}
</AnimatePresence>
</span>;
}
/** The main action morphs its icon and label in place while its width follows on a spring; the menu half never scales, so the menu opens from a still anchor. */
export function SplitButton({ label, actions, onClick, disabled, icon, variant = "primary" }: SplitButtonProps) {
const reduced = useReducedMotion() ?? false;
const contentRef = useRef<HTMLSpanElement>(null);
const glyph = iconKey(icon);
const width = useMorphWidth(contentRef, `${glyph}|${label}`, reduced);
// Radix closes the menu right after an item is chosen; a choice plays only "select", so the close sound waits one microtask to see whether a choice came with it.
const chose = useRef(false);
return <DropdownPrimitive.Root onOpenChange={open => { if (open) { chose.current = false; playUiSound("open"); } else queueMicrotask(() => { if (!chose.current) playUiSound("close"); chose.current = false; }); }}>
<div className={[styles.group, variant === "secondary" ? styles.secondary : ""].filter(Boolean).join(" ")}>
<button className={styles.primary} type="button" onClick={() => { playUiSound("click"); onClick?.(); }} disabled={disabled}>
<motion.span className={styles.primarySlot} style={{ width }} aria-hidden="true">
<span ref={contentRef} className={styles.primaryContent}>
{icon ? <span className={styles.mainIcon}><AnimatePresence initial={false}><motion.span key={glyph} className={styles.iconPhase} initial={reduced ? fadeIn : iconIn} animate={rest} exit={reduced ? fadeOut : iconOut} transition={reduced ? { duration: motionTokens.duration.instant } : iconEnter}>{icon}</motion.span></AnimatePresence></span> : null}
<MorphText text={label} reduced={reduced} />
</span>
</motion.span>
<span className={styles.srOnly} aria-live="polite">{label}</span>
</button>
<DropdownPrimitive.Trigger className={styles.trigger} type="button" aria-label={`${label} 더 보기`} disabled={disabled}><UiIcon iconRole="chevron-down" className={styles.chevron} size={16} strokeWidth={1.75} aria-hidden="true" /></DropdownPrimitive.Trigger>
</div>
<DropdownPrimitive.Portal><DropdownPrimitive.Content className={styles.menu} sideOffset={4} align="end" collisionPadding={12} loop>
{actions.map((action, index) => <DropdownPrimitive.Item key={action.label} className={[styles.item, action.destructive ? styles.destructive : ""].filter(Boolean).join(" ")} style={{ "--i": index } as CSSProperties} disabled={action.disabled} onSelect={() => { chose.current = true; playUiSound("select"); action.onSelect?.(); }}>{action.icon ? <span className={styles.icon} aria-hidden="true">{action.icon}</span> : null}{action.label}</DropdownPrimitive.Item>)}
</DropdownPrimitive.Content></DropdownPrimitive.Portal>
</DropdownPrimitive.Root>;
}
export default SplitButton;