Skip to content

A card stack you throw left or right, with undo and matching buttons.

Gestures & dragmotion@base-ui/react

01Preview

Expense claims

0 of 5 reviewed
MC

Maya Chen

12 Sep

Lodging

Hotel Lumen, Berlin

€412.80

2 nights for the client workshop

lumen-folio-0912.pdf

02Install

Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:

npm install motion @base-ui/react

03Usage

import { SwipeDeck } from "@/components/ui/swipe-deck";

<SwipeDeck
  aria-label="Expense claims to review"
  items={claims}
  getKey={(c) => c.id}
  getLabel={(c) => c.merchant}
  left={{ label: "Reject", tone: "danger" }}
  right={{ label: "Approve", tone: "success" }}
  onSwipe={(claim, direction) => review(claim.id, direction === "right")}
  renderCard={(claim) => <ClaimCard claim={claim} />}
  empty={<p>All claims reviewed</p>}
/>

04Source

"use client";
import { Tooltip } from "@base-ui/react/tooltip";
import { AnimatePresence, animate, motion, useMotionValue, useReducedMotion, useTransform, type AnimationPlaybackControls, type MotionValue } from "motion/react";
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/cn";
import { Check, Undo, X } from "@/lib/icons";
import { ease, spring } from "@/lib/motion";
import { useControllableState } from "@/lib/use-controllable-state";

export type SwipeDirection = "left" | "right";
export type SwipeDeckTone = "neutral" | "danger" | "success" | "warning" | "info";
export type SwipeDeckChoice = { label: string; icon?: React.ReactNode; tone?: SwipeDeckTone };

export type SwipeDeckProps<T> = Omit<React.ComponentProps<"div">, "children" | "onChange"> & {
  items: T[];
  getKey: (item: T) => string;
  renderCard: (item: T, state: { top: boolean }) => React.ReactNode;
  /** Names a card in announcements: "Approved Hotel Lumen. 4 left". */
  getLabel?: (item: T) => string;
  /** Position of the top card in `items`. */
  index?: number;
  defaultIndex?: number;
  onIndexChange?: (index: number) => void;
  onSwipe?: (item: T, direction: SwipeDirection) => void;
  onUndo?: (item: T, direction: SwipeDirection) => void;
  left?: SwipeDeckChoice;
  right?: SwipeDeckChoice;
  /** Shown in the deck's place once every card is decided. */
  empty?: React.ReactNode;
  /** The reject, undo and approve buttons under the deck. */
  controls?: boolean;
  disabled?: boolean;
  /** Height of the card area in px. Cards fill it. */
  height?: number;
  /** Classes for the card area. */
  deckClassName?: string;
};

type Exit<T> = { id: string; key: string; item: T; dir: SwipeDirection; from: { x: number; y: number; rotate: number }; vx: number; vy: number; how: "gesture" | "pointer" | "keyboard" };

const tones: Record<SwipeDeckTone, { text: string; soft: string; stamp: string }> = {
  neutral: { text: "text-fg", soft: "bg-fg/[0.08]", stamp: "border-fg/25 bg-raised text-fg" },
  danger: { text: "text-danger", soft: "bg-danger-soft", stamp: "border-danger/40 bg-raised text-danger" },
  success: { text: "text-success", soft: "bg-success-soft", stamp: "border-success/40 bg-raised text-success" },
  warning: { text: "text-warning", soft: "bg-warning-soft", stamp: "border-warning/40 bg-raised text-warning" },
  info: { text: "text-info", soft: "bg-info-soft", stamp: "border-info/40 bg-raised text-info" },
};

const THROW = 0.34; // of the deck's width
const PEEK = 10; // px of each card showing under the one above
const clamp = (v: number, lo = 0, hi = 1) => Math.min(hi, Math.max(lo, v));
const back = { type: "spring", stiffness: 380, damping: 30, mass: 0.9 } as const;

export function SwipeDeck<T>({
  items,
  getKey,
  renderCard,
  getLabel,
  index: indexProp,
  defaultIndex = 0,
  onIndexChange,
  onSwipe,
  onUndo,
  left = { label: "Skip", tone: "neutral" },
  right = { label: "Keep", tone: "neutral" },
  empty,
  controls = true,
  disabled = false,
  className,
  deckClassName,
  height = 380,
  onKeyDown,
  "aria-label": ariaLabel = "Cards",
  ...rest
}: SwipeDeckProps<T>) {
  const reduce = useReducedMotion();
  const [index, setIndex] = useControllableState({ value: indexProp, defaultValue: defaultIndex, onChange: onIndexChange });
  const [history, setHistory] = useState<{ index: number; dir: SwipeDirection }[]>([]);
  const [exiting, setExiting] = useState<Exit<T>[]>([]);
  const [announcement, setAnnouncement] = useState("");
  const [dragging, setDragging] = useState(false);
  const nonce = useRef(0);

  const deckRef = useRef<HTMLDivElement>(null);
  const width = useMotionValue(320);
  const x = useMotionValue(0);
  const y = useMotionValue(0);
  const lift = useMotionValue(1);
  // Which way the card tips: held by its top it swings like a door on the bottom hinge, held low it tips the other way.
  const grab = useMotionValue(1);
  // After a card leaves, the rest of the stack still owes this much of a step forward.
  const lag = useMotionValue(0);
  const enter = useMotionValue(1);
  const rotate = useTransform(() => (reduce ? 0 : (x.get() / width.get()) * 14 * grab.get()));
  const progress = useTransform(() => clamp(Math.abs(x.get()) / (width.get() * THROW)));
  const lean = useTransform(() => Math.sign(x.get()) * progress.get());
  const topY = useTransform(() => y.get() + PEEK * lag.get());
  const topScale = useTransform(() => lift.get() * (1 - 0.05 * lag.get()));
  const topOrigin = useTransform(() => (grab.get() > 0 ? "50% 110%" : "50% -10%"));

  const anims = useRef<AnimationPlaybackControls[]>([]);
  const g = useRef({ id: -1, x0: 0, y0: 0, bx: 0, by: 0, moved: false, samples: [] as { t: number; x: number; y: number }[] });
  const suppressClick = useRef(false);

  useEffect(() => {
    const el = deckRef.current;
    if (!el) return;
    const ro = new ResizeObserver(([e]) => width.set(e.contentRect.width || 320));
    ro.observe(el);
    return () => {
      ro.disconnect();
      anims.current.forEach((a) => a.stop());
    };
  }, [width]);

  const stop = () => {
    anims.current.forEach((a) => a.stop());
    anims.current = [];
  };
  const run = (...a: AnimationPlaybackControls[]) => anims.current.push(...a);

  const top = items[index];
  // Only decisions behind the current card can be undone; a reset from outside clears the way back.
  const undoable = history.filter((h) => h.index < index);
  const remaining = Math.max(0, items.length - index);
  const labelOf = (item: T) => getLabel?.(item) ?? "Card";
  const choice = (dir: SwipeDirection) => (dir === "left" ? left : right);

  function decide(dir: SwipeDirection, how: Exit<T>["how"], vx = 0, vy = 0) {
    if (!top || disabled) return;
    stop();
    const key = getKey(top);
    setExiting((list) => [...list, { id: `${key}:${nonce.current++}`, key, item: top, dir, from: { x: x.get(), y: y.get(), rotate: rotate.get() }, vx, vy, how }]);
    // The next card is already partway forward if the drag pulled it; it finishes the step from there.
    const owed = 1 - progress.get();
    if (reduce || how === "keyboard") lag.set(0);
    else {
      lag.set(owed);
      run(animate(lag, 0, spring.soft));
    }
    x.set(0);
    y.set(0);
    grab.set(1);
    lift.set(1);
    enter.set(1);
    setHistory([...undoable.slice(-49), { index, dir }]);
    setIndex(index + 1);
    onSwipe?.(top, dir);
    const rest = remaining - 1;
    setAnnouncement(`${choice(dir).label}: ${labelOf(top)}. ${rest ? `${rest} left` : "No cards left"}.`);
  }

  function undo() {
    const last = undoable[undoable.length - 1];
    if (!last || disabled) return;
    const item = items[last.index];
    if (item === undefined) return;
    stop();
    const key = getKey(item);
    setExiting((list) => list.filter((e) => e.key !== key));
    setHistory(undoable.slice(0, -1));
    setIndex(last.index);
    lag.set(0);
    grab.set(1);
    lift.set(1);
    // It comes back from the side it left by, straightening as it lands.
    if (reduce) {
      x.set(0);
      y.set(0);
      enter.set(0);
      run(animate(enter, 1, { duration: 0.15 }));
    } else {
      enter.set(1);
      x.set((last.dir === "right" ? 1 : -1) * width.get() * 1.15);
      y.set(-16);
      run(animate(x, 0, spring.soft), animate(y, 0, spring.soft));
    }
    onUndo?.(item, last.dir);
    setAnnouncement(`Restored ${labelOf(item)}.`);
  }

  // ---- pointer --------------------------------------------------------------------------

  function onPointerDown(e: React.PointerEvent<HTMLDivElement>) {
    if (disabled || (e.pointerType === "mouse" && e.button !== 0)) return;
    stop();
    lag.set(0);
    const rect = e.currentTarget.getBoundingClientRect();
    if (Math.abs(x.get()) < 2) grab.set(e.clientY - rect.top < rect.height * 0.6 ? 1 : -1);
    g.current = { id: e.pointerId, x0: e.clientX, y0: e.clientY, bx: x.get(), by: y.get(), moved: false, samples: [] };
    e.currentTarget.setPointerCapture(e.pointerId);
    if (!reduce) run(animate(lift, 1.02, spring.snappy));
  }

  function onPointerMove(e: React.PointerEvent<HTMLDivElement>) {
    const s = g.current;
    if (s.id !== e.pointerId) return;
    const dx = e.clientX - s.x0;
    const dy = e.clientY - s.y0;
    if (!s.moved) {
      if (Math.hypot(dx, dy) < 4) return;
      s.moved = true;
      setDragging(true);
    }
    x.set(s.bx + dx);
    // Vertical travel is damped: the deck is about left and right.
    y.set(s.by + dy * 0.3);
    s.samples.push({ t: performance.now(), x: x.get(), y: y.get() });
    if (s.samples.length > 10) s.samples.shift();
  }

  function onPointerEnd(e: React.PointerEvent<HTMLDivElement>) {
    const s = g.current;
    if (s.id !== e.pointerId) return;
    s.id = -1;
    setDragging(false);
    if (!s.moved) {
      if (!reduce) run(animate(lift, 1, spring.snappy));
      return;
    }
    suppressClick.current = true;
    const now = performance.now();
    const recent = s.samples.filter((p) => now - p.t < 100);
    let vx = 0;
    let vy = 0;
    if (recent.length > 1 && e.type !== "pointercancel") {
      const a = recent[0];
      const b = recent[recent.length - 1];
      const dt = Math.max(1, b.t - a.t);
      vx = ((b.x - a.x) / dt) * 1000;
      vy = ((b.y - a.y) / dt) * 1000;
    }
    const px = x.get();
    const far = Math.abs(px) > width.get() * THROW;
    const flung = Math.abs(vx) > 600 && Math.sign(vx) === Math.sign(px) && Math.abs(px) > 24;
    if (e.type !== "pointercancel" && (far || flung)) return decide(px > 0 ? "right" : "left", "gesture", vx, vy);
    // Not far enough: it springs home carrying the release speed, so a half-hearted flick settles, not snaps.
    if (reduce) {
      x.set(0);
      y.set(0);
      lift.set(1);
    } else run(animate(x, 0, { ...back, velocity: vx }), animate(y, 0, { ...back, velocity: vy }), animate(lift, 1, spring.snappy));
  }

  const visible = items.slice(index, index + 4);

  return (
    <div className={cn("flex w-full max-w-[320px] flex-col items-center gap-5", className)} {...rest}>
      <div
        ref={deckRef}
        tabIndex={disabled ? -1 : 0}
        role="group"
        aria-roledescription="card stack"
        aria-label={ariaLabel}
        aria-keyshortcuts="ArrowLeft ArrowRight Backspace"
        data-state={dragging ? "dragging" : top ? "idle" : "empty"}
        data-disabled={disabled || undefined}
        onKeyDown={(e) => {
          onKeyDown?.(e);
          if (e.defaultPrevented) return;
          if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
            e.preventDefault();
            e.stopPropagation();
            decide(e.key === "ArrowLeft" ? "left" : "right", "keyboard");
          } else if (e.key === "Backspace" || ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "z")) {
            e.preventDefault();
            e.stopPropagation();
            undo();
          }
        }}
        className={cn(
          "relative w-full rounded-2xl outline-none",
          "focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-4 focus-visible:outline-fg-3",
          deckClassName,
        )}
        style={{ height, marginBottom: PEEK * 2 }}
      >
        {/* Back to front, so the top card paints last. */}
        {visible
          .map((item, depth) => ({ item, depth }))
          .reverse()
          .map(({ item, depth }) =>
            depth === 0 ? (
              <motion.div
                key={getKey(item)}
                data-top=""
                style={{ x, y: topY, rotate, scale: topScale, opacity: enter, transformOrigin: topOrigin, zIndex: 10 }}
                onPointerDown={onPointerDown}
                onPointerMove={onPointerMove}
                onPointerUp={onPointerEnd}
                onPointerCancel={onPointerEnd}
                onDragStart={(e) => e.preventDefault()}
                onClickCapture={(e) => {
                  if (suppressClick.current) {
                    e.preventDefault();
                    e.stopPropagation();
                  }
                  suppressClick.current = false;
                }}
                className={cn(
                  "absolute inset-0 touch-pan-y select-none",
                  disabled ? "cursor-default" : dragging ? "cursor-grabbing" : "cursor-grab",
                )}
              >
                <CardFrame>
                  {renderCard(item, { top: true })}
                  <Stamp choice={right} side="right" lean={lean} />
                  <Stamp choice={left} side="left" lean={lean} />
                </CardFrame>
              </motion.div>
            ) : (
              <BackCard key={getKey(item)} depth={depth} progress={progress} lag={lag}>
                {renderCard(item, { top: false })}
              </BackCard>
            ),
          )}

        {exiting.map((ex) => (
          <ExitCard
            key={ex.id}
            exit={ex}
            width={width.get()}
            reduce={!!reduce}
            onDone={() => setExiting((list) => list.filter((e) => e.id !== ex.id))}
          >
            <CardFrame>
              {renderCard(ex.item, { top: false })}
              <Stamp choice={choice(ex.dir)} side={ex.dir} lean={ex.dir === "right" ? 1 : -1} />
            </CardFrame>
          </ExitCard>
        ))}

        <AnimatePresence initial={false}>
          {!top && empty && (
            <motion.div
              key="empty"
              initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96, filter: "blur(2px)" }}
              animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
              exit={{ opacity: 0, transition: { duration: 0.1 } }}
              transition={{ duration: 0.3, ease: ease.out, delay: reduce ? 0 : 0.12 }}
              className="absolute inset-0 grid place-items-center rounded-2xl border border-dashed border-line-2"
            >
              {empty}
            </motion.div>
          )}
        </AnimatePresence>
      </div>

      {controls && (
        <Tooltip.Provider delay={500}>
          <div className="flex items-center gap-4">
            <DeckButton label={left.label} shortcut="" tone={left.tone ?? "neutral"} size="lg" lean={lean} side="left" disabled={disabled || !top} onClick={() => decide("left", "pointer")}>
              {left.icon ?? <X size={18} />}
            </DeckButton>
            <DeckButton label="Undo" shortcut="" tone="neutral" size="sm" disabled={disabled || undoable.length === 0} onClick={undo}>
              <Undo />
            </DeckButton>
            <DeckButton label={right.label} shortcut="" tone={right.tone ?? "neutral"} size="lg" lean={lean} side="right" disabled={disabled || !top} onClick={() => decide("right", "pointer")}>
              {right.icon ?? <Check size={18} />}
            </DeckButton>
          </div>
        </Tooltip.Provider>
      )}

      <p role="status" aria-live="polite" className="sr-only">
        {announcement}
      </p>
    </div>
  );
}

/* -------------------------------------------------------------------------------------------------
 * Pieces
 * -----------------------------------------------------------------------------------------------*/

function CardFrame({ children }: { children: React.ReactNode }) {
  return <div className="relative h-full w-full overflow-hidden rounded-2xl border border-line-2 bg-raised shadow-pop">{children}</div>;
}

/** A card waiting its turn. It steps forward as the top card is dragged away. */
function BackCard({ depth, progress, lag, children }: { depth: number; progress: MotionValue<number>; lag: MotionValue<number>; children: React.ReactNode }) {
  const d = useTransform(() => Math.max(0, depth + lag.get() - progress.get()));
  const scale = useTransform(() => 1 - 0.05 * d.get());
  const y = useTransform(() => PEEK * d.get());
  const opacity = useTransform(() => {
    const v = d.get();
    return v <= 1 ? 1 : v <= 2 ? 1 - (v - 1) * 0.45 : 0.55 * clamp(3 - v);
  });
  return (
    <motion.div aria-hidden inert style={{ scale, y, opacity, zIndex: 10 - depth, transformOrigin: "50% 100%" }} className="pointer-events-none absolute inset-0">
      <CardFrame>{children}</CardFrame>
    </motion.div>
  );
}

function ExitCard<T>({ exit, width, reduce, onDone, children }: { exit: Exit<T>; width: number; reduce: boolean; onDone: () => void; children: React.ReactNode }) {
  const x = useMotionValue(exit.from.x);
  const y = useMotionValue(exit.from.y);
  const rotate = useMotionValue(exit.from.rotate);
  const opacity = useMotionValue(1);
  const done = useRef(onDone);

  useEffect(() => {
    done.current = onDone;
  });

  useEffect(() => {
    const sign = exit.dir === "right" ? 1 : -1;
    const to = sign * (width * 1.25 + 80);
    let a: AnimationPlaybackControls[];
    if (reduce) a = [animate(opacity, 0, { duration: 0.16 })];
    else if (exit.how === "gesture") {
      // Leaves along the throw, never slower than it was let go.
      const vx = sign * Math.max(Math.abs(exit.vx), 900);
      a = [
        animate(x, to, { type: "spring", stiffness: 120, damping: 20, velocity: vx, restDelta: 40 }),
        animate(y, exit.from.y + exit.vy * 0.12, { type: "spring", stiffness: 120, damping: 20, velocity: exit.vy }),
        animate(rotate, exit.from.rotate + sign * 10, { duration: 0.4, ease: ease.out }),
      ];
    } else {
      const d = exit.how === "keyboard" ? 0.2 : 0.34;
      a = [
        animate(x, to, { duration: d, ease: ease.in }),
        animate(y, [0, -18, 12], { duration: d, ease: "easeOut" }),
        animate(rotate, sign * 16, { duration: d, ease: ease.in }),
      ];
    }
    let live = true;
    a[0].finished.then(() => live && done.current());
    return () => {
      live = false;
      a.forEach((c) => c.stop());
    };
  }, [exit, width, reduce, x, y, rotate, opacity]);

  return (
    <motion.div aria-hidden inert style={{ x, y, rotate, opacity, zIndex: 20, transformOrigin: "50% 110%" }} className="pointer-events-none absolute inset-0">
      {children}
    </motion.div>
  );
}

/** The verdict, written on the card as it leans toward that side. */
function Stamp({ choice, side, lean }: { choice: SwipeDeckChoice; side: SwipeDirection; lean: MotionValue<number> | number }) {
  const t = tones[choice.tone ?? "neutral"];
  const from = (v: number) => clamp(((side === "right" ? v : -v) - 0.2) / 0.6);
  const value = typeof lean === "number" ? from(lean) : undefined;
  const mv = useTransform(() => (typeof lean === "number" ? from(lean) : from(lean.get())));
  const scale = useTransform(() => 0.9 + 0.1 * mv.get());
  return (
    <motion.span
      aria-hidden
      style={{ opacity: value ?? mv, scale: value !== undefined ? 1 : scale }}
      className={cn(
        "pointer-events-none absolute top-4 flex h-7 items-center gap-1.5 rounded-full border px-2.5 text-[12.5px] font-medium shadow-[var(--shadow)]",
        // It sits on the trailing edge, where it stays readable while the card travels.
        side === "right" ? "left-4 origin-left" : "right-4 origin-right",
        t.stamp,
      )}
    >
      <span className="grid size-3.5 place-items-center [&>svg]:size-3.5">{choice.icon ?? (side === "right" ? <Check /> : <X />)}</span>
      {choice.label}
    </motion.span>
  );
}

function DeckButton({
  label,
  shortcut,
  tone,
  size,
  lean,
  side,
  disabled,
  onClick,
  children,
}: {
  label: string;
  shortcut: string;
  tone: SwipeDeckTone;
  size: "sm" | "lg";
  lean?: MotionValue<number>;
  side?: SwipeDirection;
  disabled: boolean;
  onClick: () => void;
  children: React.ReactNode;
}) {
  const t = tones[tone];
  // The button a drag would press lights up as the card leans its way.
  const fill = useTransform(() => (lean && side ? clamp((side === "right" ? lean.get() : -lean.get()) * 1.1) : 0));
  const grow = useTransform(() => 1 + 0.08 * fill.get());
  return (
    <Tooltip.Root>
      <Tooltip.Trigger
        render={
          <button
            type="button"
            aria-label={label}
            aria-keyshortcuts={shortcut === "" ? "ArrowLeft" : shortcut === "" ? "ArrowRight" : "Backspace"}
            disabled={disabled}
            onClick={onClick}
            className={cn(
              "relative grid shrink-0 place-items-center overflow-hidden rounded-full border border-line-2 bg-raised shadow-[var(--shadow)] outline-none",
              "transition-[background-color,border-color,scale,opacity] duration-150 hover:border-fg-4 active:scale-[0.92] active:duration-75",
              "focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3",
              "disabled:pointer-events-none disabled:opacity-40",
              size === "lg" ? cn("size-12", t.text) : "size-9 text-fg-2 hover:text-fg",
            )}
          />
        }
      >
        {size === "lg" && <motion.span aria-hidden style={{ opacity: fill }} className={cn("absolute inset-0", t.soft)} />}
        <motion.span style={{ scale: grow }} className="relative grid place-items-center">
          {children}
        </motion.span>
      </Tooltip.Trigger>
      <Tooltip.Portal>
        <Tooltip.Positioner side="bottom" sideOffset={8} collisionPadding={8} className="z-(--z-tooltip)">
          <Tooltip.Popup
            className={cn(
              "flex origin-(--transform-origin) items-center gap-2 rounded-lg border border-line-2 bg-raised py-[3px] pl-2 pr-1 text-[12px] leading-5 text-fg shadow-pop",
              "transition-[opacity,scale] duration-150 ease-out-expo data-ending-style:duration-100",
              "data-starting-style:scale-96 data-starting-style:opacity-0 data-ending-style:opacity-0 data-instant:transition-none",
            )}
          >
            {label}
            <kbd className="inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-[5px] border border-line-2 bg-frame px-1 font-sans text-[11px] leading-none text-fg-2">{shortcut}</kbd>
          </Tooltip.Popup>
        </Tooltip.Positioner>
      </Tooltip.Portal>
    </Tooltip.Root>
  );
}

05Props

PropTypeDefaultDescription
items*T[]Every card, in order. The deck shows the one at index and the three behind it.
getKey*(item: T) => stringA stable key per card.
renderCard*(item: T, state: { top: boolean }) => ReactNodeThe card's contents. The deck draws the card surface around it.
getLabel(item: T) => stringNames a card in announcements, e.g. “Approve: Hotel Lumen. 4 left.”
indexnumberPosition of the top card, controlled. Setting it back to 0 starts over and clears the undo history.
defaultIndexnumber0Starting position, uncontrolled.
onIndexChange(index: number) => voidCalled after every decision and undo.
onSwipe(item: T, direction: "left" | "right") => voidCalled once per decision, whether it came from a throw, a button or an arrow key.
onUndo(item: T, direction: "left" | "right") => voidCalled with the card that came back and the way it had gone.
left{ label: string; icon?: ReactNode; tone?: Tone }{ label: "Skip" }The left choice: its stamp, button and announcement.
right{ label: string; icon?: ReactNode; tone?: Tone }{ label: "Keep" }The right choice.
emptyReactNodeShown in the deck's place once every card is decided.
controlsbooleantrueThe left, undo and right buttons under the deck.
heightnumber380Height of the card area in px.
disabledbooleanfalseIgnores drags, keys and buttons.
deckClassNamestringClasses for the card area.

06Notes

Behavior

  • A card goes when it's dragged past 34% of the deck's width, or flung faster than 600px/s in the direction it leans. Anything less springs home with the release velocity.
  • The card tips around a hinge below it when held high and above it when held low, so it turns the way the hand does. Vertical travel is damped to 30%.
  • Throws are fire-and-forget: the next card is live immediately, so rapid decisions never wait on an animation. Undo takes back up to 50 decisions.
  • Only horizontal swipes are claimed on touch (touch-action: pan-y), so the page still scrolls when a thumb drags vertically across the deck.

Motion

  • The card follows the pointer 1:1, lifts to 1.02 on the snappy spring and tilts up to 14° at the edge. Its stamp and the matching button fill in as it leans.
  • The cards behind step forward continuously with the drag (scale 0.95 and 10px per step). After a throw they finish on the soft spring from wherever the drag left them.
  • A released throw leaves along its own velocity (never under 900px/s). Button throws arc out in 340ms on the ease-in curve, arrow keys in 200ms. Undo flies the card back from the side it left by.
  • Reduced motion drops tilt, lift and travel: decided cards fade out in 160ms and an undone card fades back in.

Accessibility

  • The deck is a focusable group with a card stack role description. ArrowLeft and ArrowRight decide, Backspace or ⌘Z undoes.
  • Every decision and undo is announced in a polite live region with the card's name and how many are left.
  • The buttons carry the choice labels, tooltips with the matching key, and aria-keyshortcuts. Cards behind the top one are inert.