{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"action-button","type":"registry:ui","title":"Action button","description":"A compact button for frequent toolbar actions.","categories":["actions"],"dependencies":["lucide-react","motion"],"registryDependencies":["https://ui.teamofsilicons.com/r/silicon-motion-tokens.json","https://ui.teamofsilicons.com/r/text-morph.json"],"files":[{"path":"registry/components/action-button/action-button.tsx","type":"registry:component","target":"@components/silicon-ui/action-button/action-button.tsx","content":"\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport type { ButtonHTMLAttributes, RefObject } from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport type { TargetAndTransition, Variants } from \"motion/react\";\nimport { ArrowRight } from \"lucide-react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { TextMorph } from \"../text-morph/text-morph\";\nimport styles from \"./action-button.module.css\";\n\nexport interface ActionButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, \"onClick\" | \"onDrag\" | \"onDragEnd\" | \"onDragStart\" | \"onAnimationStart\"> {\n  label: string;\n  successLabel?: string;\n  pendingLabel?: string;\n  onAction: () => void | Promise<void>;\n  resetAfterMs?: number;\n  onActionError?: (error: unknown) => void;\n}\n\nconst pressVariants: Variants = {\n  pressed: (button: RefObject<HTMLButtonElement | null>) => ({ scale: (button.current?.offsetWidth ?? 0) > 220 ? .985 : .97, transition: { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] } }),\n};\nconst rest: TargetAndTransition = { opacity: 1, y: 0, scale: 1, filter: \"blur(0px)\" };\nconst iconIn: TargetAndTransition = { opacity: 0, scale: .6, filter: `blur(${motionTokens.blur.subtle}px)` };\nconst iconOut: TargetAndTransition = { ...iconIn, transition: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] } };\n/** The arrow leaves in the direction of the action and returns from behind once the button resets. */\nconst arrowIn: TargetAndTransition = { opacity: 0, x: -6, filter: `blur(${motionTokens.blur.subtle}px)` };\nconst arrowOut: TargetAndTransition = { opacity: 0, x: 8, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] } };\nconst iconRest: TargetAndTransition = { ...rest, x: 0 };\nconst fadeIn: TargetAndTransition = { ...rest, opacity: 0 };\nconst fadeOut: TargetAndTransition = { opacity: 0, transition: { duration: motionTokens.duration.instant } };\n/** Scale rides the spring; opacity and blur tween so blur never overshoots below zero. */\nconst iconEnter = { ...motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.enter] }, filter: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.enter] } } as const;\n\n/** The success tick draws itself from its short stroke, the way a hand would write it. */\nfunction DrawnCheck({ reduced }: { reduced: boolean }) {\n  return <svg className={styles.statusIcon} width={17} height={17} viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth={2} strokeLinecap=\"round\" strokeLinejoin=\"round\">\n    <motion.path d=\"M4 12l5 5L20 6\" initial={reduced ? false : { pathLength: 0, opacity: 0 }} animate={{ pathLength: 1, opacity: 1 }} transition={{ pathLength: { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter], delay: .05 }, opacity: { duration: .05, delay: .05 } }} />\n  </svg>;\n}\n\nexport function ActionButton({ label, successLabel = \"Saved\", pendingLabel = \"Saving\", onAction, resetAfterMs = 2400, onActionError, className, disabled, ...props }: ActionButtonProps) {\n  const [state, setState] = useState<\"idle\" | \"pending\" | \"success\">(\"idle\");\n  const resetTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const buttonRef = useRef<HTMLButtonElement>(null);\n  const reduceMotion = useReducedMotion() ?? false;\n  const text = state === \"pending\" ? pendingLabel : state === \"success\" ? successLabel : label;\n\n  useEffect(() => () => { if (resetTimer.current) clearTimeout(resetTimer.current); }, []);\n\n  async function run() {\n    if (state === \"pending\") return;\n    if (resetTimer.current) clearTimeout(resetTimer.current);\n    setState(\"pending\");\n    try {\n      await onAction();\n      setState(\"success\");\n      if (resetAfterMs > 0) resetTimer.current = setTimeout(() => setState(\"idle\"), resetAfterMs);\n    } catch (error) {\n      setState(\"idle\");\n      onActionError?.(error);\n    }\n  }\n\n  const pending = state === \"pending\";\n  const arrow = state === \"idle\";\n\n  // Pending stays focusable (aria-disabled instead of disabled), so a keyboard user keeps focus through the whole save.\n  return <motion.button {...props} ref={buttonRef} tabIndex={props.tabIndex ?? 0} type={props.type ?? \"button\"} className={[styles.button, className].filter(Boolean).join(\" \")} data-variant=\"primary\" disabled={disabled} aria-disabled={pending ? true : props[\"aria-disabled\"]} aria-busy={pending} data-state={state} onClick={run} custom={buttonRef} variants={pressVariants} whileTap={reduceMotion || disabled || pending ? undefined : \"pressed\"} transition={motionTokens.spring.snappy}>\n    <span className={styles.content} aria-hidden=\"true\">\n      <TextMorph>{text}</TextMorph>\n      <span className={styles.iconSlot}><AnimatePresence initial={false}><motion.span key={state} className={styles.phase} initial={reduceMotion ? fadeIn : arrow ? arrowIn : iconIn} animate={iconRest} exit={reduceMotion ? fadeOut : arrow ? arrowOut : iconOut} transition={reduceMotion ? { duration: motionTokens.duration.instant } : iconEnter}>{pending ? <span className={styles.spinner} /> : state === \"success\" ? <DrawnCheck reduced={reduceMotion} /> : <ArrowRight className={styles.arrow} width={17} height={17} />}</motion.span></AnimatePresence></span>\n    </span>\n    <span className={styles.visuallyHidden}>{label}</span>\n    <span className={styles.visuallyHidden} role=\"status\">{state === \"pending\" ? pendingLabel : state === \"success\" ? successLabel : \"\"}</span>\n  </motion.button>;\n}\n\nexport default ActionButton;\n"},{"path":"registry/components/action-button/action-button.module.css","type":"registry:component","target":"@components/silicon-ui/action-button/action-button.module.css","content":"/* The button hugs its label: TextMorph morphs the text in place and springs its width, so no state ever snaps the layout. */\n.button { position: relative; display: inline-flex; min-height: var(--control-height-md); align-items: center; justify-content: center; overflow: hidden; padding: 0 var(--space-comfort); border: 1px solid var(--primary); border-radius: var(--radius-control); corner-shape: var(--corner-shape); background: var(--primary); color: var(--primary-foreground); font: inherit; font-size: var(--text-sm); font-weight: 500; line-height: var(--leading-body); cursor: pointer; -webkit-tap-highlight-color: transparent; transition: background var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), box-shadow var(--duration-fast) var(--ease-standard), opacity var(--duration-fast) var(--ease-standard); }\n@media (hover: hover) and (pointer: fine) { .button:hover:not(:disabled) { box-shadow: var(--shadow-resting); } }\n.button:disabled { cursor: not-allowed; }\n.button:disabled:not([data-state=\"pending\"]) { opacity: .7; }\n.button[data-state=\"pending\"] { cursor: progress; }\n.content { display: inline-flex; min-height: 1.25rem; align-items: center; justify-content: center; gap: var(--space-related); white-space: nowrap; }\n.iconSlot { display: grid; width: 17px; height: 17px; flex: 0 0 17px; place-items: center; }\n.phase { grid-area: 1 / 1; display: grid; width: 17px; height: 17px; place-items: center; }\n.arrow { flex: none; }\n.statusIcon { color: currentColor; }\n.spinner { display: inline-block; width: var(--space-inset); height: var(--space-inset); border: 1.5px solid currentColor; border-right-color: transparent; border-radius: var(--radius-full); corner-shape: round; animation: spin .7s linear infinite; }\n.visuallyHidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }\n@keyframes spin { to { transform: rotate(360deg); } }\n@media (prefers-reduced-motion: reduce) { .button { transition: none; } .spinner { animation: none; } }\n"},{"path":"registry/components/text-morph/text-morph.tsx","type":"registry:component","target":"@components/silicon-ui/text-morph/text-morph.tsx","content":"\"use client\";\nimport { useEffect, useLayoutEffect, useMemo, useRef, useState } from \"react\";\nimport { AnimatePresence, animate, motion, useReducedMotion, type AnimationPlaybackControls } from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./text-morph.module.css\";\n\n/**\n * Morphs one short label into the next in place. Letters both strings share glide to their new positions, new letters sharpen in, removed letters blur away,\n * and the width follows on a spring so the surrounding layout glides instead of jumping. Use it for status words that change a few letters at a time,\n * such as Publish, Publishing, and Published, or Follow and Following. It stays on one line, and assistive technology reads the plain text.\n */\nexport interface TextMorphProps { children: string; as?: \"span\" | \"div\" | \"p\" | \"strong\" | \"h1\" | \"h2\" | \"h3\"; className?: string; id?: string }\n\nconst enter = [...motionTokens.ease.enter] as [number, number, number, number];\nconst standard = [...motionTokens.ease.standard] as [number, number, number, number];\nconst blurred = `blur(${motionTokens.blur.soft}px)`;\n/** Lets leaving letters start to clear before the first new letter sharpens in their place. */\nconst HANDOFF = .05;\n/** Letters match by character and occurrence, so the second \"i\" in one string pairs with the second \"i\" in the next. */\nfunction toGlyphs(text: string) {\n  const seen = new Map<string, number>();\n  return Array.from(text).map(char => { const count = seen.get(char) ?? 0; seen.set(char, count + 1); return { char, key: `${char}-${count}` }; });\n}\nconst measure = (element: HTMLElement) => parseFloat(getComputedStyle(element).width);\n\nexport function TextMorph({ children, as = \"span\", className, id }: TextMorphProps) {\n  const Tag = as;\n  const reduced = useReducedMotion();\n  const frame = useRef<HTMLSpanElement>(null);\n  const track = useRef<HTMLSpanElement>(null);\n  const width = useRef(0);\n  const sizing = useRef<AnimationPlaybackControls | null>(null);\n  const glyphs = useMemo(() => toGlyphs(children), [children]);\n  // The previous label tells which letters are new, so only they stagger, in reading order, however far along the word they sit.\n  const [labels, setLabels] = useState({ current: children, previous: children });\n  if (labels.current !== children) setLabels({ current: children, previous: labels.current });\n  const kept = useMemo(() => new Set(toGlyphs(labels.previous).map(glyph => glyph.key)), [labels.previous]);\n  let entering = 0;\n\n  // The frame holds an explicit width so a new label never snaps it; the spring then follows the track, which already has the new letters in place.\n  useLayoutEffect(() => {\n    const frameElement = frame.current, trackElement = track.current;\n    if (!frameElement || !trackElement) return;\n    const next = measure(trackElement);\n    if (!Number.isFinite(next)) return;\n    if (width.current && Math.abs(next - width.current) > .5 && !reduced) sizing.current = animate(frameElement, { width: next }, motionTokens.spring.morph);\n    else { sizing.current?.stop(); frameElement.style.width = `${next}px`; }\n    width.current = next;\n  }, [children, reduced]);\n  // Font loading or a responsive font size changes the width without a new label: follow it immediately.\n  useEffect(() => {\n    const frameElement = frame.current, trackElement = track.current;\n    if (!frameElement || !trackElement || typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(() => {\n      const next = measure(trackElement);\n      if (!Number.isFinite(next) || Math.abs(next - width.current) < .5) return;\n      sizing.current?.stop(); frameElement.style.width = `${next}px`; width.current = next;\n    });\n    observer.observe(trackElement);\n    return () => observer.disconnect();\n  }, []);\n\n  return <Tag id={id} className={className}>\n    <span className={styles.srOnly}>{children}</span>\n    <span ref={frame} className={styles.frame} aria-hidden=\"true\"><span ref={track} className={styles.track}>\n      <AnimatePresence mode=\"popLayout\" initial={false}>\n        {glyphs.map(({ char, key }) => { const order = kept.has(key) ? 0 : entering++; return <motion.span key={key} layout=\"position\" className={styles.glyph}\n          initial={reduced ? false : { opacity: 0, scale: .8, y: \"0.14em\", filter: blurred }}\n          animate={{ opacity: 1, scale: 1, y: \"0em\", filter: \"blur(0px)\" }}\n          exit={reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, scale: .86, y: \"-0.1em\", filter: blurred, transition: { duration: motionTokens.duration.exit, ease: standard } }}\n          transition={reduced ? { duration: 0 } : { layout: motionTokens.spring.morph, default: { duration: motionTokens.duration.standard + .04, ease: enter, delay: HANDOFF + Math.min(order, 8) * motionTokens.stagger.char * 2 } }}>\n          {char === \" \" ? \"\\u00a0\" : char}\n        </motion.span>; })}\n      </AnimatePresence>\n    </span></span>\n  </Tag>;\n}\n\nexport default TextMorph;\n"},{"path":"registry/components/text-morph/text-morph.module.css","type":"registry:component","target":"@components/silicon-ui/text-morph/text-morph.module.css","content":"/* The frame carries the animated width. Its mask reveals new letters through a soft trailing edge as it grows and fades leaving ones as it shrinks.\n   Padding with matching negative margins gives the mask room for blur and descenders without changing layout; the edge sits entirely in that room, so resting text is never dimmed.\n   The track always starts at the frame's leading edge, even in centered text: otherwise it would recenter inside the old width and leaving letters would jump. */\n.frame.frame, .frame { position: relative; display: inline-block; box-sizing: content-box; padding: .5em .5em .5em .2em; margin: -.5em -.5em -.5em -.2em; text-align: start; white-space: nowrap; mask-image: linear-gradient(90deg, #000 calc(100% - .5em), transparent); }\n/* The track sizes to the letters in flow. Leaving letters are taken out of flow and positioned against it. */\n.track { position: relative; display: inline-block; white-space: nowrap; }\n.glyph { display: inline-block; white-space: pre; transform-origin: 50% 60%; }\n.srOnly { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; user-select: none; }\n"}],"docs":"Docs and live preview: https://ui.teamofsilicons.com/action-button","meta":{"tier":"free","kind":"component","docs":"https://ui.teamofsilicons.com/action-button","markdown":"https://ui.teamofsilicons.com/action-button.md","tags":["action","toolbar"],"status":"stable"}}