# Throw — Animated Input for React

> Slide-to-commit track and swipe-action rows. Throw: drag the knob (or arrow to 100% and press Enter) past the threshold and it snaps home, floods the track and turns into a check; the label shimmers and fades as the knob passes, early releases spring back. ThrowRow: swipe a list row to reveal leading/trailing actions with rubber-banding and scale-in icons; a full swipe past 60% fires the primary and the row collapses on a height spring. Slider semantics, arrow-key reveal, ref handles; tweens only under reduced motion.

- Page: https://crucibleui.com/components/inputs/throw
- Category: [inputs](https://crucibleui.com/components/inputs)
- Tier: free (full source)
- Engine: motion
- npm dependencies: motion

## Install

```bash
npx shadcn@latest add @crucible/throw
```

Registry JSON: https://crucibleui.com/r/throw.json

Import from `@/components/crucible/throw`.

## Props (`ThrowProps`)

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `label?` | `string` | `"Slide to confirm"` | Instruction text inside the track; also the slider's accessible name. |
| `committedLabel?` | `string` | `"Done"` | Text shown in the filled track once committed. |
| `onCommit?` | `() => void` | — | Fires once when the knob is thrown past the threshold (or Enter is pressed at the end). |
| `threshold?` | `number` | `0.85` | Fraction of the travel (0–1) the knob must pass on release to commit. |
| `accentColor?` | `string` | `"#f5f5f5"` | Fill colour behind the knob and of the committed track. Any CSS colour; hex gets auto-contrast ink. |
| `disabled?` | `boolean` | `false` | Disable dragging and keys. |
| `hint?` | `boolean` | `true` | Idle hint — the knob nudges forward every few seconds until first touched. |
| `paused?` | `boolean` | `false` | Freeze the shimmer and idle hint. |
| `className?` | `string` | — | — |
| `ref?` | `React.Ref<ThrowHandle>` | — | Imperative handle — `{ reset(), focus() }`. |

## Usage example

```tsx
"use client";

import * as React from "react";

import {
  Throw,
  ThrowRow,
  type ThrowAction,
  type ThrowHandle,
} from "@/components/crucible/throw";

const MAIL = [
  { id: "a", from: "Maya Okafor", initials: "MO", subject: "Notes from Thursday", preview: "Pulled the three open questions into one list…", time: "9:41" },
  { id: "b", from: "Build bot", initials: "BB", subject: "Nightly run passed", preview: "All 412 checks green in 6m 03s.", time: "8:15" },
  { id: "c", from: "Theo Lind", initials: "TL", subject: "Lunch on Friday?", preview: "There's a new noodle place two blocks over.", time: "Mon" },
];

const icon = (d: React.ReactNode) => (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round">
    {d}
  </svg>
);
const ARCHIVE = icon(
  <>
    <rect x="3" y="4" width="18" height="4" rx="1" />
    <path d="M5 8v11a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V8" />
    <path d="M10 12h4" />
  </>
);
const TRASH = icon(
  <>
    <path d="M4 7h16" />
    <path d="M9 7V4h6v3" />
    <path d="M6 7l1 13h10l1-13" />
  </>
);
const FLAG = icon(
  <>
    <path d="M5 21V4" />
    <path d="M5 4h11l-2 4 2 4H5" />
  </>
);

export default function ThrowDemo() {
  const throwRef = React.useRef<ThrowHandle>(null);
  const [rows, setRows] = React.useState(MAIL);
  const [status, setStatus] = React.useState("");
  const [flagged, setFlagged] = React.useState<string[]>([]);

  const remove = (id: string, verb: string, from: string) => {
    setRows((r) => r.filter((m) => m.id !== id));
    setStatus(`${verb} · ${from}`);
  };

  return (
    <div className="relative isolate flex h-full min-h-[360px] w-full items-center justify-center overflow-hidden rounded-xl bg-neutral-950">
      <div
        aria-hidden
        className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_center,rgba(255,255,255,0.035),transparent_70%)]"
      />
      <div className="relative z-10 flex w-full flex-col items-center gap-6 px-4 py-8 sm:px-6">
        <div className="flex w-full max-w-3xl flex-col items-center gap-8 md:flex-row md:items-center md:justify-center md:gap-12">
          <div className="flex w-full max-w-xs flex-col items-center gap-3">
            <Throw
              ref={throwRef}
              label="Slide to send"
              committedLabel="Sent"
              onCommit={() => {
                setStatus("Message sent");
                window.setTimeout(() => throwRef.current?.reset(), 1800);
              }}
            />
          </div>

          <div className="w-full max-w-sm overflow-hidden rounded-xl border border-white/[0.07] bg-neutral-900 shadow-[0_20px_40px_-24px_rgba(0,0,0,0.9)]">
            {rows.length === 0 ? (
              <div className="flex h-[216px] flex-col items-center justify-center gap-3 text-sm text-neutral-500">
                Inbox zero
                <button
                  type="button"
                  onClick={() => {
                    setRows(MAIL);
                    setStatus("");
                  }}
                  className="rounded-full border border-white/10 px-3 py-1 text-xs text-neutral-300 outline-none transition-colors hover:bg-white/5 focus-visible:ring-2 focus-visible:ring-white/60"
                >
                  Restore rows
                </button>
              </div>
            ) : (
              <div role="list" aria-label="Inbox">
                {rows.map((m, i) => {
                  const leading: ThrowAction = {
                    label: "Archive",
                    icon: ARCHIVE,
                    color: "#0369a1",
                    onAction: () => remove(m.id, "Archived", m.from),
                  };
                  const trailing: ThrowAction[] = [
                    { label: "Delete", icon: TRASH, color: "#be123c", onAction: () => remove(m.id, "Deleted", m.from) },
                    {
                      label: flagged.includes(m.id) ? "Unflag" : "Flag",
                      icon: FLAG,
                      color: "#3f3f46",
                      onAction: () =>
                        setFlagged((f) => (f.includes(m.id) ? f.filter((x) => x !== m.id) : [...f, m.id])),
                    },
                  ];
                  return (
                    <div role="listitem" key={m.id}>
                      <ThrowRow
                        leading={leading}
                        trailing={trailing}
                        peek={i === 0}
                        label={`${m.from}: ${m.subject}`}
                        className={i > 0 ? "border-t border-white/[0.06]" : undefined}
                      >
                        <div className="flex items-center gap-3 px-4 py-3">
                          <span
                            aria-hidden
                            className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-neutral-800 text-[11px] font-semibold text-neutral-300"
                          >
                            {m.initials}
                          </span>
                          <span className="flex min-w-0 flex-1 flex-col">
                            <span className="flex items-baseline justify-between gap-2">
                              <span className="flex items-center gap-1.5 truncate text-sm font-medium text-neutral-100">
                                {m.from}
                                {flagged.includes(m.id) && (
                                  <span aria-label="flagged" className="h-1.5 w-1.5 rounded-full bg-amber-400" />
                                )}
                              </span>
                              <span className="shrink-0 text-[11px] tabular-nums text-neutral-500">{m.time}</span>
                            </span>
                            <span className="truncate text-[13px] text-neutral-300">{m.subject}</span>
                            <span className="truncate text-xs text-neutral-500">{m.preview}</span>
                          </span>
                        </div>
                      </ThrowRow>
                    </div>
                  );
                })}
              </div>
            )}
          </div>
        </div>

        <p className="text-center text-sm text-neutral-500">Slide to commit — swipe a row.</p>
        <span role="status" className="h-4 text-xs text-neutral-400">
          {status}
        </span>
      </div>
    </div>
  );
}
```

## Accessibility & performance

Honors `prefers-reduced-motion` with a designed static frame or fade (never a blank box) and passes `className` through.

## Source (`throw.tsx`)

```tsx
"use client";

import {
  AnimatePresence,
  animate,
  motion,
  useInView,
  useMotionValue,
  useMotionValueEvent,
  useTransform,
  type AnimationPlaybackControls,
} from "motion/react";
import * as React from "react";

import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/hooks/use-reduced-motion";

/* -------------------------------------------------------------------------- */
/*                                   shared                                   */
/* -------------------------------------------------------------------------- */

const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));

/** Picks a legible ink (near-black or white) for text drawn on `hex`. */
function inkOn(color: string): string {
  const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(color.trim());
  if (!m) return "#0a0a0a";
  let h = m[1];
  if (h.length === 3) h = h.replace(/./g, (c) => c + c);
  const lin = (i: number) => {
    const c = parseInt(h.slice(i, i + 2), 16) / 255;
    return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
  };
  const L = 0.2126 * lin(0) + 0.7152 * lin(2) + 0.0722 * lin(4);
  // Contrast against black vs white — whichever wins.
  return (L + 0.05) / 0.05 >= 1.05 / (L + 0.05) ? "#0a0a0a" : "#ffffff";
}

const SPRING = { type: "spring", stiffness: 520, damping: 38, mass: 0.9 } as const;
const SOFT = { type: "spring", stiffness: 300, damping: 30 } as const;
const QUICK = { duration: 0.14, ease: "easeOut" } as const;

/* -------------------------------------------------------------------------- */
/*                                    Throw                                   */
/* -------------------------------------------------------------------------- */

/** Imperative handle for {@link Throw}. */
export interface ThrowHandle {
  /** Return the knob to the start and clear the committed state. */
  reset(): void;
  /** Move keyboard focus to the knob. */
  focus(): void;
}

export interface ThrowProps {
  /** Instruction text inside the track; also the slider's accessible name. @default "Slide to confirm" */
  label?: string;
  /** Text shown in the filled track once committed. @default "Done" */
  committedLabel?: string;
  /** Fires once when the knob is thrown past the threshold (or Enter is pressed at the end). */
  onCommit?: () => void;
  /** Fraction of the travel (0–1) the knob must pass on release to commit. @default 0.85 */
  threshold?: number;
  /** Fill colour behind the knob and of the committed track. Any CSS colour; hex gets auto-contrast ink. @default "#f5f5f5" */
  accentColor?: string;
  /** Disable dragging and keys. @default false */
  disabled?: boolean;
  /** Idle hint — the knob nudges forward every few seconds until first touched. @default true */
  hint?: boolean;
  /** Freeze the shimmer and idle hint. @default false */
  paused?: boolean;
  className?: string;
  /** Imperative handle — `{ reset(), focus() }`. */
  ref?: React.Ref<ThrowHandle>;
}

const KNOB = 48;
const PAD = 4;

/**
 * Throw — a slide-to-commit track. Drag the knob (or use the arrow keys) to the
 * end: past `threshold` on release it snaps home and commits, the track floods
 * with the accent, the knob turns into a check. Released early it springs back.
 * The instruction label shimmers and fades as the knob passes over it.
 *
 * Accessibility: the knob is a `role="slider"` (0–100, with valuetext);
 * Arrow/PageUp/PageDown/Home/End move it and Enter or Space commits once it sits
 * at 100%. Reduced motion drops the shimmer, springs and idle hint.
 */
export function Throw({
  label = "Slide to confirm",
  committedLabel = "Done",
  onCommit,
  threshold = 0.85,
  accentColor = "#f5f5f5",
  disabled = false,
  hint = true,
  paused = false,
  className,
  ref,
}: ThrowProps) {
  const reduced = useReducedMotion();
  const still = reduced || paused;
  const trackRef = React.useRef<HTMLDivElement>(null);
  const knobRef = React.useRef<HTMLDivElement>(null);
  const inView = useInView(trackRef, { amount: 0.4 });
  const descId = React.useId();

  const x = useMotionValue(0);
  const max = useMotionValue(0);
  const progress = useTransform([x, max], ([a, b]: number[]) => (b > 0 ? clamp(a / b, 0, 1) : 0));
  const fillW = useTransform(x, (v) => v + KNOB);
  const labelOpacity = useTransform(progress, [0, 0.55], [1, 0]);
  const labelShift = useTransform(progress, [0, 1], [0, 18]);

  const [committed, setCommitted] = React.useState(false);
  const [pct, setPct] = React.useState(0);
  const [dragging, setDragging] = React.useState(false);
  const [armed, setArmed] = React.useState(false);
  const [flash, setFlash] = React.useState(0);

  const anim = React.useRef<AnimationPlaybackControls | null>(null);
  const touched = React.useRef(false);
  const drag = React.useRef<{ id: number; sx: number; start: number } | null>(null);
  const committedRef = React.useRef(false);
  const onCommitRef = React.useRef(onCommit);
  React.useEffect(() => {
    onCommitRef.current = onCommit;
  });

  const run = React.useCallback(
    (to: number | number[], t: object) => {
      anim.current?.stop();
      anim.current = animate(x, to as number, t);
    },
    [x]
  );

  // Track width → travel. A committed knob stays pinned to the end on resize.
  React.useLayoutEffect(() => {
    const el = trackRef.current;
    if (!el) return;
    const measure = () => {
      const m = Math.max(0, el.clientWidth - KNOB - PAD * 2);
      max.set(m);
      if (committedRef.current) x.set(m);
    };
    measure();
    const ro = new ResizeObserver(measure);
    ro.observe(el);
    return () => ro.disconnect();
  }, [max, x]);

  useMotionValueEvent(progress, "change", (p) => {
    if (drag.current) setArmed(p >= threshold);
  });

  const commit = React.useCallback(() => {
    if (committedRef.current) return;
    committedRef.current = true;
    touched.current = true;
    setCommitted(true);
    setArmed(false);
    setPct(100);
    setFlash((n) => n + 1);
    run(max.get(), still ? QUICK : SOFT);
    onCommitRef.current?.();
  }, [max, run, still]);

  const goTo = React.useCallback(
    (p: number) => {
      const next = clamp(p, 0, 1);
      setPct(Math.round(next * 100));
      run(next * max.get(), still ? QUICK : SPRING);
    },
    [max, run, still]
  );

  const reset = React.useCallback(() => {
    committedRef.current = false;
    setCommitted(false);
    setArmed(false);
    setPct(0);
    run(0, still ? QUICK : SOFT);
  }, [run, still]);

  React.useImperativeHandle(
    ref,
    () => ({ reset, focus: () => knobRef.current?.focus() }),
    [reset]
  );

  // Idle hint: a seeded-cadence nudge that teaches the gesture. Also serves as
  // the designed intro (first nudge lands shortly after the track enters view).
  React.useEffect(() => {
    if (!hint || still || disabled || committed || !inView) return;
    let timer = 0;
    const tease = () => {
      const el = knobRef.current;
      if (
        touched.current ||
        committedRef.current ||
        drag.current ||
        (el && document.activeElement === el)
      )
        return;
      const m = max.get();
      run([0, m * 0.24, 0], { duration: 1.15, times: [0, 0.38, 1], ease: ["easeOut", [0.5, 0, 0.2, 1]] });
      timer = window.setTimeout(tease, 4200);
    };
    timer = window.setTimeout(tease, 700);
    return () => window.clearTimeout(timer);
  }, [hint, still, disabled, committed, inView, max, run]);

  React.useEffect(() => () => anim.current?.stop(), []);

  /* ------------------------------- pointer -------------------------------- */

  const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
    if (disabled || committedRef.current || e.button !== 0) return;
    e.preventDefault();
    touched.current = true;
    anim.current?.stop();
    e.currentTarget.setPointerCapture(e.pointerId);
    e.currentTarget.focus({ preventScroll: true });
    drag.current = { id: e.pointerId, sx: e.clientX, start: x.get() };
    setDragging(true);
  };
  const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
    const d = drag.current;
    if (!d || d.id !== e.pointerId) return;
    const m = max.get();
    const nx = clamp(d.start + e.clientX - d.sx, 0, m);
    x.set(nx);
    setPct(m > 0 ? Math.round((nx / m) * 100) : 0);
  };
  const endDrag = (e: React.PointerEvent<HTMLDivElement>) => {
    const d = drag.current;
    if (!d || d.id !== e.pointerId) return;
    drag.current = null;
    setDragging(false);
    setArmed(false);
    if (progress.get() >= threshold) commit();
    else goTo(0);
  };

  /* ------------------------------ keyboard -------------------------------- */

  const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
    if (disabled) return;
    if (committedRef.current) return;
    const p = pct / 100;
    const big = e.shiftKey ? 0.25 : 0.1;
    let next: number | null = null;
    switch (e.key) {
      case "ArrowRight":
      case "ArrowUp":
        next = p + big;
        break;
      case "ArrowLeft":
      case "ArrowDown":
        next = p - big;
        break;
      case "PageUp":
        next = p + 0.25;
        break;
      case "PageDown":
        next = p - 0.25;
        break;
      case "Home":
        next = 0;
        break;
      case "End":
        next = 1;
        break;
      case "Enter":
      case " ":
        e.preventDefault();
        if (pct >= 100) commit();
        return;
      default:
        return;
    }
    e.preventDefault();
    touched.current = true;
    goTo(next);
  };

  // A half-thrown knob left by the keyboard slides home when focus leaves.
  const onBlur = () => {
    if (!committedRef.current && !drag.current && pct > 0) goTo(0);
  };

  const valueText = committed
    ? committedLabel
    : pct >= 100
      ? "At the end — press Enter to commit"
      : `${pct}%`;

  const ink = inkOn(accentColor);
  const tint = (a: number) => `color-mix(in srgb, ${accentColor} ${a}%, transparent)`;

  return (
    <div
      ref={trackRef}
      data-crucible="throw"
      data-committed={committed || undefined}
      className={cn(
        "relative isolate h-14 w-full max-w-xs touch-pan-y select-none rounded-full",
        "border border-white/[0.07] bg-neutral-900",
        "shadow-[inset_0_2px_6px_rgba(0,0,0,0.55),inset_0_-1px_0_rgba(255,255,255,0.04),0_1px_0_rgba(255,255,255,0.03)]",
        disabled && "opacity-50",
        className
      )}
    >
      {/* Instruction label — shimmers, then fades and drifts as the knob passes. */}
      <motion.span
        aria-hidden
        className="pointer-events-none absolute inset-0 flex items-center justify-center pl-12 pr-4 text-sm font-medium tracking-wide"
        style={{ opacity: labelOpacity, x: labelShift }}
      >
        <motion.span
          className="truncate"
          style={
            still
              ? { color: "#a3a3a3" }
              : {
                  color: "transparent",
                  backgroundImage:
                    "linear-gradient(105deg, #6b6b6b 0%, #6b6b6b 38%, #fafafa 50%, #6b6b6b 62%, #6b6b6b 100%)",
                  backgroundSize: "260% 100%",
                  WebkitBackgroundClip: "text",
                  backgroundClip: "text",
                }
          }
          animate={still || !inView ? undefined : { backgroundPosition: ["100% 0%", "0% 0%"] }}
          transition={{ duration: 2.6, ease: "linear", repeat: Infinity, repeatDelay: 0.4 }}
        >
          {label}
        </motion.span>
      </motion.span>

      {/* Fill — trails the knob; floods solid on commit. */}
      <motion.span
        aria-hidden
        className="pointer-events-none absolute rounded-full"
        style={{
          top: PAD - 1,
          bottom: PAD - 1,
          left: PAD - 1,
          width: committed ? `calc(100% - ${(PAD - 1) * 2}px)` : fillW,
          background: committed
            ? accentColor
            : `linear-gradient(90deg, ${tint(6)}, ${tint(armed ? 42 : 26)})`,
          boxShadow: committed ? `0 0 24px -6px ${tint(70)}` : `inset 0 0 0 1px ${tint(14)}`,
          transition: still ? "none" : "background 240ms ease, box-shadow 240ms ease",
        }}
      />

      {/* Committed label, inked for contrast on the accent. */}
      <AnimatePresence>
        {committed && (
          <motion.span
            aria-hidden
            key="done"
            className="pointer-events-none absolute inset-0 flex items-center justify-center pr-12 text-sm font-semibold tracking-wide"
            style={{ color: ink }}
            initial={still ? { opacity: 0 } : { opacity: 0, x: -10 }}
            animate={{ opacity: 1, x: 0 }}
            exit={{ opacity: 0 }}
            transition={still ? QUICK : { duration: 0.32, ease: "easeOut", delay: 0.08 }}
          >
            {committedLabel}
          </motion.span>
        )}
      </AnimatePresence>

      {/* One-shot ring flash on commit. */}
      {!still && flash > 0 && (
        <motion.span
          key={flash}
          aria-hidden
          className="pointer-events-none absolute -inset-px rounded-full"
          style={{ boxShadow: `0 0 0 1.5px ${accentColor}, 0 0 28px ${tint(55)}` }}
          initial={{ opacity: 0.9, scale: 1 }}
          animate={{ opacity: 0, scale: 1.06 }}
          transition={{ duration: 0.7, ease: "easeOut" }}
        />
      )}

      {/* Knob — the slider itself. */}
      <motion.div
        ref={knobRef}
        role="slider"
        tabIndex={disabled ? -1 : 0}
        aria-label={label}
        aria-describedby={descId}
        aria-valuemin={0}
        aria-valuemax={100}
        aria-valuenow={pct}
        aria-valuetext={valueText}
        aria-disabled={disabled || undefined}
        aria-readonly={committed || undefined}
        onPointerDown={onPointerDown}
        onPointerMove={onPointerMove}
        onPointerUp={endDrag}
        onPointerCancel={endDrag}
        onKeyDown={onKeyDown}
        onBlur={onBlur}
        className={cn(
          "absolute flex items-center justify-center rounded-full outline-none",
          "focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-950",
          disabled ? "cursor-not-allowed" : committed ? "cursor-default" : dragging ? "cursor-grabbing" : "cursor-grab"
        )}
        style={{
          top: PAD,
          left: PAD,
          width: KNOB,
          height: KNOB,
          x,
          touchAction: "none",
          background: committed
            ? "#0a0a0a"
            : "linear-gradient(180deg, #ffffff 0%, #e7e7e7 100%)",
          color: committed ? accentColor : "#171717",
          boxShadow: committed
            ? "0 1px 2px rgba(0,0,0,0.4), inset 0 0 0 1px rgba(255,255,255,0.08)"
            : "0 4px 14px -2px rgba(0,0,0,0.65), 0 1px 2px rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.9)",
        }}
        animate={{ scale: still ? 1 : dragging ? (armed ? 1.06 : 0.97) : 1 }}
        transition={still ? { duration: 0 } : SPRING}
      >
        <AnimatePresence initial={false} mode="popLayout">
          {committed ? (
            <motion.svg
              key="check"
              aria-hidden
              viewBox="0 0 24 24"
              fill="none"
              stroke="currentColor"
              strokeWidth="2.4"
              strokeLinecap="round"
              strokeLinejoin="round"
              className="h-5 w-5"
              initial={still ? { opacity: 0 } : { opacity: 0, scale: 0.4, rotate: -30 }}
              animate={{ opacity: 1, scale: 1, rotate: 0 }}
              exit={{ opacity: 0 }}
              transition={still ? QUICK : SPRING}
            >
              <motion.path
                d="M5 12.5l4.5 4.5L19 7.5"
                initial={still ? false : { pathLength: 0 }}
                animate={{ pathLength: 1 }}
                transition={still ? { duration: 0 } : { duration: 0.34, ease: "easeOut", delay: 0.06 }}
              />
            </motion.svg>
          ) : (
            <motion.svg
              key="arrow"
              aria-hidden
              viewBox="0 0 24 24"
              fill="none"
              stroke="currentColor"
              strokeWidth="2.2"
              strokeLinecap="round"
              strokeLinejoin="round"
              className="h-5 w-5"
              initial={still ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}
              animate={{ opacity: 1, scale: 1 }}
              exit={{ opacity: 0, scale: 0.6 }}
              transition={still ? QUICK : SPRING}
            >
              <path d="M6 7l5 5-5 5" opacity={0.45} />
              <path d="M12 7l5 5-5 5" />
            </motion.svg>
          )}
        </AnimatePresence>
      </motion.div>

      <span id={descId} className="sr-only">
        Drag the handle to the end, or use the arrow keys to reach 100% and press Enter.
      </span>
      <span role="status" aria-live="polite" className="sr-only">
        {committed ? committedLabel : ""}
      </span>
    </div>
  );
}

/* -------------------------------------------------------------------------- */
/*                                  ThrowRow                                  */
/* -------------------------------------------------------------------------- */

/** One swipe action. The first action on a side is that side's primary. */
export interface ThrowAction {
  /** Visible caption and accessible name. */
  label: string;
  /** Icon node (≈18px, uses `currentColor`). */
  icon?: React.ReactNode;
  /** Background colour of the action well. @default "#404040" (primary) / "#262626" */
  color?: string;
  /** Called when the action is chosen (after the row collapses, when it collapses). */
  onAction: () => void;
}

type Side = "leading" | "trailing";

/** Imperative handle for {@link ThrowRow}. */
export interface ThrowRowHandle {
  /** Slide a side's actions into view. */
  open(side: Side): void;
  /** Slide the row closed. */
  close(): void;
  /** Un-collapse a row whose action has fired and close it. */
  reset(): void;
}

export interface ThrowRowProps {
  /** Row content — the sliding foreground surface. */
  children: React.ReactNode;
  /** Actions revealed by swiping right (they sit on the left edge). */
  leading?: ThrowAction | ThrowAction[];
  /** Actions revealed by swiping left (they sit on the right edge). */
  trailing?: ThrowAction | ThrowAction[];
  /** A swipe past 60% of the row fires that side's primary action. @default true */
  fullSwipe?: boolean;
  /** Collapse the row (height spring) before firing an action. @default true */
  collapseOnAction?: boolean;
  /** Idle hint — the row briefly peeks its trailing (else leading) actions until touched. @default false */
  peek?: boolean;
  /** Accessible name for the row. Defaults to its text content. */
  label?: string;
  /** Class for the outer wrapper. */
  className?: string;
  /** Class for the sliding foreground surface. */
  contentClassName?: string;
  /** Imperative handle — `{ open(side), close(), reset() }`. */
  ref?: React.Ref<ThrowRowHandle>;
}

const ACTION_W = 76;
const FULL = 0.6;

const asList = (a?: ThrowAction | ThrowAction[]) => (a ? (Array.isArray(a) ? a : [a]) : []);

/** Exponential rubber band: grows quickly at first, saturates near `limit`. */
const rubber = (over: number, limit: number) => limit * (1 - Math.exp(-over / (limit * 1.6)));

/**
 * ThrowRow — a list row that slides to reveal actions. Swipe left for trailing
 * actions, right for leading ones; travel past the actions is rubber-banded,
 * the icons scale in as they're uncovered, and a full swipe past 60% fires the
 * side's primary action: the row flies out and collapses on a height spring.
 *
 * Keyboard: the row is focusable; ArrowLeft / ArrowRight reveal actions and
 * move focus into them (Enter activates), Escape or the opposite arrow closes.
 * Reduced motion: no rubber band or springs — short tweens only.
 */
export function ThrowRow({
  children,
  leading,
  trailing,
  fullSwipe = true,
  collapseOnAction = true,
  peek = false,
  label,
  className,
  contentClassName,
  ref,
}: ThrowRowProps) {
  const reduced = useReducedMotion();
  const lead = asList(leading);
  const trail = asList(trailing);
  const rootRef = React.useRef<HTMLDivElement>(null);
  const rowRef = React.useRef<HTMLDivElement>(null);
  const leadRef = React.useRef<HTMLDivElement>(null);
  const trailRef = React.useRef<HTMLDivElement>(null);
  const inView = useInView(rootRef, { amount: 0.6 });
  const descId = React.useId();

  const x = useMotionValue(0);
  const leadW = useTransform(x, (v) => Math.max(0, v));
  const trailW = useTransform(x, (v) => Math.max(0, -v));
  const leadIcon = useTransform(leadW, [0, ACTION_W * 0.9], [0.35, 1], { clamp: true });
  const trailIcon = useTransform(trailW, [0, ACTION_W * 0.9], [0.35, 1], { clamp: true });
  const leadOp = useTransform(leadW, [8, ACTION_W * 0.7], [0, 1], { clamp: true });
  const trailOp = useTransform(trailW, [8, ACTION_W * 0.7], [0, 1], { clamp: true });
  // Captions wait until each action has room for them (no cramped half-words).
  const leadN = useMotionValue(lead.length);
  const trailN = useMotionValue(trail.length);
  React.useEffect(() => {
    leadN.set(lead.length);
    trailN.set(trail.length);
  }, [lead.length, trail.length, leadN, trailN]);
  const cap = ([w, n]: number[]) =>
    clamp((w / Math.max(1, n) - ACTION_W * 0.62) / (ACTION_W * 0.3), 0, 1);
  const leadCap = useTransform([leadW, leadN], cap);
  const trailCap = useTransform([trailW, trailN], cap);

  const [open, setOpen] = React.useState<Side | null>(null);
  const [armed, setArmed] = React.useState<Side | null>(null);
  const [firing, setFiring] = React.useState<{ side: Side; index: number } | null>(null);
  const [collapsed, setCollapsed] = React.useState(false);

  const anim = React.useRef<AnimationPlaybackControls | null>(null);
  const touched = React.useRef(false);
  const dragged = React.useRef(false);
  const focusInto = React.useRef<Side | null>(null);
  const pending = React.useRef<ThrowAction | null>(null);
  const drag = React.useRef<{
    id: number;
    sx: number;
    sy: number;
    start: number;
    decided: boolean;
    w: number;
    lx: number;
    lt: number;
    v: number;
  } | null>(null);

  const actionsFor = (s: Side) => (s === "leading" ? lead : trail);
  const sign = (s: Side) => (s === "leading" ? 1 : -1);

  const run = React.useCallback(
    (to: number | number[], t: object) => {
      anim.current?.stop();
      anim.current = animate(x, to as number, t);
    },
    [x]
  );

  const close = React.useCallback(() => {
    setOpen(null);
    setArmed(null);
    run(0, reduced ? QUICK : SPRING);
  }, [reduced, run]);

  const openSide = React.useCallback(
    (s: Side) => {
      const n = (s === "leading" ? lead : trail).length;
      if (!n) return;
      setOpen(s);
      setArmed(null);
      run(sign(s) * n * ACTION_W, reduced ? QUICK : SPRING);
    },
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [lead.length, trail.length, reduced, run]
  );

  const fire = (s: Side, index: number) => {
    const action = actionsFor(s)[index];
    if (!action || firing) return;
    touched.current = true;
    setFiring({ side: s, index });
    setArmed(null);
    if (!collapseOnAction) {
      action.onAction();
      window.setTimeout(() => {
        setFiring(null);
        close();
      }, reduced ? 60 : 220);
      return;
    }
    pending.current = action;
    const w = rowRef.current?.offsetWidth ?? 320;
    run(sign(s) * w, {
      ...(reduced ? QUICK : { duration: 0.24, ease: [0.4, 0, 0.6, 1] }),
      onComplete: () => setCollapsed(true),
    });
  };

  const reset = React.useCallback(() => {
    pending.current = null;
    setFiring(null);
    setCollapsed(false);
    setOpen(null);
    setArmed(null);
    anim.current?.stop();
    x.set(0);
  }, [x]);

  React.useImperativeHandle(ref, () => ({ open: openSide, close, reset }), [openSide, close, reset]);

  // Focus the revealed panel's first action after a keyboard reveal.
  React.useEffect(() => {
    const s = focusInto.current;
    if (!s || open !== s) return;
    focusInto.current = null;
    (s === "leading" ? leadRef : trailRef).current?.querySelector<HTMLButtonElement>("button")?.focus();
  }, [open]);

  // Tap / press outside closes an open row.
  React.useEffect(() => {
    if (!open) return;
    const onDown = (e: PointerEvent) => {
      if (!rootRef.current?.contains(e.target as Node)) close();
    };
    document.addEventListener("pointerdown", onDown);
    return () => document.removeEventListener("pointerdown", onDown);
  }, [open, close]);

  // Idle peek — teaches that the row swipes.
  const peekSide: Side | null = trail.length ? "trailing" : lead.length ? "leading" : null;
  React.useEffect(() => {
    if (!peek || reduced || !inView || !peekSide || collapsed) return;
    let timer = 0;
    const go = () => {
      if (touched.current || drag.current || open || firing) return;
      const d = sign(peekSide) * ACTION_W * 0.85;
      run([0, d, d, 0], { duration: 1.6, times: [0, 0.3, 0.55, 1], ease: ["easeOut", "linear", [0.5, 0, 0.2, 1]] });
      timer = window.setTimeout(go, 6000);
    };
    timer = window.setTimeout(go, 1200);
    return () => window.clearTimeout(timer);
  }, [peek, reduced, inView, peekSide, collapsed, open, firing, run]);

  React.useEffect(() => () => anim.current?.stop(), []);

  /* ------------------------------- pointer -------------------------------- */

  const shape = (raw: number, w: number) => {
    const s: Side = raw >= 0 ? "leading" : "trailing";
    const n = actionsFor(s).length;
    const a = Math.abs(raw);
    if (!n) return reduced ? 0 : sign(s) * rubber(a, 28);
    const reach = fullSwipe ? w * 0.82 : n * ACTION_W;
    if (reduced) return sign(s) * Math.min(a, reach);
    return sign(s) * (a <= reach ? a : reach + rubber(a - reach, 40));
  };

  const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
    if (firing || e.button !== 0) return;
    anim.current?.stop();
    dragged.current = false;
    drag.current = {
      id: e.pointerId,
      sx: e.clientX,
      sy: e.clientY,
      start: x.get(),
      decided: false,
      w: rowRef.current?.offsetWidth ?? 320,
      lx: e.clientX,
      lt: e.timeStamp,
      v: 0,
    };
  };
  const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
    const d = drag.current;
    if (!d || d.id !== e.pointerId) return;
    const dx = e.clientX - d.sx;
    const dy = e.clientY - d.sy;
    if (!d.decided) {
      if (Math.abs(dx) < 6 && Math.abs(dy) < 6) return;
      if (Math.abs(dy) > Math.abs(dx)) {
        drag.current = null; // vertical — let the page scroll
        return;
      }
      d.decided = true;
      dragged.current = true;
      touched.current = true;
      e.currentTarget.setPointerCapture(e.pointerId);
    }
    const dt = Math.max(1, e.timeStamp - d.lt);
    d.v = d.v * 0.6 + ((e.clientX - d.lx) / dt) * 1000 * 0.4;
    d.lx = e.clientX;
    d.lt = e.timeStamp;
    const nx = shape(d.start + dx, d.w);
    x.set(nx);
    const s: Side = nx >= 0 ? "leading" : "trailing";
    const isArmed = fullSwipe && actionsFor(s).length > 0 && Math.abs(nx) > d.w * FULL;
    setArmed(isArmed ? s : null);
  };
  const onPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
    const d = drag.current;
    if (!d || d.id !== e.pointerId) return;
    drag.current = null;
    if (!d.decided) return;
    const cur = x.get();
    const s: Side = cur >= 0 ? "leading" : "trailing";
    const n = actionsFor(s).length;
    const a = Math.abs(cur);
    const toward = Math.sign(d.v) === sign(s);
    if (n && fullSwipe && a > d.w * FULL) return fire(s, 0);
    if (n && ((a > (n * ACTION_W) / 2 && !(Math.abs(d.v) > 500 && !toward)) || (toward && Math.abs(d.v) > 500)))
      return openSide(s);
    close();
  };

  /* ------------------------------ keyboard -------------------------------- */

  const onRowKey = (e: React.KeyboardEvent<HTMLDivElement>) => {
    if (e.target !== e.currentTarget || firing) return;
    if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
      e.preventDefault();
      touched.current = true;
      const s: Side = e.key === "ArrowLeft" ? "trailing" : "leading";
      if (open && open !== s) return close();
      if (!actionsFor(s).length) return;
      focusInto.current = s;
      if (open === s) {
        (s === "leading" ? leadRef : trailRef).current?.querySelector<HTMLButtonElement>("button")?.focus();
        focusInto.current = null;
      } else openSide(s);
    } else if (e.key === "Escape" && open) {
      e.preventDefault();
      close();
    }
  };

  const onActionKey = (s: Side) => (e: React.KeyboardEvent<HTMLButtonElement>) => {
    const closeKey = s === "trailing" ? "ArrowRight" : "ArrowLeft";
    const buttons = Array.from(
      (s === "leading" ? leadRef : trailRef).current?.querySelectorAll<HTMLButtonElement>("button") ?? []
    );
    const i = buttons.indexOf(e.currentTarget);
    if (e.key === "Escape") {
      e.preventDefault();
      close();
      rowRef.current?.focus();
    } else if (e.key === closeKey) {
      e.preventDefault();
      // Step back toward the content; past the first action, close.
      if (i > 0) buttons[i - 1].focus();
      else {
        close();
        rowRef.current?.focus();
      }
    } else if (e.key === (s === "trailing" ? "ArrowLeft" : "ArrowRight")) {
      e.preventDefault();
      buttons[i + 1]?.focus();
    }
  };

  /* -------------------------------- render -------------------------------- */

  const panel = (s: Side) => {
    const acts = actionsFor(s);
    if (!acts.length) return null;
    const isLead = s === "leading";
    const hot = firing?.side === s ? firing.index : armed === s ? 0 : -1;
    const visible = open === s;
    return (
      <motion.div
        ref={isLead ? leadRef : trailRef}
        inert={!visible}
        className={cn(
          "absolute inset-y-0 flex overflow-hidden",
          isLead ? "left-0 flex-row" : "right-0 flex-row-reverse"
        )}
        style={{
          width: isLead ? leadW : trailW,
          background: hot >= 0 ? (acts[hot].color ?? "#404040") : "#171717",
          transition: reduced ? "none" : "background 160ms ease",
        }}
      >
        {acts.map((a, i) => {
          const bg = a.color ?? (i === 0 ? "#404040" : "#262626");
          const grow = hot < 0 ? 1 : hot === i ? 1 : 0;
          return (
            <button
              key={a.label}
              type="button"
              tabIndex={visible ? 0 : -1}
              aria-label={a.label}
              onClick={() => fire(s, i)}
              onKeyDown={onActionKey(s)}
              className={cn(
                "relative flex min-w-0 basis-0 flex-col items-center justify-center gap-1 overflow-hidden px-1 text-[11px] font-medium outline-none",
                "focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/80"
              )}
              style={{
                background: bg,
                color: inkOn(bg),
                flexGrow: grow,
                transition: reduced ? "none" : "flex-grow 240ms cubic-bezier(.3,.7,.2,1)",
              }}
            >
              <motion.span
                className="flex w-[68px] flex-col items-center justify-center gap-1"
                style={{
                  scale: isLead ? leadIcon : trailIcon,
                  opacity: isLead ? leadOp : trailOp,
                }}
              >
                {a.icon && (
                  <motion.span
                    aria-hidden
                    className="flex h-5 w-5 items-center justify-center [&>svg]:h-[18px] [&>svg]:w-[18px]"
                    animate={{ scale: !reduced && hot === i ? 1.18 : 1 }}
                    transition={reduced ? { duration: 0 } : SPRING}
                  >
                    {a.icon}
                  </motion.span>
                )}
                <motion.span
                  className="truncate"
                  style={{ opacity: hot === i ? 1 : isLead ? leadCap : trailCap }}
                >
                  {a.label}
                </motion.span>
              </motion.span>
            </button>
          );
        })}
      </motion.div>
    );
  };

  const hasActions = lead.length + trail.length > 0;
  const hintText = [
    trail.length ? `Left arrow: ${trail.map((a) => a.label).join(", ")}` : null,
    lead.length ? `Right arrow: ${lead.map((a) => a.label).join(", ")}` : null,
  ]
    .filter(Boolean)
    .join(". ");

  return (
    <motion.div
      ref={rootRef}
      data-crucible="throw-row"
      className={cn("relative overflow-hidden", className)}
      initial={false}
      animate={collapsed ? { height: 0, opacity: 0 } : { height: "auto", opacity: 1 }}
      transition={reduced ? { duration: 0.12 } : { height: SOFT, opacity: { duration: 0.2 } }}
      onAnimationComplete={() => {
        if (!collapsed || !pending.current) return;
        const a = pending.current;
        pending.current = null;
        a.onAction();
      }}
    >
      {panel("leading")}
      {panel("trailing")}
      <motion.div
        ref={rowRef}
        tabIndex={collapsed ? -1 : 0}
        role="group"
        aria-roledescription="swipeable row"
        aria-label={label}
        aria-describedby={hasActions ? descId : undefined}
        onPointerDown={onPointerDown}
        onPointerMove={onPointerMove}
        onPointerUp={onPointerUp}
        onPointerCancel={onPointerUp}
        onClickCapture={(e) => {
          if (dragged.current) {
            e.preventDefault();
            e.stopPropagation();
            dragged.current = false;
          } else if (open) {
            e.stopPropagation();
            close();
          }
        }}
        onKeyDown={onRowKey}
        className={cn(
          "relative z-10 touch-pan-y select-none bg-neutral-900 outline-none",
          "focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60",
          contentClassName
        )}
        style={{ x }}
      >
        {children}
      </motion.div>
      {hasActions && (
        <span id={descId} className="sr-only">
          {hintText}. Escape closes.
        </span>
      )}
    </motion.div>
  );
}
```
