yugodesign

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.

cue-button

Install

2 dependencies. The component is copied into your project, so the file is yours after that.

terminal
bun add @yugo/sound motion

Or let the shadcn CLI do the copying: same file, landing in components/yugo.

terminal
bunx shadcn@latest add https://yugo.click/r/cue-button.json

Usage

stats.tsx
"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

components/yugo/cue-button.tsx
"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

children
React.ReactNode

The label. It stays put through every state; the status mark has its own reserved slot.

onPress
() => unknown

Runs 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/error
CueSet

Per-slot overrides. Any slot set to null goes silent without disabling the others.

moveTolerance8
number

Pixels of drift before the press is treated as a scroll and the release cue is withheld.

pendingDelay260
number

How long work has to run before it earns a loading cue. Anything faster gets its outcome cue only.

settleAfter1400
number

Milliseconds the done or failed mark is held before the button returns to idle.

disabledfalse
boolean

Refuses to press, and makes no sound doing it.

className""
string

Appended last to the button, so a caller's surface and radius win.