New: Activity Terrain +12 more

Tear Button

A confirm button you tear off like a ticket stub, hole by hole.

1 exampleFrom KokonutUI (MIT)

Installation

Run this in your project. It adds the files, installs the npm packages and anything this builds on, and sets up the theme:

npx shadcn@latest add https://kryptonite-ui.vercel.app/r/tear-button.json
Install manually
  1. Copy this into components/kokonut/tear-button.tsx:

    tear-button.tsx
    "use client";
    
    /**
     * @author: @dorianbaffier
     * @description: Tear Button
     * @version: 1.0.0
     * @date: 2026-10-14
     * @license: MIT
     * @website: https://kokonutui.com
     * @github: https://github.com/kokonut-labs/kokonutui
     */
    
    import {
      type KeyboardEvent,
      type PointerEvent,
      type ReactNode,
      useCallback,
      useEffect,
      useRef,
      useState,
    } from "react";
    import { cn } from "@/lib/utils";
    
    const HEIGHT = 68;
    const BODY_WIDTH = 236;
    const STUB_WIDTH = 76;
    // How far (deg) the stub swings, about the foot of the perforation, before
    // the last fibre gives.
    const TEAR_ANGLE = 34;
    // The perforation tears hole by hole: each one holds for a moment, then
    // gives. That stick-slip is what makes it feel like paper.
    const HOLES = 7;
    const HOLD = 0.55;
    const HOLD_SHARE = 0.3;
    // The stub's swing chases the hand on a stiff spring, so the slips snap.
    const SWING_STIFFNESS = 900;
    const SWING_DAMPING = 42;
    const RETURN_STIFFNESS = 520;
    const RETURN_DAMPING = 22;
    // Once free: px and seconds.
    const GRAVITY = 2600;
    const FALL_DISTANCE = 220;
    const STEP = 1 / 120;
    // Ragged edge of the tear, as offsets in px along the perforation.
    const FIBRES = [
      1.6, 0.4, 2.2, 0.9, 1.8, 0.2, 2.4, 1.1, 0.5, 2, 1.4, 0.3, 2.3, 0.8, 1.7, 0.1,
      2.1, 1.2, 0.6, 1.9,
    ];
    const EASE_OUT = "cubic-bezier(0.23, 1, 0.32, 1)";
    
    type Phase = "idle" | "tearing" | "flying" | "torn";
    
    function clamp(value: number, min: number, max: number) {
      return Math.min(Math.max(value, min), max);
    }
    
    function haptic(ms: number) {
      if (typeof navigator !== "undefined" && "vibrate" in navigator) {
        navigator.vibrate(ms);
      }
    }
    
    /** Raw pull (0..1+) to how far the tear has run, holding at each hole. */
    function stickSlip(pull: number) {
      const scaled = clamp(pull, 0, 1) * HOLES;
      const hole = Math.min(Math.floor(scaled), HOLES - 1);
      const f = scaled - hole;
      const given =
        f < HOLD
          ? (f / HOLD) * HOLD_SHARE
          : HOLD_SHARE + ((f - HOLD) / (1 - HOLD)) * (1 - HOLD_SHARE);
      return (hole + given) / HOLES;
    }
    
    /**
     * One side of the tear as a clip-path: ragged down to `tip` (px from the
     * top), straight below. `side` is 1 for the body's right edge, -1 for the
     * stub's left edge.
     */
    function tornEdge(tip: number, width: number, side: 1 | -1) {
      const points: string[] = [];
      const edge = side === 1 ? width : 0;
      const far = side === 1 ? 0 : width;
      points.push(`${far}px 0px`);
      const rows = Math.ceil((tip / HEIGHT) * FIBRES.length);
      for (let k = 0; k <= rows; k++) {
        const y = Math.min((k / FIBRES.length) * HEIGHT, tip);
        const x = edge - side * FIBRES[k % FIBRES.length];
        points.push(`${x.toFixed(1)}px ${y.toFixed(1)}px`);
      }
      points.push(`${edge}px ${tip.toFixed(1)}px`, `${edge}px ${HEIGHT}px`);
      points.push(`${far}px ${HEIGHT}px`);
      return `polygon(${points.join(",")})`;
    }
    
    // Ticket notches, cut where the perforation meets the top and bottom edges.
    const NOTCH = 7;
    const bodyMask = `radial-gradient(circle at 100% 0, #0000 ${NOTCH}px, #000 ${NOTCH + 0.5}px) top / 100% 51% no-repeat, radial-gradient(circle at 100% 100%, #0000 ${NOTCH}px, #000 ${NOTCH + 0.5}px) bottom / 100% 51% no-repeat`;
    const stubMask = `radial-gradient(circle at 0 0, #0000 ${NOTCH}px, #000 ${NOTCH + 0.5}px) top / 100% 51% no-repeat, radial-gradient(circle at 0 100%, #0000 ${NOTCH}px, #000 ${NOTCH + 0.5}px) bottom / 100% 51% no-repeat`;
    
    const TONES = {
      default: {
        paper: "bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900",
        chip: "bg-white/10 dark:bg-zinc-900/10",
        chipDone: "bg-white text-zinc-900 dark:bg-zinc-900 dark:text-white",
        sub: "text-white/55 dark:text-zinc-900/55",
        stub: "text-white/60 dark:text-zinc-900/60",
        perforation: "border-white/30 dark:border-zinc-900/30",
      },
      muted: {
        paper: "bg-zinc-100 text-zinc-900 dark:bg-zinc-800 dark:text-zinc-100",
        chip: "bg-zinc-900/5 dark:bg-white/10",
        chipDone: "bg-zinc-900 text-white dark:bg-white dark:text-zinc-900",
        sub: "text-zinc-500 dark:text-zinc-400",
        stub: "text-zinc-500 dark:text-zinc-400",
        perforation: "border-zinc-900/20 dark:border-white/25",
      },
      danger: {
        paper: "bg-red-600 text-white",
        chip: "bg-white/15",
        chipDone: "bg-white text-red-600",
        sub: "text-white/70",
        stub: "text-white/75",
        perforation: "border-white/40",
      },
    } as const;
    
    const TRASH = (
      <svg
        aria-hidden="true"
        className="size-4"
        fill="none"
        stroke="currentColor"
        strokeLinecap="round"
        strokeLinejoin="round"
        strokeWidth={2}
        viewBox="0 0 24 24"
      >
        <path d="M4 7h16M10 11v6M14 11v6M6 7l1 12a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-12M9 7V4h6v3" />
      </svg>
    );
    
    export interface TearButtonProps {
      /** Paper colour. */
      tone?: keyof typeof TONES;
      /** Shown in the round chip until the stub is torn; a trash can by default. */
      icon?: ReactNode;
      label?: string;
      description?: string;
      stubLabel?: string;
      confirmedLabel?: string;
      confirmedDescription?: string;
      /** Called once the stub comes away. */
      onConfirm?: () => void;
      /** Print a fresh stub this many ms after confirming. Off when 0. */
      resetAfter?: number;
      className?: string;
    }
    
    export default function TearButton({
      tone = "default",
      icon = TRASH,
      label = "Delete workspace",
      description = "Tear off the stub to confirm",
      stubLabel = "Tear",
      confirmedLabel = "Workspace deleted",
      confirmedDescription = "Kept in trash for 30 days",
      onConfirm,
      resetAfter = 2400,
      className,
    }: TearButtonProps) {
      const [phase, setPhase] = useState<Phase>("idle");
      const [generation, setGeneration] = useState(0);
      const rootRef = useRef<HTMLDivElement>(null);
      const bodyRef = useRef<HTMLDivElement>(null);
      const stubRef = useRef<HTMLButtonElement>(null);
      // Set when the stub tears away with focus on it, so focus can follow.
      const hadFocusRef = useRef(false);
    
      const sim = useRef({
        angle: 0,
        speed: 0,
        target: 0,
        held: false,
        grabAngle: 0,
        hole: 0,
        raf: 0,
        last: 0,
        carry: 0,
        // Free flight, once torn.
        x: 0,
        y: 0,
        vx: 0,
        vy: 0,
        spin: 0,
        // The hand's recent movement, for the throw.
        px: 0,
        py: 0,
        pt: 0,
        pvx: 0,
        pvy: 0,
        auto: 0,
        flying: false,
        reduceMotion: false,
      });
    
      const latest = useRef({ onConfirm });
      latest.current = { onConfirm };
    
      const hinge = useCallback(() => {
        const rect = rootRef.current?.getBoundingClientRect();
        return rect
          ? { x: rect.left + BODY_WIDTH, y: rect.top + HEIGHT }
          : { x: 0, y: 0 };
      }, []);
    
      const drawTear = useCallback(() => {
        const s = sim.current;
        const tip = clamp(s.angle / TEAR_ANGLE, 0, 1) * HEIGHT;
        const stub = stubRef.current;
        const body = bodyRef.current;
        if (body) {
          body.style.clipPath = tip > 0.5 ? tornEdge(tip, BODY_WIDTH, 1) : "";
        }
        if (stub) {
          stub.style.clipPath = tip > 0.5 ? tornEdge(tip, STUB_WIDTH, -1) : "";
          stub.style.transform = `rotate(${s.angle.toFixed(2)}deg)`;
        }
      }, []);
    
      const drawFlight = useCallback(() => {
        const s = sim.current;
        const stub = stubRef.current;
        if (!stub) {
          return;
        }
        const fade = clamp(
          1 - (s.y - FALL_DISTANCE * 0.5) / (FALL_DISTANCE * 0.5),
          0,
          1
        );
        stub.style.transform = `translate(${s.x.toFixed(1)}px, ${s.y.toFixed(1)}px) rotate(${s.angle.toFixed(1)}deg)`;
        stub.style.opacity = fade.toFixed(3);
      }, []);
    
      const detach = useCallback(() => {
        const s = sim.current;
        s.held = false;
        haptic(14);
        // The stub leaves with the swing it had, plus the hand's throw.
        const rad = (s.angle * Math.PI) / 180;
        const tangential = ((s.speed * Math.PI) / 180) * HEIGHT;
        s.x = 0;
        s.y = 0;
        s.vx = Math.cos(rad) * tangential + clamp(s.pvx, -900, 900) * 0.6 + 90;
        s.vy = Math.sin(rad) * tangential + clamp(s.pvy, -900, 900) * 0.6 - 120;
        s.spin = s.speed * 0.6 + 140;
        s.flying = true;
        if (stubRef.current) {
          stubRef.current.style.transition = "none";
          hadFocusRef.current = document.activeElement === stubRef.current;
        }
        setPhase("flying");
        latest.current.onConfirm?.();
      }, []);
    
      // While attached: the swing chases its target, held or springing home.
      const stepSwing = useCallback(() => {
        const s = sim.current;
        const stiffness = s.held || s.auto ? SWING_STIFFNESS : RETURN_STIFFNESS;
        const damping = s.held || s.auto ? SWING_DAMPING : RETURN_DAMPING;
        const force = (s.target - s.angle) * stiffness - s.speed * damping;
        s.speed += force * STEP;
        s.angle += s.speed * STEP;
        // A tick for each hole that gives.
        const hole = Math.floor(clamp(s.angle / TEAR_ANGLE, 0, 1) * HOLES);
        if (hole > s.hole) {
          haptic(3);
        }
        s.hole = hole;
        return s.angle >= TEAR_ANGLE * 0.995;
      }, []);
    
      const stepFlight = useCallback(() => {
        const s = sim.current;
        s.vy += GRAVITY * STEP;
        s.x += s.vx * STEP;
        s.y += s.vy * STEP;
        s.angle += s.spin * STEP;
        return s.y > FALL_DISTANCE;
      }, []);
    
      // Runs the fixed steps owed since the last frame; true once this phase is
      // over (the stub tore through, or it has fallen out of sight).
      const advance = useCallback(
        (elapsed: number) => {
          const s = sim.current;
          s.carry += elapsed;
          let finished = false;
          while (s.carry >= STEP && !finished) {
            s.carry -= STEP;
            if (s.flying) {
              finished = stepFlight();
            } else if (s.auto) {
              s.target = Math.min(s.target + STEP * 60, TEAR_ANGLE * 1.05);
              finished = stepSwing();
            } else {
              finished = stepSwing() && s.held;
            }
          }
          return finished;
        },
        [stepFlight, stepSwing]
      );
    
      const tick = useCallback(
        (now: number) => {
          const s = sim.current;
          s.raf = 0;
          const elapsed = s.last ? Math.min((now - s.last) / 1000, 0.05) : STEP;
          s.last = now;
          const { flying } = s;
          const finished = advance(elapsed);
          if (flying) {
            drawFlight();
            if (finished) {
              s.last = 0;
              setPhase("torn");
              return;
            }
            s.raf = requestAnimationFrame(tick);
            return;
          }
          drawTear();
          if (finished) {
            s.auto = 0;
            detach();
            s.raf = requestAnimationFrame(tick);
            return;
          }
          const resting =
            !(s.held || s.auto) &&
            Math.abs(s.angle) < 0.02 &&
            Math.abs(s.speed) < 0.05;
          if (!resting) {
            s.raf = requestAnimationFrame(tick);
            return;
          }
          s.angle = 0;
          s.speed = 0;
          s.last = 0;
          drawTear();
          setPhase("idle");
        },
        [advance, detach, drawFlight, drawTear]
      );
    
      const wake = useCallback(() => {
        const s = sim.current;
        if (!s.raf) {
          s.raf = requestAnimationFrame(tick);
        }
      }, [tick]);
    
      useEffect(() => {
        const s = sim.current;
        const query = window.matchMedia("(prefers-reduced-motion: reduce)");
        s.reduceMotion = query.matches;
        const onChange = () => {
          s.reduceMotion = query.matches;
        };
        query.addEventListener("change", onChange);
        return () => {
          query.removeEventListener("change", onChange);
          cancelAnimationFrame(s.raf);
          s.raf = 0;
        };
      }, []);
    
      // A fresh stub, a little while after the last one came away.
      useEffect(() => {
        if (phase !== "torn" || !resetAfter) {
          return;
        }
        const id = window.setTimeout(() => {
          const s = sim.current;
          s.angle = 0;
          s.speed = 0;
          s.target = 0;
          s.hole = 0;
          s.flying = false;
          setGeneration((g) => g + 1);
          setPhase("idle");
        }, resetAfter);
        return () => window.clearTimeout(id);
      }, [phase, resetAfter]);
    
      // The torn stub unmounts, which would drop focus on <body>. Hold it on the
      // ticket meanwhile and hand it to the fresh stub when one is printed.
      useEffect(() => {
        if (!hadFocusRef.current) {
          return;
        }
        if (phase === "torn") {
          rootRef.current?.focus({ preventScroll: true });
        } else if (phase === "idle") {
          hadFocusRef.current = false;
          stubRef.current?.focus({ preventScroll: true });
        }
      }, [phase]);
    
      // In reduced motion the stub doesn't fall: it fades where it tore.
      useEffect(() => {
        if (phase !== "flying" || !sim.current.reduceMotion) {
          return;
        }
        const s = sim.current;
        cancelAnimationFrame(s.raf);
        s.raf = 0;
        s.last = 0;
        const stub = stubRef.current;
        if (stub) {
          stub.style.transition = `opacity 180ms ${EASE_OUT}`;
          stub.style.opacity = "0";
        }
        const id = window.setTimeout(() => setPhase("torn"), 180);
        return () => window.clearTimeout(id);
      }, [phase]);
    
      const handlePointerDown = (event: PointerEvent<HTMLButtonElement>) => {
        const s = sim.current;
        if (phase === "flying" || phase === "torn") {
          return;
        }
        if (event.pointerType === "mouse" && event.button !== 0) {
          return;
        }
        event.preventDefault();
        event.currentTarget.setPointerCapture(event.pointerId);
        const h = hinge();
        s.grabAngle =
          (Math.atan2(event.clientY - h.y, event.clientX - h.x) * 180) / Math.PI -
          s.angle;
        s.held = true;
        s.px = event.clientX;
        s.py = event.clientY;
        s.pt = event.timeStamp;
        s.pvx = 0;
        s.pvy = 0;
        setPhase("tearing");
        wake();
      };
    
      const handlePointerMove = (event: PointerEvent<HTMLButtonElement>) => {
        const s = sim.current;
        if (!s.held) {
          return;
        }
        const dt = event.timeStamp - s.pt;
        if (dt > 0) {
          s.pvx = s.pvx * 0.5 + ((event.clientX - s.px) / dt) * 500;
          s.pvy = s.pvy * 0.5 + ((event.clientY - s.py) / dt) * 500;
        }
        s.px = event.clientX;
        s.py = event.clientY;
        s.pt = event.timeStamp;
        const h = hinge();
        const raw =
          (Math.atan2(event.clientY - h.y, event.clientX - h.x) * 180) / Math.PI -
          s.grabAngle;
        // Pushing the stub back into the body only gives a little.
        const pull = raw < 0 ? raw * 0.15 : raw;
        s.target =
          pull >= TEAR_ANGLE
            ? TEAR_ANGLE * 1.05
            : stickSlip(Math.max(pull, 0) / TEAR_ANGLE) * TEAR_ANGLE +
              Math.min(pull, 0);
        wake();
      };
    
      const handlePointerUp = () => {
        const s = sim.current;
        if (!s.held) {
          return;
        }
        s.held = false;
        // Not torn through: the stub swings back and the perforation holds.
        s.target = 0;
        wake();
      };
    
      const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
        if (event.key !== "Enter" && event.key !== " ") {
          return;
        }
        event.preventDefault();
        const s = sim.current;
        if (phase !== "idle" || event.repeat) {
          return;
        }
        // From the keyboard the stub tears itself, hole by hole.
        s.auto = 1;
        s.target = 0;
        setPhase("tearing");
        wake();
      };
    
      const done = phase === "flying" || phase === "torn";
      const colors = TONES[tone];
    
      return (
        <div
          className={cn("relative select-none outline-none", className)}
          ref={rootRef}
          style={{ width: BODY_WIDTH + STUB_WIDTH, height: HEIGHT }}
          tabIndex={-1}
        >
          <div
            className={cn(
              "absolute inset-y-0 left-0 flex items-center gap-3 rounded-l-2xl px-4",
              colors.paper
            )}
            ref={bodyRef}
            style={{ width: BODY_WIDTH, mask: bodyMask, WebkitMask: bodyMask }}
          >
            <span
              aria-hidden="true"
              className={cn(
                "flex size-9 shrink-0 items-center justify-center rounded-full transition-colors duration-300 ease-out",
                done ? colors.chipDone : colors.chip
              )}
            >
              {done ? (
                <svg
                  aria-hidden="true"
                  className="size-4"
                  fill="none"
                  stroke="currentColor"
                  strokeLinecap="round"
                  strokeLinejoin="round"
                  strokeWidth={2}
                  viewBox="0 0 24 24"
                >
                  <path d="M5 12.5l4.5 4.5L19 7.5" />
                </svg>
              ) : (
                icon
              )}
            </span>
            <span className="relative grid min-w-0 flex-1">
              {[
                { key: "ask", on: !done, title: label, sub: description },
                {
                  key: "done",
                  on: done,
                  title: confirmedLabel,
                  sub: confirmedDescription,
                },
              ].map((line) => (
                <span
                  aria-hidden={!line.on}
                  className={cn(
                    "col-start-1 row-start-1 min-w-0 transition-[opacity,filter,translate] duration-300 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-opacity",
                    line.on
                      ? "translate-y-0 opacity-100 blur-0"
                      : "pointer-events-none translate-y-1 opacity-0 blur-[3px]"
                  )}
                  key={line.key}
                >
                  <span className="block truncate font-medium text-sm">
                    {line.title}
                  </span>
                  <span className={cn("block truncate text-xs", colors.sub)}>
                    {line.sub}
                  </span>
                </span>
              ))}
            </span>
          </div>
    
          {phase === "torn" ? null : (
            <button
              aria-label={`${label}: ${description}`}
              className={cn(
                "absolute inset-y-0 flex touch-none flex-col items-center justify-center gap-1 rounded-r-2xl outline-none",
                // The notch mask clips anything drawn outside the stub, so the
                // focus ring sits inside it.
                "focus-visible:ring-2 focus-visible:ring-current focus-visible:ring-inset",
                colors.paper,
                colors.stub,
                "starting:-translate-x-3 starting:opacity-0 transition-[opacity,translate] duration-300 ease-[cubic-bezier(0.23,1,0.32,1)]",
                phase === "tearing" ? "cursor-grabbing" : "cursor-grab"
              )}
              // A new stub per tear, so no inline style from the last one lingers.
              key={generation}
              onKeyDown={handleKeyDown}
              onLostPointerCapture={handlePointerUp}
              onPointerCancel={handlePointerUp}
              onPointerDown={handlePointerDown}
              onPointerMove={handlePointerMove}
              onPointerUp={handlePointerUp}
              ref={stubRef}
              style={{
                left: BODY_WIDTH,
                width: STUB_WIDTH,
                mask: stubMask,
                WebkitMask: stubMask,
                transformOrigin: "0 100%",
              }}
              type="button"
            >
              {/* The perforation. */}
              <span
                aria-hidden="true"
                className={cn(
                  "absolute inset-y-2.5 left-0 border-l-2 border-dotted",
                  colors.perforation
                )}
              />
              <svg
                aria-hidden="true"
                className="size-4"
                fill="none"
                stroke="currentColor"
                strokeLinecap="round"
                strokeLinejoin="round"
                strokeWidth={2}
                viewBox="0 0 24 24"
              >
                <path d="M6 6c6 0 12 3 12 11M14 14l4 4 4-4" />
              </svg>
              <span className="font-medium text-xs">{stubLabel}</span>
            </button>
          )}
          <span aria-live="polite" className="sr-only">
            {done ? confirmedLabel : ""}
          </span>
        </div>
      );
    }

Feedback

Questions, bugs or ideas for Tear Button? Post them here, or reply to someone else. Sign in with GitHub to comment.