Sound·11.3
Cue Button
Press, release, and the outcome, each with its own sound.
Press, drag off, and let go: no release cue. Every third save fails.
Install
2 dependencies. The component is copied into your project, so the file is yours after that.
bun add @yugo/sound motionOr let the shadcn CLI do the copying: same file, landing in components/yugo.
bunx shadcn@latest add https://yugo.click/r/cue-button.jsonUsage
"use client";
import { CueButton, useCueButton } from "@/components/yugo/cue-button";
export function PublishButton({ id }: { id: string }) {
return <CueButton onPress={() => publish(id)}>Publish</CueButton>;
}
export function IconAction({ onRemove }: { onRemove: () => Promise<void> }) {
const { bind, held, state } = useCueButton({
onPress: onRemove,
cues: { press: "press", release: "release", error: "error" },
pendingDelay: 400,
});
return (
<button {...bind} data-held={held} aria-busy={state === "pending"}>
<TrashIcon />
</button>
);
}Source
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
import { play, type SoundName } from "@yugo/sound";
const DEPTH = {
type: "spring",
stiffness: 760,
damping: 44,
mass: 0.45,
} as const;
const SWAP = {
type: "spring",
stiffness: 380,
damping: 34,
mass: 0.6,
} as const;
const INSTANT = { duration: 0 } as const;
const MOVE_TOLERANCE = 8;
const PENDING_DELAY = 260;
const SETTLE_MS = 1400;
const DEFAULT_CUES = {
press: "press",
release: "release",
pending: "loading",
success: "success",
error: "error",
} as const;
export type CueSet = {
press?: SoundName | null;
release?: SoundName | null;
pending?: SoundName | null;
success?: SoundName | null;
error?: SoundName | null;
};
export type CueButtonState = "idle" | "pending" | "done" | "failed";
export type UseCueButtonOptions = {
onPress?: () => unknown;
cues?: CueSet;
moveTolerance?: number;
pendingDelay?: number;
settleAfter?: number;
disabled?: boolean;
};
export function useCueButton({
onPress,
cues,
moveTolerance = MOVE_TOLERANCE,
pendingDelay = PENDING_DELAY,
settleAfter = SETTLE_MS,
disabled = false,
}: UseCueButtonOptions = {}) {
const set = { ...DEFAULT_CUES, ...cues };
const [held, setHeld] = useState(false);
const [state, setState] = useState<CueButtonState>("idle");
const holding = useRef(false);
const origin = useRef({ x: 0, y: 0 });
const pendingTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const settleTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const run = useRef(0);
const cue = useRef(set);
cue.current = set;
const handler = useRef(onPress);
handler.current = onPress;
const abandon = useCallback(() => {
if (!holding.current) return;
holding.current = false;
setHeld(false);
}, []);
const lift = useCallback(() => {
if (!holding.current) return;
holding.current = false;
setHeld(false);
if (cue.current.release) play(cue.current.release);
}, []);
useEffect(() => {
const onBlur = () => abandon();
const onVisibility = () => {
if (document.visibilityState === "hidden") abandon();
};
window.addEventListener("blur", onBlur);
document.addEventListener("visibilitychange", onVisibility);
return () => {
window.removeEventListener("blur", onBlur);
document.removeEventListener("visibilitychange", onVisibility);
};
}, [abandon]);
useEffect(
() => () => {
if (pendingTimer.current) clearTimeout(pendingTimer.current);
if (settleTimer.current) clearTimeout(settleTimer.current);
},
[],
);
const activate = useCallback(() => {
if (disabled) return;
const result = handler.current?.();
if (!(result instanceof Promise)) {
if (cue.current.success) play(cue.current.success);
return;
}
const ticket = ++run.current;
setState("pending");
if (settleTimer.current) clearTimeout(settleTimer.current);
if (cue.current.pending) {
pendingTimer.current = setTimeout(() => {
if (run.current === ticket && cue.current.pending) play(cue.current.pending);
}, pendingDelay);
}
const finish = (next: CueButtonState, sound: SoundName | null | undefined) => {
if (run.current !== ticket) return;
if (pendingTimer.current) clearTimeout(pendingTimer.current);
setState(next);
if (sound) play(sound);
settleTimer.current = setTimeout(() => {
if (run.current === ticket) setState("idle");
}, settleAfter);
};
result.then(
() => finish("done", cue.current.success),
() => finish("failed", cue.current.error),
);
}, [disabled, pendingDelay, settleAfter]);
const bind = {
onPointerDown: (event: React.PointerEvent<HTMLElement>) => {
if (disabled) return;
if (event.pointerType === "mouse" && event.button !== 0) return;
event.currentTarget.setPointerCapture?.(event.pointerId);
holding.current = true;
origin.current = { x: event.clientX, y: event.clientY };
setHeld(true);
if (cue.current.press) play(cue.current.press);
},
onPointerMove: (event: React.PointerEvent<HTMLElement>) => {
if (!holding.current) return;
const dx = event.clientX - origin.current.x;
const dy = event.clientY - origin.current.y;
if (Math.hypot(dx, dy) > moveTolerance) abandon();
},
onPointerUp: lift,
onPointerCancel: abandon,
onLostPointerCapture: abandon,
onKeyDown: (event: React.KeyboardEvent<HTMLElement>) => {
if (disabled) return;
if (event.key === "Escape") {
abandon();
return;
}
if (event.key !== " " && event.key !== "Enter") return;
if (event.repeat || holding.current) return;
holding.current = true;
setHeld(true);
if (cue.current.press) play(cue.current.press);
},
onKeyUp: (event: React.KeyboardEvent<HTMLElement>) => {
if (event.key !== " " && event.key !== "Enter") return;
lift();
},
onClick: activate,
};
return { bind, held, state, pending: state === "pending", activate };
}
function SpinnerIcon() {
return (
<svg viewBox="0 0 16 16" width="13" height="13" fill="none" aria-hidden>
<circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="2" opacity="0.28" />
<path
d="M14 8a6 6 0 0 0-6-6"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
);
}
function CheckIcon() {
return (
<svg viewBox="0 0 16 16" width="13" height="13" fill="none" aria-hidden>
<path
d="m3.6 8.4 2.9 2.9 5.9-6"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function CrossIcon() {
return (
<svg viewBox="0 0 16 16" width="13" height="13" fill="none" aria-hidden>
<path
d="m4.4 4.4 7.2 7.2M11.6 4.4l-7.2 7.2"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
);
}
export type CueButtonProps = {
children: React.ReactNode;
onPress?: () => unknown;
cues?: CueSet;
moveTolerance?: number;
pendingDelay?: number;
settleAfter?: number;
disabled?: boolean;
className?: string;
};
export function CueButton({
children,
onPress,
cues,
moveTolerance = MOVE_TOLERANCE,
pendingDelay = PENDING_DELAY,
settleAfter = SETTLE_MS,
disabled = false,
className = "",
}: CueButtonProps) {
const reduced = useReducedMotion();
const { bind, held, state } = useCueButton({
onPress,
cues,
moveTolerance,
pendingDelay,
settleAfter,
disabled,
});
const mark =
state === "pending" ? (
<motion.span
aria-hidden
animate={reduced ? undefined : { rotate: 360 }}
transition={
reduced ? INSTANT : { duration: 0.9, ease: "linear", repeat: Infinity }
}
className="flex"
>
<SpinnerIcon />
</motion.span>
) : state === "done" ? (
<CheckIcon />
) : state === "failed" ? (
<CrossIcon />
) : null;
return (
<motion.button
type="button"
{...bind}
disabled={disabled}
aria-busy={state === "pending"}
initial={false}
animate={{ scale: held && !reduced ? 0.97 : 1 }}
transition={reduced ? INSTANT : DEPTH}
style={{ touchAction: "manipulation" }}
className={`inline-flex h-[38px] select-none items-center gap-2 rounded-[10px] bg-stone-900 pl-4 pr-3.5 text-[13px] font-medium text-white outline-none disabled:pointer-events-none disabled:opacity-50 focus-visible:shadow-[0_0_0_2px_#4568FF] dark:bg-stone-100 dark:text-stone-900 dark:focus-visible:shadow-[0_0_0_2px_#93B0FF] ${className}`}
>
<span>{children}</span>
<span className="grid size-[14px] shrink-0 place-items-center">
<motion.span
key={state}
initial={reduced ? false : { opacity: 0, scale: 0.7 }}
animate={{ opacity: 1, scale: 1 }}
transition={reduced ? INSTANT : SWAP}
className="col-start-1 row-start-1 flex"
style={{
color:
state === "failed"
? "#F5897F"
: state === "done"
? "#5BD79C"
: "currentColor",
}}
>
{mark}
</motion.span>
</span>
<span aria-live="polite" className="sr-only">
{state === "pending"
? "Working"
: state === "done"
? "Done"
: state === "failed"
? "Failed"
: ""}
</span>
</motion.button>
);
}Props
childrenReact.ReactNodeThe label. It stays put through every state; the status mark has its own reserved slot.
onPress() => unknownRuns on activation. Return a promise to get the pending, success and error cues; return anything else and the success cue fires immediately.
cuespress/release/loading/success/errorCueSetPer-slot overrides. Any slot set to null goes silent without disabling the others.
moveTolerance8numberPixels of drift before the press is treated as a scroll and the release cue is withheld.
pendingDelay260numberHow long work has to run before it earns a loading cue. Anything faster gets its outcome cue only.
settleAfter1400numberMilliseconds the done or failed mark is held before the button returns to idle.
disabledfalsebooleanRefuses to press, and makes no sound doing it.
className""stringAppended last to the button, so a caller's surface and radius win.