About
Button payment effect bubble
Built with React and Tailwind. Copy the prompt and paste it into Claude Code, Cursor or Lovable: your agent adds Button Payment to your project and fits it to your theme.
Code
How Button Payment is used: the demo's code. The component's own source comes with a free account.
Usage.tsx
import { useCallback, useEffect, useId, useRef, useState } from "react";
type Phase = "idle" | "processing" | "success";
const CSS = `
@import url('https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap');
.p2p-root{--ink:#fff;--ink-soft:rgba(255,255,255,.72);--glass-edge:rgba(255,255,255,.55);--ok:#34d399;--spring:cubic-bezier(.34,1.56,.64,1);--ease:cubic-bezier(.22,1,.36,1);
box-sizing:border-box;padding-top:env(safe-area-inset-top,0px);padding-bottom:env(safe-area-inset-bottom,0px)}
.p2p-root,.p2p-root *{box-sizing:border-box}
.p2p-root{position:fixed;inset:0;width:100%;height:100%;overflow-x:hidden;overflow-y:auto;font-family:Roboto,-apple-system,"Helvetica Neue",Arial,sans-serif;color:var(--ink);background:#0b0b0c;display:grid}
.p2p-bg{position:fixed;inset:0;overflow:hidden;z-index:0}
.p2p-blob{position:absolute;border-radius:50%;filter:blur(46px);opacity:.9;animation:p2p-float 14s ease-in-out infinite alternate}
.p2p-b1{width:60vmax;height:60vmax;left:-15vmax;top:-18vmax;background:#8a8a8a}
.p2p-b2{width:48vmax;height:48vmax;right:-14vmax;top:20vh;background:#bdbdbd;animation-delay:-5s}
.p2p-b3{width:42vmax;height:42vmax;left:10vw;bottom:-20vmax;background:#4a4a4a;animation-delay:-9s}
@keyframes p2p-float{to{transform:translate(6vmax,4vmax) scale(1.12)}}
.p2p-main{margin:auto;position:relative;z-index:1;width:min(92vw,400px);padding:12px 0}
.p2p-card{padding:26px 24px 22px;border-radius:34px;background:linear-gradient(160deg,rgba(255,255,255,.22),rgba(255,255,255,.07));border:1px solid var(--glass-edge);
backdrop-filter:blur(28px) saturate(1.6);-webkit-backdrop-filter:blur(28px) saturate(1.6);
box-shadow:0 30px 60px -20px rgba(8,4,40,.6),inset 0 1px 0 rgba(255,255,255,.6),inset 0 -1px 0 rgba(255,255,255,.12)}
.p2p-title{position:relative;height:50px;margin:0 0 4px;font-family:Roboto,-apple-system,"Helvetica Neue",Arial,sans-serif;font-size:36px;line-height:1.15;font-weight:700;letter-spacing:-.02em}
.p2p-title span{position:absolute;left:0;top:0;transition:opacity .6s var(--ease),transform .6s var(--ease),filter .6s var(--ease)}
.p2p-t-buy{opacity:0;transform:translateY(22px);filter:blur(8px)}
.p2p-root[data-state="success"] .p2p-t-pay{opacity:0;transform:translateY(-22px);filter:blur(8px)}
.p2p-root[data-state="success"] .p2p-t-buy{opacity:1;transform:none;filter:none}
.p2p-store{display:flex;align-items:center;gap:7px;margin:0 0 8px;font-size:12px;font-weight:500;letter-spacing:.14em;text-transform:uppercase;color:var(--ink-soft)}
.p2p-store svg{width:15px;height:15px;flex:none}
.p2p-sub{margin:0 0 20px;color:#facc15;font-size:14px;min-height:20px}
.p2p-items{list-style:none;margin:0 0 16px;padding:0;display:grid;gap:10px}
.p2p-items li{display:flex;justify-content:space-between;gap:12px;font-size:15px}
.p2p-items small{display:block;color:var(--ink-soft);font-size:12px}
.p2p-total{display:flex;justify-content:space-between;align-items:baseline;padding-top:14px;border-top:1px solid rgba(255,255,255,.25);margin-bottom:20px}
.p2p-total b{font-size:24px;letter-spacing:-.01em}
.p2p-receipt{display:grid;grid-template-rows:0fr;transition:grid-template-rows .6s var(--ease),opacity .5s var(--ease);opacity:0}
.p2p-receipt>div{overflow:hidden}
.p2p-rbox{container-type:inline-size;margin:0 0 18px;padding:12px 14px;border-radius:16px;background:rgba(255,255,255,.12);border:1px solid rgba(255,255,255,.4);font-size:13px;line-height:1.5}
.p2p-rbox p{margin:0 0 8px;color:#4ade80;font-weight:500;white-space:nowrap;font-size:12px;font-size:min(13px,3.7cqw)}
.p2p-meta{margin:0;display:grid;gap:4px;padding-top:8px;border-top:1px solid rgba(255,255,255,.25)}
.p2p-meta div{display:flex;justify-content:space-between;gap:12px}
.p2p-meta dt{color:var(--ink-soft)}
.p2p-meta dd{margin:0;font-weight:500;text-align:right}
.p2p-root[data-state="success"] .p2p-receipt{grid-template-rows:1fr;opacity:1}
.p2p-wrap{position:relative;width:100%;height:64px}
.p2p-stage{position:absolute;inset:0;transition:transform .35s var(--spring)}
.p2p-root[data-state="idle"] .p2p-wrap:hover .p2p-stage{transform:translateY(-2px) scale(1.012)}
.p2p-root[data-state="idle"] .p2p-wrap:active .p2p-stage{transform:scale(.96);transition-duration:.12s}
.p2p-stage>svg{position:absolute;left:0;top:0;overflow:visible;pointer-events:none;filter:drop-shadow(0 12px 14px rgba(0,0,0,.5));transition:filter .5s}
.p2p-root[data-state="processing"] .p2p-stage>svg,.p2p-root[data-state="success"] .p2p-stage>svg{filter:drop-shadow(0 12px 16px rgba(52,211,153,.5))}
.p2p-green{opacity:0;transition:opacity .5s}
.p2p-root[data-state="processing"] .p2p-green,.p2p-root[data-state="success"] .p2p-green{opacity:1}
.p2p-pay{position:absolute;left:0;top:0;height:64px;width:100%;border:0;border-radius:32px;background:none;color:#0b0b0c;font-family:Roboto,-apple-system,"Helvetica Neue",Arial,sans-serif;font-weight:500;font-size:18px;line-height:1;letter-spacing:.01em;text-shadow:0 1px 0 rgba(255,255,255,.45);cursor:pointer;overflow:hidden;white-space:nowrap;padding:0}
.p2p-pay:focus-visible{outline:3px solid #fff;outline-offset:3px}
.p2p-lb.p2p-pay1{color:#1d4ed8;font-weight:700}
.p2p-root[data-state="processing"] .p2p-pay{cursor:progress}
.p2p-root[data-state="processing"] .p2p-pay,.p2p-root[data-state="success"] .p2p-pay{pointer-events:none}
.p2p-lb,.p2p-spin,.p2p-check{position:absolute;inset:0;display:grid;place-items:center;transition:opacity .3s,transform .4s var(--spring)}
.p2p-lb.p2p-proc,.p2p-lb.p2p-done,.p2p-spin,.p2p-check{opacity:0;transform:scale(.6)}
.p2p-root[data-state="processing"] .p2p-lb.p2p-pay1{opacity:0;transform:scale(.8) translateY(-8px)}
.p2p-root[data-state="processing"] .p2p-lb.p2p-proc{opacity:1;transform:none}
.p2p-root[data-state="processing"] .p2p-spin{opacity:1;transform:none}
.p2p-root[data-state="success"] .p2p-lb.p2p-pay1,.p2p-root[data-state="success"] .p2p-lb.p2p-proc{opacity:0;transform:scale(.8) translateY(-8px)}
.p2p-root[data-state="success"] .p2p-lb.p2p-done{opacity:1;transform:none}
.p2p-root[data-state="success"] .p2p-check{opacity:1;transform:none}
.p2p-icon{position:absolute;top:0;left:0;width:64px;height:64px;opacity:0;pointer-events:none}
.p2p-spin i{display:block;width:26px;height:26px;border-radius:50%;border:3px solid rgba(0,0,0,.18);border-top-color:#0b0b0c;animation:p2p-rot .8s linear infinite}
@keyframes p2p-rot{to{transform:rotate(360deg)}}
.p2p-check svg{display:block;width:34px;height:34px}
.p2p-check path{fill:none;stroke:#0b0b0c;stroke-width:3.5;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:30;stroke-dashoffset:30}
.p2p-root[data-state="success"] .p2p-check path{animation:p2p-draw .5s .2s var(--ease) forwards}
@keyframes p2p-draw{to{stroke-dashoffset:0}}
.p2p-ring{position:absolute;right:0;top:0;width:64px;height:64px;border-radius:50%;border:2px solid var(--ok);opacity:0;pointer-events:none}
.p2p-root[data-state="success"] .p2p-ring{animation:p2p-ring 1s var(--ease)}
@keyframes p2p-ring{0%{opacity:.9;transform:scale(1)}100%{opacity:0;transform:scale(3.2)}}
.p2p-reset{display:block;margin:16px auto 0;background:none;border:0;color:var(--ink-soft);font:500 13px inherit;font-family:inherit;text-decoration:underline;text-underline-offset:3px;cursor:pointer;opacity:0;pointer-events:none;transition:opacity .4s .5s;min-height:44px;padding:0 12px}
.p2p-reset:focus-visible{outline:2px solid #fff;border-radius:8px}
.p2p-root[data-state="success"] .p2p-reset{opacity:1;pointer-events:auto}
.p2p-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)}
@media (prefers-reduced-motion:reduce){.p2p-root *,.p2p-root *::before,.p2p-root *::after{animation-duration:.01ms!important;animation-iteration-count:1!important;transition-duration:.01ms!important}.p2p-blob{animation:none}}
`;
const clamp01 = (t: number) => Math.max(0, Math.min(1, t));
const easeOut = (t: number) => 1 - Math.pow(1 - t, 3);
const easeBack = (t: number) => {
const c1 = 1.1;
const c3 = c1 + 1;
return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
};
const SUB_TEXT: Record<Phase, string> = {
idle: "Review your order, then tap pay.",
processing: "Processing your payment\u2026",
success: "Thank you! Your payment is now a purchase.",
};
export default function PaymentToPurchase() {
const uid = useId().replace(/:/g, "");
const [phase, setPhase] = useState<Phase>("idle");
const [receipt, setReceipt] = useState({ order: "", date: "" });
const wrapRef = useRef<HTMLDivElement>(null);
const svgRef = useRef<SVGSVGElement>(null);
const payRef = useRef<HTMLButtonElement>(null);
const iconRef = useRef<HTMLDivElement>(null);
const resetRef = useRef<HTMLButtonElement>(null);
const rectRef = useRef<SVGRectElement>(null);
const circleRef = useRef<SVGCircleElement>(null);
const dropRef = useRef<SVGCircleElement>(null);
const sheenRectRef = useRef<SVGRectElement>(null);
const sheenDotRef = useRef<SVGEllipseElement>(null);
const progress = useRef(0);
const width = useRef(300);
const raf = useRef(0);
const timer = useRef<number | undefined>(undefined);
const busy = useRef(false);
const draw = useCallback(() => {
const p = progress.current;
const W = width.current;
const leftWidth = W - 80 * easeOut(p);
const cx = W / 2 + (W / 2 - 32) * easeBack(clamp01((p - 0.1) / 0.9));
rectRef.current?.setAttribute("width", String(leftWidth));
circleRef.current?.setAttribute("cx", String(cx));
dropRef.current?.setAttribute("cx", String(W * 0.5 + (W / 2 - 48) * easeOut(p)));
dropRef.current?.setAttribute("r", String(13 * Math.sin(Math.PI * p)));
sheenRectRef.current?.setAttribute("width", String(Math.max(0, leftWidth - 28)));
sheenDotRef.current?.setAttribute("cx", String(cx));
sheenDotRef.current?.setAttribute("opacity", String(clamp01((p - 0.5) / 0.4) * 0.7));
if (payRef.current) payRef.current.style.width = leftWidth + "px";
if (iconRef.current) {
iconRef.current.style.left = cx - 32 + "px";
iconRef.current.style.opacity = String(clamp01((p - 0.55) / 0.35));
}
}, []);
const measure = useCallback(() => {
const wrap = wrapRef.current;
const svg = svgRef.current;
if (!wrap || !svg) return;
width.current = wrap.clientWidth;
svg.setAttribute("width", String(width.current));
svg.setAttribute("viewBox", `0 0 ${width.current} 64`);
draw();
}, [draw]);
const animateTo = useCallback(
(to: number, duration: number, done?: () => void) => {
cancelAnimationFrame(raf.current);
const reduce =
typeof window !== "undefined" &&
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
if (reduce) {
progress.current = to;
draw();
done?.();
return;
}
const from = progress.current;
const start = performance.now();
const step = (now: number) => {
const k = clamp01((now - start) / duration);
progress.current = from + (to - from) * k;
draw();
if (k < 1) raf.current = requestAnimationFrame(step);
else done?.();
};
raf.current = requestAnimationFrame(step);
},
[draw]
);
useEffect(() => {
measure();
const wrap = wrapRef.current;
if (!wrap || typeof ResizeObserver === "undefined") return;
const ro = new ResizeObserver(measure);
ro.observe(wrap);
return () => ro.disconnect();
}, [measure]);
useEffect(
() => () => {
cancelAnimationFrame(raf.current);
window.clearTimeout(timer.current);
},
[]
);
const handlePay = () => {
if (phase !== "idle" || busy.current) return;
busy.current = true;
setPhase("processing");
animateTo(1, 1000);
timer.current = window.setTimeout(() => {
setReceipt({
order: "INV-" + Math.floor(100000 + Math.random() * 900000),
date: new Date().toLocaleDateString("en-US", {
weekday: "short",
month: "short",
day: "numeric",
year: "numeric",
}),
});
setPhase("success");
resetRef.current?.focus({ preventScroll: true });
}, 2400);
};
const handleReset = () => {
window.clearTimeout(timer.current);
setPhase("idle");
animateTo(0, 900, () => {
busy.current = false;
payRef.current?.focus({ preventScroll: true });
});
};
const message = receipt.order ? `Payment received. Order ${receipt.order} is being prepared.` : "";
const liveText =
phase === "processing" ? "Processing payment" : phase === "success" ? `Purchase complete. ${message}` : "";
const gooId = `${uid}-goo`;
return (
<div className="p2p-root" data-state={phase}>
<style>{CSS}</style>
<div className="p2p-bg" aria-hidden="true">
<div className="p2p-blob p2p-b1" />
<div className="p2p-blob p2p-b2" />
<div className="p2p-blob p2p-b3" />
</div>
<main className="p2p-main">
<section className="p2p-card">
<h1 className="p2p-title" aria-live="polite">
<span className="p2p-t-pay">Payment</span>
<span className="p2p-t-buy">Purchase</span>
</h1>
<p className="p2p-store">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M17 8h1a3 3 0 010 6h-1" />
<path d="M3 8h14v6a5 5 0 01-5 5H8a5 5 0 01-5-5V8z" />
<path d="M6 2v2M10 2v2M14 2v2" />
</svg>
Cascade Coffee Roasters
</p>
<p className="p2p-sub">{SUB_TEXT[phase]}</p>
<ul className="p2p-items">
<li>
<div>
Arabica Coffee 250 g<small>Qty: 1</small>
</div>
<span>$18.00</span>
</li>
<li>
<div>
Pour-over Kettle<small>Qty: 1</small>
</div>
<span>$42.00</span>
</li>
</ul>
<div className="p2p-total">
<span>Total</span>
<b>$60.00</b>
</div>
<div className="p2p-receipt">
<div>
<div className="p2p-rbox">
<p>{message}</p>
<dl className="p2p-meta">
<div><dt>Store</dt><dd>Cascade Coffee Roasters</dd></div>
<div><dt>City</dt><dd>Portland, OR, USA</dd></div>
<div><dt>Date</dt><dd>{receipt.date}</dd></div>
</dl>
</div>
</div>
</div>
<div className="p2p-wrap" ref={wrapRef}>
<div className="p2p-stage">
<svg ref={svgRef} height="64" aria-hidden="true">
<defs>
<linearGradient id={`${uid}-gB`} x1="0.1" y1="0" x2="0.3" y2="1">
<stop offset="0" stopColor="#ffffff" />
<stop offset=".3" stopColor="#d6dae0" />
<stop offset=".48" stopColor="#f6f8fa" />
<stop offset=".75" stopColor="#b3b9c2" />
<stop offset="1" stopColor="#e8ebef" />
</linearGradient>
<linearGradient id={`${uid}-gG`} x1="0.1" y1="0" x2="0.3" y2="1">
<stop offset="0" stopColor="#8af3c6" />
<stop offset=".3" stopColor="#1fbf77" />
<stop offset=".48" stopColor="#72e6b0" />
<stop offset=".75" stopColor="#0b8754" />
<stop offset="1" stopColor="#3fd69a" />
</linearGradient>
<linearGradient id={`${uid}-sheen`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="#fff" stopOpacity=".6" />
<stop offset="1" stopColor="#fff" stopOpacity="0" />
</linearGradient>
<filter id={gooId} x="-10%" y="-60%" width="120%" height="220%" colorInterpolationFilters="sRGB">
<feGaussianBlur in="SourceGraphic" stdDeviation="6" />
<feColorMatrix values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 24 -10" />
</filter>
<g id={`${uid}-shapes`}>
<rect ref={rectRef} x="0" y="0" width="300" height="64" rx="32" />
<circle ref={circleRef} cx="150" cy="32" r="32" />
<circle ref={dropRef} cx="150" cy="32" r="0" />
</g>
</defs>
<g filter={`url(#${gooId})`}>
<use href={`#${uid}-shapes`} fill={`url(#${uid}-gB)`} />
</g>
<g filter={`url(#${gooId})`} className="p2p-green">
<use href={`#${uid}-shapes`} fill={`url(#${uid}-gG)`} />
</g>
<rect ref={sheenRectRef} x="14" y="6" width="272" height="20" rx="10" fill={`url(#${uid}-sheen)`} opacity=".7" />
<ellipse ref={sheenDotRef} cx="150" cy="17" rx="17" ry="8" fill={`url(#${uid}-sheen)`} opacity="0" />
</svg>
<button ref={payRef} className="p2p-pay" type="button" aria-label="Pay" onClick={handlePay}>
<span className="p2p-lb p2p-pay1">Pay</span>
<span className="p2p-lb p2p-proc" aria-hidden="true">Processing…</span>
<span className="p2p-lb p2p-done" aria-hidden="true">Payment successful</span>
</button>
<div ref={iconRef} className="p2p-icon" aria-hidden="true">
<span className="p2p-spin"><i /></span>
<span className="p2p-check">
<svg viewBox="0 0 32 32"><path d="M9.5 16.5l4.5 4.5 8.5-10" /></svg>
</span>
</div>
<span className="p2p-ring" aria-hidden="true" />
</div>
</div>
<button ref={resetRef} className="p2p-reset" type="button" onClick={handleReset}>
Replay demo
</button>
<div className="p2p-sr" role="status">{liveText}</div>
</section>
</main>
</div>
);
}














