Back to buttons
"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>
  );
}

Tear Button

By Kokonut UI

Try the interaction, copy the React source files, or download the complete example.

About Tear Button

Tear Button is an animated React and Tailwind CSS button by Kokonut UI. Try the interaction, copy the React source, or download the example with creator credit.

Effects and categories
Hover effects, Animated
Using this example
Copy a TSX file or download the original source and standalone demo. Test keyboard focus, motion and contrast in your own project.

Usage, Tailwind and licensing questions

Read the source files

import-examples/tear-button.tsx

import TearButton from "@/components/kokonutui/tear-button";
import {Ticket,X} from "lucide-react";
export default function ImportedDemo(){return <>
    <div className="flex flex-col items-center gap-6">
        <TearButton />
        <TearButton
            confirmedDescription="20% off applied at checkout"
            confirmedLabel="Coupon redeemed"
            description="Tear off to use 20% off"
            icon={<Ticket className="size-4" />}
            label="Redeem coupon"
            tone="muted"
        />
        <TearButton
            confirmedDescription="Access until 14 November"
            confirmedLabel="Plan cancelled"
            description="Tear off to end your plan"
            icon={<X className="size-4" />}
            label="Cancel Pro plan"
            tone="danger"
        />
    </div>
</>}

components/kokonutui/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>
  );
}

lib/utils.ts

import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

demo.tsx

export { default as default } from "@/import-examples/tear-button";

CSS

/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */
@layer properties;
@layer theme, base, components, utilities;
@layer theme {
  :root, :host {
    --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
      "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
      "Segoe UI Symbol", "Noto Color Emoji";
    --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
      "Courier New", monospace;
    --color-red-600: oklch(57.7% 0.245 27.325);
    --color-zinc-100: oklch(96.7% 0.001 286.375);
    --color-zinc-400: oklch(70.5% 0.015 286.067);
    --color-zinc-500: oklch(55.2% 0.016 285.938);
    --color-zinc-800: oklch(27.4% 0.006 286.033);
    --color-zinc-900: oklch(21% 0.006 285.885);
    --color-white: #fff;
    --spacing: 0.25rem;
    --text-xs: 0.75rem;
    --text-xs--line-height: calc(1 / 0.75);
    --text-sm: 0.875rem;
    --text-sm--line-height: calc(1.25 / 0.875);
    --font-weight-medium: 500;
    --radius-2xl: 1rem;
    --ease-out: cubic-bezier(0, 0, 0.2, 1);
    --default-transition-duration: 150ms;
    --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
    --default-font-family: var(--font-sans);
    --default-mono-font-family: var(--font-mono);
  }
}
@layer base {
  *, ::after, ::before, ::backdrop, ::file-selector-button {
    box-sizing: border-box;
    margin: 0;
    padding: 0;
    border: 0 solid;
  }
  html, :host {
    line-height: 1.5;
    -webkit-text-size-adjust: 100%;
    tab-size: 4;
    font-family: var(--default-font-family, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");
    font-feature-settings: var(--default-font-feature-settings, normal);
    font-variation-settings: var(--default-font-variation-settings, normal);
    -webkit-tap-highlight-color: transparent;
  }
  hr {
    height: 0;
    color: inherit;
    border-top-width: 1px;
  }
  abbr:where([title]) {
    -webkit-text-decoration: underline dotted;
    text-decoration: underline dotted;
  }
  h1, h2, h3, h4, h5, h6 {
    font-size: inherit;
    font-weight: inherit;
  }
  a {
    color: inherit;
    -webkit-text-decoration: inherit;
    text-decoration: inherit;
  }
  b, strong {
    font-weight: bolder;
  }
  code, kbd, samp, pre {
    font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);
    font-feature-settings: var(--default-mono-font-feature-settings, normal);
    font-variation-settings: var(--default-mono-font-variation-settings, normal);
    font-size: 1em;
  }
  small {
    font-size: 80%;
  }
  sub, sup {
    font-size: 75%;
    line-height: 0;
    position: relative;
    vertical-align: baseline;
  }
  sub {
    bottom: -0.25em;
  }
  sup {
    top: -0.5em;
  }
  table {
    text-indent: 0;
    border-color: inherit;
    border-collapse: collapse;
  }
  :-moz-focusring:where(:not(iframe)) {
    outline: auto;
  }
  progress {
    vertical-align: baseline;
  }
  summary {
    display: list-item;
  }
  ol, ul, menu {
    list-style: none;
  }
  img, svg, video, canvas, audio, iframe, embed, object {
    display: block;
    vertical-align: middle;
  }
  img, video {
    max-width: 100%;
    height: auto;
  }
  button, input, select, optgroup, textarea, ::file-selector-button {
    font: inherit;
    font-feature-settings: inherit;
    font-variation-settings: inherit;
    letter-spacing: inherit;
    color: inherit;
    border-radius: 0;
    background-color: transparent;
    opacity: 1;
  }
  :where(select:is([multiple], [size])) optgroup {
    font-weight: bolder;
  }
  :where(select:is([multiple], [size])) optgroup option {
    padding-inline-start: 20px;
  }
  ::file-selector-button {
    margin-inline-end: 4px;
  }
  ::placeholder {
    opacity: 1;
  }
  @supports (not (-webkit-appearance: -apple-pay-button))  or (contain-intrinsic-size: 1px) {
    ::placeholder {
      color: currentcolor;
      @supports (color: color-mix(in lab, red, red)) {
        color: color-mix(in oklab, currentcolor 50%, transparent);
      }
    }
  }
  textarea {
    resize: vertical;
  }
  ::-webkit-search-decoration {
    -webkit-appearance: none;
  }
  ::-webkit-date-and-time-value {
    min-height: 1lh;
    text-align: inherit;
  }
  ::-webkit-datetime-edit {
    display: inline-flex;
  }
  ::-webkit-datetime-edit-fields-wrapper {
    padding: 0;
  }
  ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {
    padding-block: 0;
  }
  ::-webkit-calendar-picker-indicator {
    line-height: 1;
  }
  :-moz-ui-invalid {
    box-shadow: none;
  }
  button, input:where([type="button"], [type="reset"], [type="submit"]), ::file-selector-button {
    appearance: button;
  }
  ::-webkit-inner-spin-button, ::-webkit-outer-spin-button {
    height: auto;
  }
  [hidden]:where(:not([hidden="until-found"])) {
    display: none !important;
  }
}
@layer utilities {
  .pointer-events-none {
    pointer-events: none;
  }
  .sr-only {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
    border-width: 0;
  }
  .absolute {
    position: absolute;
  }
  .fixed {
    position: fixed;
  }
  .relative {
    position: relative;
  }
  .inset-y-0 {
    inset-block: 0px;
  }
  .inset-y-2\.5 {
    inset-block: calc(var(--spacing) * 2.5);
  }
  .left-0 {
    left: 0px;
  }
  .col-start-1 {
    grid-column-start: 1;
  }
  .row-start-1 {
    grid-row-start: 1;
  }
  .block {
    display: block;
  }
  .flex {
    display: flex;
  }
  .grid {
    display: grid;
  }
  .inline {
    display: inline;
  }
  .size-4 {
    width: calc(var(--spacing) * 4);
    height: calc(var(--spacing) * 4);
  }
  .size-9 {
    width: calc(var(--spacing) * 9);
    height: calc(var(--spacing) * 9);
  }
  .min-w-0 {
    min-width: 0px;
  }
  .flex-1 {
    flex: 1;
  }
  .shrink-0 {
    flex-shrink: 0;
  }
  .translate-y-0 {
    --tw-translate-y: 0px;
    translate: var(--tw-translate-x) var(--tw-translate-y);
  }
  .translate-y-1 {
    --tw-translate-y: var(--spacing);
    translate: var(--tw-translate-x) var(--tw-translate-y);
  }
  .transform {
    transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,);
  }
  .cursor-grab {
    cursor: grab;
  }
  .cursor-grabbing {
    cursor: grabbing;
  }
  .touch-none {
    touch-action: none;
  }
  .flex-col {
    flex-direction: column;
  }
  .items-center {
    align-items: center;
  }
  .justify-center {
    justify-content: center;
  }
  .gap-1 {
    gap: var(--spacing);
  }
  .gap-3 {
    gap: calc(var(--spacing) * 3);
  }
  .gap-6 {
    gap: calc(var(--spacing) * 6);
  }
  .truncate {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .rounded-full {
    border-radius: calc(infinity * 1px);
  }
  .rounded-l-2xl {
    border-top-left-radius: var(--radius-2xl);
    border-bottom-left-radius: var(--radius-2xl);
  }
  .rounded-r-2xl {
    border-top-right-radius: var(--radius-2xl);
    border-bottom-right-radius: var(--radius-2xl);
  }
  .border-l-2 {
    border-left-style: var(--tw-border-style);
    border-left-width: 2px;
  }
  .border-dotted {
    --tw-border-style: dotted;
    border-style: dotted;
  }
  .border-white\/30 {
    border-color: color-mix(in srgb, #fff 30%, transparent);
    @supports (color: color-mix(in lab, red, red)) {
      border-color: color-mix(in oklab, var(--color-white) 30%, transparent);
    }
  }
  .border-white\/40 {
    border-color: color-mix(in srgb, #fff 40%, transparent);
    @supports (color: color-mix(in lab, red, red)) {
      border-color: color-mix(in oklab, var(--color-white) 40%, transparent);
    }
  }
  .border-zinc-900\/20 {
    border-color: color-mix(in srgb, oklch(21% 0.006 285.885) 20%, transparent);
    @supports (color: color-mix(in lab, red, red)) {
      border-color: color-mix(in oklab, var(--color-zinc-900) 20%, transparent);
    }
  }
  .bg-red-600 {
    background-color: var(--color-red-600);
  }
  .bg-white {
    background-color: var(--color-white);
  }
  .bg-white\/10 {
    background-color: color-mix(in srgb, #fff 10%, transparent);
    @supports (color: color-mix(in lab, red, red)) {
      background-color: color-mix(in oklab, var(--color-white) 10%, transparent);
    }
  }
  .bg-white\/15 {
    background-color: color-mix(in srgb, #fff 15%, transparent);
    @supports (color: color-mix(in lab, red, red)) {
      background-color: color-mix(in oklab, var(--color-white) 15%, transparent);
    }
  }
  .bg-zinc-100 {
    background-color: var(--color-zinc-100);
  }
  .bg-zinc-900 {
    background-color: var(--color-zinc-900);
  }
  .bg-zinc-900\/5 {
    background-color: color-mix(in srgb, oklch(21% 0.006 285.885) 5%, transparent);
    @supports (color: color-mix(in lab, red, red)) {
      background-color: color-mix(in oklab, var(--color-zinc-900) 5%, transparent);
    }
  }
  .px-4 {
    padding-inline: calc(var(--spacing) * 4);
  }
  .text-sm {
    font-size: var(--text-sm);
    line-height: var(--tw-leading, var(--text-sm--line-height));
  }
  .text-xs {
    font-size: var(--text-xs);
    line-height: var(--tw-leading, var(--text-xs--line-height));
  }
  .font-medium {
    --tw-font-weight: var(--font-weight-medium);
    font-weight: var(--font-weight-medium);
  }
  .text-red-600 {
    color: var(--color-red-600);
  }
  .text-white {
    color: var(--color-white);
  }
  .text-white\/55 {
    color: color-mix(in srgb, #fff 55%, transparent);
    @supports (color: color-mix(in lab, red, red)) {
      color: color-mix(in oklab, var(--color-white) 55%, transparent);
    }
  }
  .text-white\/60 {
    color: color-mix(in srgb, #fff 60%, transparent);
    @supports (color: color-mix(in lab, red, red)) {
      color: color-mix(in oklab, var(--color-white) 60%, transparent);
    }
  }
  .text-white\/70 {
    color: color-mix(in srgb, #fff 70%, transparent);
    @supports (color: color-mix(in lab, red, red)) {
      color: color-mix(in oklab, var(--color-white) 70%, transparent);
    }
  }
  .text-white\/75 {
    color: color-mix(in srgb, #fff 75%, transparent);
    @supports (color: color-mix(in lab, red, red)) {
      color: color-mix(in oklab, var(--color-white) 75%, transparent);
    }
  }
  .text-zinc-500 {
    color: var(--color-zinc-500);
  }
  .text-zinc-900 {
    color: var(--color-zinc-900);
  }
  .opacity-0 {
    opacity: 0%;
  }
  .opacity-100 {
    opacity: 100%;
  }
  .ring {
    --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
    box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
  }
  .blur-\[3px\] {
    --tw-blur: blur(3px);
    filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
  }
  .transition {
    transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, content-visibility, overlay, pointer-events;
    transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
    transition-duration: var(--tw-duration, var(--default-transition-duration));
  }
  .transition-\[opacity\,filter\,translate\] {
    transition-property: opacity,filter,translate;
    transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
    transition-duration: var(--tw-duration, var(--default-transition-duration));
  }
  .transition-\[opacity\,translate\] {
    transition-property: opacity,translate;
    transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
    transition-duration: var(--tw-duration, var(--default-transition-duration));
  }
  .transition-colors {
    transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to;
    transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
    transition-duration: var(--tw-duration, var(--default-transition-duration));
  }
  .duration-300 {
    --tw-duration: 300ms;
    transition-duration: 300ms;
  }
  .ease-\[cubic-bezier\(0\.23\,1\,0\.32\,1\)\] {
    --tw-ease: cubic-bezier(0.23,1,0.32,1);
    transition-timing-function: cubic-bezier(0.23,1,0.32,1);
  }
  .ease-out {
    --tw-ease: var(--ease-out);
    transition-timing-function: var(--ease-out);
  }
  .outline-none {
    --tw-outline-style: none;
    outline-style: none;
  }
  .select-none {
    -webkit-user-select: none;
    user-select: none;
  }
  .focus-visible\:ring-2:focus-visible {
    --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
    box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
  }
  .focus-visible\:ring-current:focus-visible {
    --tw-ring-color: currentcolor;
  }
  .focus-visible\:ring-inset:focus-visible {
    --tw-ring-inset: inset;
  }
  @media (prefers-reduced-motion: reduce) {
    .motion-reduce\:transition-opacity {
      transition-property: opacity;
      transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
      transition-duration: var(--tw-duration, var(--default-transition-duration));
    }
  }
  .dark\:border-white\/25:where(.dark, .dark *) {
    border-color: color-mix(in srgb, #fff 25%, transparent);
    @supports (color: color-mix(in lab, red, red)) {
      border-color: color-mix(in oklab, var(--color-white) 25%, transparent);
    }
  }
  .dark\:border-zinc-900\/30:where(.dark, .dark *) {
    border-color: color-mix(in srgb, oklch(21% 0.006 285.885) 30%, transparent);
    @supports (color: color-mix(in lab, red, red)) {
      border-color: color-mix(in oklab, var(--color-zinc-900) 30%, transparent);
    }
  }
  .dark\:bg-white:where(.dark, .dark *) {
    background-color: var(--color-white);
  }
  .dark\:bg-white\/10:where(.dark, .dark *) {
    background-color: color-mix(in srgb, #fff 10%, transparent);
    @supports (color: color-mix(in lab, red, red)) {
      background-color: color-mix(in oklab, var(--color-white) 10%, transparent);
    }
  }
  .dark\:bg-zinc-100:where(.dark, .dark *) {
    background-color: var(--color-zinc-100);
  }
  .dark\:bg-zinc-800:where(.dark, .dark *) {
    background-color: var(--color-zinc-800);
  }
  .dark\:bg-zinc-900:where(.dark, .dark *) {
    background-color: var(--color-zinc-900);
  }
  .dark\:bg-zinc-900\/10:where(.dark, .dark *) {
    background-color: color-mix(in srgb, oklch(21% 0.006 285.885) 10%, transparent);
    @supports (color: color-mix(in lab, red, red)) {
      background-color: color-mix(in oklab, var(--color-zinc-900) 10%, transparent);
    }
  }
  .dark\:text-white:where(.dark, .dark *) {
    color: var(--color-white);
  }
  .dark\:text-zinc-100:where(.dark, .dark *) {
    color: var(--color-zinc-100);
  }
  .dark\:text-zinc-400:where(.dark, .dark *) {
    color: var(--color-zinc-400);
  }
  .dark\:text-zinc-900:where(.dark, .dark *) {
    color: var(--color-zinc-900);
  }
  .dark\:text-zinc-900\/55:where(.dark, .dark *) {
    color: color-mix(in srgb, oklch(21% 0.006 285.885) 55%, transparent);
    @supports (color: color-mix(in lab, red, red)) {
      color: color-mix(in oklab, var(--color-zinc-900) 55%, transparent);
    }
  }
  .dark\:text-zinc-900\/60:where(.dark, .dark *) {
    color: color-mix(in srgb, oklch(21% 0.006 285.885) 60%, transparent);
    @supports (color: color-mix(in lab, red, red)) {
      color: color-mix(in oklab, var(--color-zinc-900) 60%, transparent);
    }
  }
  @starting-style {
    .starting\:-translate-x-3 {
      --tw-translate-x: calc(var(--spacing) * -3);
      translate: var(--tw-translate-x) var(--tw-translate-y);
    }
    .starting\:opacity-0 {
      opacity: 0%;
    }
  }
}
:root {
  --background: #fff;
  --foreground: #111;
  --primary: #171717;
  --primary-foreground: #fff;
  --secondary: #eee;
  --secondary-foreground: #171717;
  --muted: #eee;
  --muted-foreground: #666;
  --accent: #eee;
  --accent-foreground: #171717;
  --border: #ddd;
  --input: #ddd;
  --ring: #777;
  --destructive: #dc2626;
  --destructive-foreground: #fff;
  --popover: #fff;
  --popover-foreground: #111;
  --success: #15803d;
  --success-foreground: #fff;
  --radius: .625rem;
  --hairline: 1px;
}
.dark {
  --background: #090909;
  --foreground: #fafafa;
  --primary: #fafafa;
  --primary-foreground: #171717;
  --secondary: #262626;
  --secondary-foreground: #fafafa;
  --muted: #262626;
  --muted-foreground: #a3a3a3;
  --accent: #262626;
  --accent-foreground: #fafafa;
  --border: #333;
  --input: #333;
  --popover: #171717;
  --popover-foreground: #fafafa;
  --color-fd-background: #000;
  --color-fd-card: #0a0a0a;
  --color-fd-popover: #0a0a0a;
  --color-fd-border: rgb(255 255 255 / 0.14);
  --color-fd-accent: rgb(255 255 255 / 0.07);
}
:root {
  --sidebar: hsl(0 0% 98%);
  --sidebar-foreground: hsl(240 5.3% 26.1%);
  --sidebar-primary: hsl(240 5.9% 10%);
  --sidebar-primary-foreground: hsl(0 0% 98%);
  --sidebar-accent: hsl(240 4.8% 95.9%);
  --sidebar-accent-foreground: hsl(240 5.9% 10%);
  --sidebar-border: hsl(220 13% 91%);
  --sidebar-ring: hsl(217.2 91.2% 59.8%);
}
.dark {
  --sidebar: hsl(240 5.9% 10%);
  --sidebar-foreground: hsl(240 4.8% 95.9%);
  --sidebar-primary: hsl(224.3 76.3% 48%);
  --sidebar-primary-foreground: hsl(0 0% 100%);
  --sidebar-accent: hsl(240 3.7% 15.9%);
  --sidebar-accent-foreground: hsl(240 4.8% 95.9%);
  --sidebar-border: hsl(240 3.7% 15.9%);
  --sidebar-ring: hsl(217.2 91.2% 59.8%);
}
:root {
  --leaf: #1fa834;
}
.dark {
  --leaf: #3bb44b;
}
@property --tw-translate-x {
  syntax: "*";
  inherits: false;
  initial-value: 0;
}
@property --tw-translate-y {
  syntax: "*";
  inherits: false;
  initial-value: 0;
}
@property --tw-translate-z {
  syntax: "*";
  inherits: false;
  initial-value: 0;
}
@property --tw-rotate-x {
  syntax: "*";
  inherits: false;
}
@property --tw-rotate-y {
  syntax: "*";
  inherits: false;
}
@property --tw-rotate-z {
  syntax: "*";
  inherits: false;
}
@property --tw-skew-x {
  syntax: "*";
  inherits: false;
}
@property --tw-skew-y {
  syntax: "*";
  inherits: false;
}
@property --tw-border-style {
  syntax: "*";
  inherits: false;
  initial-value: solid;
}
@property --tw-font-weight {
  syntax: "*";
  inherits: false;
}
@property --tw-shadow {
  syntax: "*";
  inherits: false;
  initial-value: 0 0 #0000;
}
@property --tw-shadow-color {
  syntax: "*";
  inherits: false;
}
@property --tw-shadow-alpha {
  syntax: "<percentage>";
  inherits: false;
  initial-value: 100%;
}
@property --tw-inset-shadow {
  syntax: "*";
  inherits: false;
  initial-value: 0 0 #0000;
}
@property --tw-inset-shadow-color {
  syntax: "*";
  inherits: false;
}
@property --tw-inset-shadow-alpha {
  syntax: "<percentage>";
  inherits: false;
  initial-value: 100%;
}
@property --tw-ring-color {
  syntax: "*";
  inherits: false;
}
@property --tw-ring-shadow {
  syntax: "*";
  inherits: false;
  initial-value: 0 0 #0000;
}
@property --tw-inset-ring-color {
  syntax: "*";
  inherits: false;
}
@property --tw-inset-ring-shadow {
  syntax: "*";
  inherits: false;
  initial-value: 0 0 #0000;
}
@property --tw-ring-inset {
  syntax: "*";
  inherits: false;
}
@property --tw-ring-offset-width {
  syntax: "<length>";
  inherits: false;
  initial-value: 0px;
}
@property --tw-ring-offset-color {
  syntax: "*";
  inherits: false;
  initial-value: #fff;
}
@property --tw-ring-offset-shadow {
  syntax: "*";
  inherits: false;
  initial-value: 0 0 #0000;
}
@property --tw-blur {
  syntax: "*";
  inherits: false;
}
@property --tw-brightness {
  syntax: "*";
  inherits: false;
}
@property --tw-contrast {
  syntax: "*";
  inherits: false;
}
@property --tw-grayscale {
  syntax: "*";
  inherits: false;
}
@property --tw-hue-rotate {
  syntax: "*";
  inherits: false;
}
@property --tw-invert {
  syntax: "*";
  inherits: false;
}
@property --tw-opacity {
  syntax: "*";
  inherits: false;
}
@property --tw-saturate {
  syntax: "*";
  inherits: false;
}
@property --tw-sepia {
  syntax: "*";
  inherits: false;
}
@property --tw-drop-shadow {
  syntax: "*";
  inherits: false;
}
@property --tw-drop-shadow-color {
  syntax: "*";
  inherits: false;
}
@property --tw-drop-shadow-alpha {
  syntax: "<percentage>";
  inherits: false;
  initial-value: 100%;
}
@property --tw-drop-shadow-size {
  syntax: "*";
  inherits: false;
}
@property --tw-duration {
  syntax: "*";
  inherits: false;
}
@property --tw-ease {
  syntax: "*";
  inherits: false;
}
@layer properties {
  @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {
    *, ::before, ::after, ::backdrop {
      --tw-translate-x: 0;
      --tw-translate-y: 0;
      --tw-translate-z: 0;
      --tw-rotate-x: initial;
      --tw-rotate-y: initial;
      --tw-rotate-z: initial;
      --tw-skew-x: initial;
      --tw-skew-y: initial;
      --tw-border-style: solid;
      --tw-font-weight: initial;
      --tw-shadow: 0 0 #0000;
      --tw-shadow-color: initial;
      --tw-shadow-alpha: 100%;
      --tw-inset-shadow: 0 0 #0000;
      --tw-inset-shadow-color: initial;
      --tw-inset-shadow-alpha: 100%;
      --tw-ring-color: initial;
      --tw-ring-shadow: 0 0 #0000;
      --tw-inset-ring-color: initial;
      --tw-inset-ring-shadow: 0 0 #0000;
      --tw-ring-inset: initial;
      --tw-ring-offset-width: 0px;
      --tw-ring-offset-color: #fff;
      --tw-ring-offset-shadow: 0 0 #0000;
      --tw-blur: initial;
      --tw-brightness: initial;
      --tw-contrast: initial;
      --tw-grayscale: initial;
      --tw-hue-rotate: initial;
      --tw-invert: initial;
      --tw-opacity: initial;
      --tw-saturate: initial;
      --tw-sepia: initial;
      --tw-drop-shadow: initial;
      --tw-drop-shadow-color: initial;
      --tw-drop-shadow-alpha: 100%;
      --tw-drop-shadow-size: initial;
      --tw-duration: initial;
      --tw-ease: initial;
    }
  }
}