Vasnev.art

Swipe to Confirm

Component 16

About

Slide to confirm, HIG style. Spring knob with rubber band, fill that trails it, haptics, spinner while your promise runs, error shake, success check. ConfirmPanel wraps it: card on desktop, bottom sheet on phones. Send, pay and delete demos.

Built with React and Tailwind. Copy the prompt and paste it into Claude Code, Cursor or Lovable: your agent adds Swipe to Confirm to your project and fits it to your theme.

Code

How Swipe to Confirm is used: the demo's code. The component's own source comes with a free account.

Usage.tsx

"use client";

import * as React from "react";
import { ConfirmPanel, type ConfirmPanelProps } from "@/components/ui/swipe-to-confirm";

/* Live controls (21st Demo Controls). */
const settings = {
  mobileView: false,
};

const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));

/* demo content: a USDC mark for the send scenario */
const UsdcIcon = () => (
  <svg viewBox="0 0 32 32" width="100%" height="100%" aria-label="USDC">
    <circle cx="16" cy="16" r="16" fill="#2775CA" />
    <path d="M9.98 7.4A10.5 10.5 0 0 0 9.98 24.6M22.02 7.4A10.5 10.5 0 0 1 22.02 24.6" fill="none" stroke="#fff" strokeWidth="2" strokeLinecap="round" />
    <text x="16" y="16" dy=".36em" textAnchor="middle" fill="#fff" fontSize="15" fontWeight="700">
      $
    </text>
  </svg>
);

type Scenario = { id: string; label: string; panel: (attempt: { n: number }) => ConfirmPanelProps };

const SCENARIOS: Scenario[] = [
  {
    id: "send",
    label: "Send",
    panel: () => ({
      title: "Send",
      caption: "Balance $2,480.00",
      party: { name: "Alex Morgan", detail: "0x7a3f…c91e" },
      amount: { value: "250", token: { symbol: "USDC", icon: <UsdcIcon /> }, fiat: "≈ $250.00" },
      rows: [
        { label: "Network", value: "Base" },
        { label: "Fee", value: "$0.02" },
        { label: "Arrives", value: "~ 5 sec" },
      ],
      swipe: { label: "Slide to send", doneLabel: "Sent", onConfirm: () => wait(1400) },
    }),
  },
  {
    id: "pay",
    label: "Pay",
    panel: (attempt) => ({
      title: "Checkout",
      party: { name: "Pro plan", detail: "Billed yearly" },
      amount: { value: "$144" },
      rows: [
        { label: "Card", value: "Visa •••• 4242" },
        { label: "Renews", value: "Oct 20, 2027" },
      ],
      swipe: {
        label: "Slide to pay",
        doneLabel: "Paid",
        color: "#5856D6",
        onConfirm: async () => {
          await wait(1100);
          if (attempt.n++ === 0) throw new Error("Card declined. Try again.");
        },
      },
    }),
  },
  {
    id: "delete",
    label: "Delete",
    panel: () => ({
      title: "Delete workspace",
      party: { name: "Acme Studio", detail: "12 members · 3 projects" },
      hero: (
        <div className="rounded-[14px] bg-destructive/10 px-3.5 py-3 text-[15px] leading-[21px] text-destructive">
          This deletes every project and file for all members. You can't undo it.
        </div>
      ),
      swipe: {
        label: "Slide to delete",
        doneLabel: "Deleted",
        color: "var(--color-destructive)",
        onConfirm: () => wait(1200),
        icon: (
          <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
            <path d="M4 7h16M10 11v6M14 11v6M6 7l1 12a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-12M9 7V4h6v3" />
          </svg>
        ),
      },
    }),
  },
];

/* segmented control on theme tokens */
function Segmented({ value, onChange }: { value: string; onChange: (id: string) => void }) {
  const i = Math.max(0, SCENARIOS.findIndex((x) => x.id === value));
  return (
    <div role="tablist" aria-label="Scenario" className="relative grid h-8 w-[252px] grid-cols-3 rounded-[9px] bg-muted p-[2px]">
      <div
        className="absolute bottom-[2px] left-[2px] top-[2px] w-[calc((100%-4px)/3)] rounded-[7px] bg-background shadow-[0_3px_8px_rgb(0_0_0/.12),0_3px_1px_rgb(0_0_0/.04)] transition-transform duration-300 [transition-timing-function:cubic-bezier(.2,.9,.25,1)]"
        style={{ transform: `translateX(${i * 100}%)` }}
      />
      {SCENARIOS.map((it) => (
        <button
          key={it.id}
          role="tab"
          type="button"
          aria-selected={it.id === value}
          onClick={() => onChange(it.id)}
          className={`relative z-10 text-[13px] text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring ${it.id === value ? "font-semibold" : "font-medium"}`}
        >
          {it.label}
        </button>
      ))}
    </div>
  );
}

export default function SwipeToConfirmDemo(props: Partial<typeof settings>) {
  const s = { ...settings, ...props };
  const [id, setId] = React.useState("send");
  const [run, setRun] = React.useState(0);
  const [open, setOpen] = React.useState(true);
  const attempt = React.useRef({ n: 0 });
  const sc = SCENARIOS.find((x) => x.id === id) ?? SCENARIOS[0];

  React.useEffect(() => {
    attempt.current.n = 0;
    setOpen(true);
    setRun((r) => r + 1);
  }, [id, s.mobileView]);

  const panel = sc.panel(attempt.current);
  const action = panel.swipe?.label?.replace("Slide to ", "") ?? "Open";

  return (
    <div
      className="flex min-h-screen w-full flex-col items-center justify-center gap-6 bg-background px-4 py-10 text-foreground"
      style={{
        fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', Inter, 'Segoe UI', Roboto, sans-serif",
        /* dot grid from the "work with AI" block of vasnev.art */
        backgroundImage: "radial-gradient(circle, color-mix(in oklab, var(--color-foreground) 7%, transparent) 0 1px, transparent 1.4px)",
        backgroundSize: "20px 13px",
        backgroundPosition: "-6px -1px",
      }}
    >
      <Segmented value={id} onChange={setId} />

      {s.mobileView ? (
        /* phone: same frame as Portfolio Hero */
        <div className="relative h-[720px] w-[360px] max-w-full overflow-hidden rounded-[47px] border-[5px] border-[#d4d4d2] bg-background shadow-2xl dark:border-[#1c1c1c]">
          <div className="absolute inset-0 grid place-items-center">
            <button
              type="button"
              onClick={() => {
                setRun((r) => r + 1);
                setOpen(true);
              }}
              className="h-[50px] rounded-xl bg-primary px-6 text-[17px] font-semibold capitalize text-primary-foreground outline-none transition-transform active:scale-[.97] focus-visible:ring-2 focus-visible:ring-ring"
            >
              {action}
            </button>
          </div>
          <ConfirmPanel key={`${id}-${run}`} {...panel} presentation="sheet" sheetContainer="parent" open={open} onOpenChange={setOpen} />
        </div>
      ) : (
        <ConfirmPanel key={`${id}-${run}`} {...panel} presentation="card" swipe={{ ...panel.swipe, resetAfter: 2600 }} />
      )}

      <p className="m-0 max-w-[360px] text-center text-[13px] leading-[18px] text-muted-foreground">
        {id === "pay" ? "The first try is declined on purpose, to show the error state." : "Drag the circle to the end, or focus it and press Enter. On phones the same panel opens as a bottom sheet."}
      </p>
    </div>
  );
}

More by Vasnev.art

View all
  • Web3Bento

    Web3Bento

    349
  • Analytics Bento

    Analytics Bento

    283
  • Finance Bento

    Finance Bento

    253
  • Icon Folder

    Icon Folder

    136
  • Holo Card

    Holo Card

    60
  • Portfolio Hero

    Portfolio Hero

    53
  • Glass Prism Hero

    Glass Prism Hero

    36
  • Dot Field

    Dot Field

    30