Checkout card
A payment form that fills in the card as you type
The card on the left fills in as you type, a soft ring follows the field you're in, and it turns over when you get to the security code. It knows Visa, Mastercard and Amex from the first digits and changes color to match. Press pay and it slides into a reader that prints the receipt. Nothing is charged, so connect the pay button to your own payment provider. Plain HTML, CSS and JavaScript, no libraries.
Free to use in personal and client work. No credit needed. Put the three files in one folder and open index.html, or copy them into your project below.
Want all of them, plus React versions? Join the Motion Pack waitlist.
index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Checkout card</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="aura" aria-hidden="true"><i></i></div>
<main class="checkout">
<section class="stage" aria-hidden="true">
<div class="rig">
<div class="pocket">
<div class="drop">
<div class="halo"></div>
<div class="shadow"></div>
<div class="card">
<div class="face front">
<span class="skin" data-brand="plain"></span>
<span class="skin" data-brand="visa"></span>
<span class="skin" data-brand="mastercard"></span>
<span class="skin" data-brand="amex"></span>
<svg class="lines" viewBox="0 0 400 252" preserveAspectRatio="none">
<g fill="none" stroke="#fff">
<circle cx="360" cy="40" r="90" /><circle cx="360" cy="40" r="130" /><circle cx="360" cy="40" r="170" />
<circle cx="360" cy="40" r="210" /><circle cx="360" cy="40" r="250" /><circle cx="360" cy="40" r="290" />
</g>
</svg>
<svg class="chip" viewBox="0 0 46 34">
<defs>
<linearGradient id="gold" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#f8e3a6" /><stop offset="0.45" stop-color="#d4ab5a" /><stop offset="1" stop-color="#f3d993" />
</linearGradient>
</defs>
<rect x="0.5" y="0.5" width="45" height="33" rx="6" fill="url(#gold)" stroke="rgba(120,85,20,0.35)" />
<path d="M0.5 11.5h13M0.5 22.5h13M32.5 11.5h13M32.5 22.5h13M13.5 0.5v33M32.5 0.5v33M13.5 17h19" stroke="rgba(120,85,20,0.45)" fill="none" />
<rect x="17" y="7" width="12" height="20" rx="3" fill="none" stroke="rgba(120,85,20,0.45)" />
</svg>
<svg class="wave" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round">
<path d="M8.5 9.5a4 4 0 0 1 0 5M11.5 7a8 8 0 0 1 0 10M14.5 4.5a12 12 0 0 1 0 15" />
</svg>
<div class="logos">
<span class="logo" data-brand="plain"><i></i><i></i></span>
<span class="logo" data-brand="visa">VISA</span>
<span class="logo" data-brand="mastercard"><i></i><i></i></span>
<span class="logo" data-brand="amex">AMEX</span>
</div>
<div class="cid"><span class="value" data-ticker="cid"></span></div>
<div class="number" data-ticker="number"></div>
<div class="holder">
<small>Card holder</small>
<span class="value" data-ticker="name"></span>
</div>
<div class="expiry">
<small>Expires</small>
<span class="value" data-ticker="expiry"></span>
</div>
<span class="ring"></span>
<span class="shimmer"></span>
<span class="shine"></span>
</div>
<div class="face back">
<span class="skin" data-brand="plain"></span>
<span class="skin" data-brand="visa"></span>
<span class="skin" data-brand="mastercard"></span>
<span class="skin" data-brand="amex"></span>
<span class="stripe"></span>
<div class="sign">
<span class="signature"></span>
<div class="cvc"><span class="value" data-ticker="cvc"></span></div>
</div>
<p class="fine">Security code</p>
<p class="small">This card is a demo. It can't be charged and isn't connected to a bank.</p>
<span class="holo"></span>
<span class="ring"></span>
<span class="shine"></span>
</div>
</div>
</div>
<div class="receipt">
<div class="paper">
<div class="badge">
<span class="pulse"></span>
<svg class="check" viewBox="0 0 52 52">
<circle class="disc" cx="26" cy="26" r="24" />
<circle class="loop" cx="26" cy="26" r="24" pathLength="1" />
<path class="tick" d="M15.5 27l7 7 14-15" pathLength="1" />
</svg>
</div>
<p class="done">Payment complete</p>
<p class="paid">$0.00</p>
<dl>
<div><dt>Paid with</dt><dd data-receipt="card"></dd></div>
<div><dt>Name</dt><dd data-receipt="name"></dd></div>
<div><dt>Date</dt><dd data-receipt="date"></dd></div>
</dl>
<span class="barcode"></span>
</div>
</div>
</div>
<div class="reader"><span class="mouth"></span></div>
<p class="status">Processing payment<span>.</span><span>.</span><span>.</span></p>
</div>
</section>
<form class="pay" novalidate>
<p class="eyebrow">Checkout</p>
<h1>Payment details</h1>
<div class="order">
<span class="thumb" aria-hidden="true"></span>
<div><strong>Studio plan</strong><span>Billed yearly</span></div>
<b class="amount">$129.00</b>
</div>
<label class="field">
<span>Name on card</span>
<input name="name" autocomplete="cc-name" placeholder="Maya Lindqvist" spellcheck="false" />
</label>
<label class="field">
<span>Card number</span>
<span class="control">
<input name="number" inputmode="numeric" autocomplete="cc-number" placeholder="1234 1234 1234 1234" />
<span class="hint" aria-live="polite"></span>
</span>
</label>
<div class="row">
<label class="field">
<span>Expiry</span>
<input name="expiry" inputmode="numeric" autocomplete="cc-exp" placeholder="MM / YY" />
</label>
<label class="field">
<span>Security code</span>
<input name="cvc" inputmode="numeric" autocomplete="cc-csc" placeholder="CVC" />
</label>
</div>
<button class="submit" type="submit"><span class="label">Pay $129.00</span></button>
<p class="note">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="4.5" y="10.5" width="15" height="10" rx="2.5" /><path d="M8 10.5V8a4 4 0 0 1 8 0v2.5" />
</svg>
A demo. Nothing is sent or charged.
</p>
</form>
</main>
<button class="again" type="button">Start over</button>
<p class="sr-only" role="status" aria-live="polite"></p>
<canvas class="confetti" aria-hidden="true"></canvas>
<script src="script.js"></script>
</body>
</html>style.css
:root {
--ground: #f4f0ea;
--ink: #1d1b24;
--muted: rgba(29, 27, 36, 0.56);
--line: rgba(29, 27, 36, 0.12);
--accent: 155, 130, 245;
--green: #2fb37a;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100dvh;
overflow: hidden;
background: var(--ground);
color: var(--ink);
font: 15px/1.45 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
-webkit-font-smoothing: antialiased;
}
/* A soft light behind the card, tinted by the card's brand. script.js moves it. */
.aura { position: fixed; inset: 0; pointer-events: none; background: radial-gradient(60% 50% at 50% 40%, rgba(255, 255, 255, 0.85), transparent 75%); }
.aura i {
position: absolute;
left: 0;
top: 0;
width: 560px;
height: 560px;
margin: -280px 0 0 -280px;
border-radius: 50%;
background: rgb(var(--accent));
opacity: 0.26;
filter: blur(90px);
will-change: transform;
}
.checkout {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(300px, 380px);
align-items: center;
gap: clamp(20px, 4vw, 56px);
min-height: 100dvh;
padding: 24px clamp(20px, 5vw, 64px);
}
/* ---------- The card ---------- */
.stage { position: relative; align-self: stretch; min-height: 300px; }
.rig { position: absolute; left: 50%; top: 50%; width: 0; height: 0; will-change: transform; }
/* Everything below the reader's slot is hidden, so the card can go in and the receipt can come out. */
.pocket { position: absolute; left: -1500px; top: -1500px; width: 3000px; height: calc(1500px + var(--clip, 1500px)); overflow: hidden; pointer-events: none; }
.drop {
position: absolute;
left: calc(1500px - var(--w) / 2);
top: calc(1500px - var(--h) / 2);
width: var(--w);
height: var(--h);
perspective: 1500px;
will-change: transform;
}
.halo { position: absolute; inset: -16px; border-radius: calc(var(--w) * 0.09); overflow: hidden; filter: blur(22px); opacity: 0; }
.halo::before {
content: "";
position: absolute;
left: 50%;
top: 50%;
width: 160%;
aspect-ratio: 1;
translate: -50% -50%;
rotate: var(--spin, 0deg);
background: conic-gradient(rgb(var(--accent)), #ffb892, #8ccdff, #b9a0ff, rgb(var(--accent)));
}
.shadow {
position: absolute;
left: 10%;
right: 10%;
bottom: -8%;
height: 40%;
border-radius: 50%;
background: rgba(30, 22, 60, 0.5);
filter: blur(22px);
opacity: 0.55;
}
.card {
position: absolute;
inset: 0;
font-size: calc(var(--w) / 25);
transform-style: preserve-3d;
will-change: transform;
}
.face {
position: absolute;
inset: 0;
border-radius: 1.15em;
overflow: hidden;
color: #fff;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.16), inset 0 1px 0 rgba(255, 255, 255, 0.32), inset 0 -1px 0 rgba(0, 0, 0, 0.18);
}
.back { transform: rotateY(180deg); }
/* One skin per brand. script.js fades between them. */
.skin { position: absolute; inset: 0; opacity: 0; }
.skin[data-brand="plain"] {
opacity: 1;
background:
radial-gradient(90% 120% at 0% 0%, #c4b2ff 0%, transparent 60%),
radial-gradient(80% 110% at 100% 100%, #ffb39a 0%, transparent 60%),
linear-gradient(135deg, #8b78ee 0%, #7c9cf2 55%, #b08cf0 100%);
}
.skin[data-brand="visa"] {
background:
radial-gradient(90% 120% at 100% 0%, #4f7dff 0%, transparent 58%),
radial-gradient(70% 90% at 0% 100%, #18245e 0%, transparent 70%),
linear-gradient(140deg, #0b1233 0%, #182a78 55%, #2c52d0 100%);
}
.skin[data-brand="mastercard"] {
background:
radial-gradient(90% 120% at 100% 100%, #ff8a4c 0%, transparent 58%),
radial-gradient(70% 90% at 0% 0%, #4a2140 0%, transparent 70%),
linear-gradient(140deg, #1a1217 0%, #3a1c2c 50%, #a63a3e 100%);
}
.skin[data-brand="amex"] {
background:
radial-gradient(90% 120% at 0% 100%, #82e3c3 0%, transparent 58%),
radial-gradient(70% 90% at 100% 0%, #0d3c37 0%, transparent 70%),
linear-gradient(140deg, #0a2b28 0%, #145448 55%, #3a957e 100%);
}
.lines { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0.09; }
.chip { position: absolute; left: 7.5%; top: 15%; width: 2.75em; filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.25)); }
.wave { position: absolute; left: calc(7.5% + 3.1em); top: calc(15% + 0.3em); width: 1.5em; opacity: 0.85; }
.logos { position: absolute; right: 7.5%; top: 12.5%; width: 4.6em; height: 2.4em; }
.logo { position: absolute; right: 0; top: 0; height: 100%; display: flex; align-items: center; justify-content: flex-end; opacity: 0; }
.logo[data-brand="plain"] i, .logo[data-brand="mastercard"] i { width: 1.9em; height: 1.9em; border-radius: 50%; }
.logo[data-brand="plain"] i { border: 1.5px solid rgba(255, 255, 255, 0.7); }
.logo[data-brand="plain"] i + i { margin-left: -0.75em; }
.logo[data-brand="mastercard"] i:first-child { background: #eb001b; }
.logo[data-brand="mastercard"] i + i { margin-left: -0.75em; background: #f79e1b; mix-blend-mode: screen; opacity: 0.95; }
.logo[data-brand="visa"] { font-size: 1.7em; font-weight: 800; font-style: italic; letter-spacing: -0.02em; }
.logo[data-brand="amex"] { font-size: 0.82em; font-weight: 800; letter-spacing: 0.08em; }
.logo[data-brand="amex"]::before { content: ""; position: absolute; inset: 0.55em -0.5em 0.55em -0.5em; border: 1.5px solid rgba(255, 255, 255, 0.85); border-radius: 0.3em; }
.value { display: inline-flex; white-space: pre; }
.slot { position: relative; display: inline-block; }
.slot[hidden] { display: none; }
.slot > span { display: inline-block; will-change: transform, opacity; }
.slot > .out { position: absolute; left: 0; top: 0; width: 100%; text-align: center; }
.dim { color: rgba(255, 255, 255, 0.42); }
.number {
position: absolute;
left: 7.5%;
top: 49%;
display: flex;
font-size: 1.62em;
font-weight: 500;
font-variant-numeric: tabular-nums;
letter-spacing: 0.02em;
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.3);
}
.number .slot { width: 0.66em; text-align: center; }
.number .gap { width: 0.42em; }
.holder, .expiry { position: absolute; bottom: 10%; display: grid; gap: 0.2em; }
.holder { left: 7.5%; max-width: 60%; }
.expiry { right: 7.5%; text-align: right; justify-items: end; }
.holder small, .expiry small, .fine { font-size: 0.56em; letter-spacing: 0.14em; text-transform: uppercase; opacity: 0.66; }
.holder .value, .expiry .value { font-size: 0.98em; font-weight: 500; letter-spacing: 0.08em; text-shadow: 0 1px 1px rgba(0, 0, 0, 0.25); }
.expiry .value { font-variant-numeric: tabular-nums; }
/* Amex prints its four digit code on the front, so the card doesn't turn over for it. */
.cid { position: absolute; right: 7.5%; top: 36%; font-size: 0.82em; font-weight: 600; letter-spacing: 0.1em; opacity: 0; }
.stripe { position: absolute; left: 0; right: 0; top: 13%; height: 19%; background: linear-gradient(#17151c, #26232c); }
.sign { position: absolute; left: 7.5%; right: 7.5%; top: 43%; height: 17%; display: flex; }
.signature {
flex: 1;
display: flex;
align-items: center;
padding: 0 0.8em;
border-radius: 0.3em 0 0 0.3em;
background: repeating-linear-gradient(-12deg, #f6f2ea 0 0.3em, #ebe5da 0.3em 0.6em);
color: #2b2a4c;
font: italic 1.3em/1 "Snell Roundhand", "Segoe Script", "Brush Script MT", cursive;
white-space: nowrap;
overflow: hidden;
}
.cvc {
display: grid;
place-items: center;
width: 4.4em;
border-radius: 0 0.3em 0.3em 0;
background: #fff;
color: #1d1b24;
font-size: 1.05em;
font-weight: 600;
font-style: italic;
letter-spacing: 0.08em;
}
.cvc .dim { color: rgba(29, 27, 36, 0.32); }
.fine { position: absolute; right: 7.5%; top: 62%; margin: 0; }
.small { position: absolute; left: 7.5%; bottom: 9%; width: 58%; margin: 0; font-size: 0.5em; line-height: 1.5; opacity: 0.55; }
.holo {
position: absolute;
right: 7.5%;
bottom: 10%;
width: 3.2em;
height: 2.2em;
border-radius: 0.4em;
background: conic-gradient(from var(--holo, 0deg), #ffd6f0, #c6e7ff, #d9ffe9, #fff2c4, #ffd6f0);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.5);
opacity: 0.85;
}
/* The soft frame that follows whichever field you're typing in. */
.ring {
position: absolute;
left: 0;
top: 0;
border-radius: 0.55em;
border: 1.5px solid rgba(255, 255, 255, 0.78);
background: rgba(255, 255, 255, 0.07);
box-shadow: 0 0 0 0.28em rgba(255, 255, 255, 0.1), 0 0 1.6em rgba(255, 255, 255, 0.35), inset 0 0 0.9em rgba(255, 255, 255, 0.14);
opacity: 0;
pointer-events: none;
}
.back .ring { border-color: rgba(var(--accent), 0.95); box-shadow: 0 0 0 0.28em rgba(var(--accent), 0.25), 0 0 1.6em rgba(var(--accent), 0.55); background: none; }
.shimmer { position: absolute; inset: 0; opacity: 0; background: linear-gradient(105deg, transparent 30%, rgba(255, 255, 255, 0.42) 50%, transparent 70%); }
.shine {
position: absolute;
inset: 0;
mix-blend-mode: soft-light;
background:
radial-gradient(circle at var(--sx, 30%) var(--sy, 20%), rgba(255, 255, 255, 0.75), transparent 46%),
linear-gradient(115deg, transparent calc(var(--gx, 40%) - 20%), rgba(255, 255, 255, 0.5) var(--gx, 40%), transparent calc(var(--gx, 40%) + 20%));
}
/* ---------- The reader and the receipt ---------- */
.reader {
position: absolute;
left: calc(var(--rw) / -2 - 1.6em);
top: calc(var(--slot) - 1.3em);
width: calc(var(--rw) + 3.2em);
height: 3.4em;
font-size: var(--u);
border-radius: 1.1em;
background: linear-gradient(#ffffff, #f1ece5);
box-shadow: 0 0 0 1px rgba(29, 27, 36, 0.05), inset 0 1px 0 #fff, 0 1.4em 2.6em -1.2em rgba(40, 30, 70, 0.4);
opacity: 0;
}
.reader::after {
content: "";
position: absolute;
left: 50%;
bottom: 0.6em;
width: 0.34em;
height: 0.34em;
margin-left: -0.17em;
border-radius: 50%;
background: var(--led, #ddd6cc);
box-shadow: 0 0 0.6em var(--led, transparent);
}
.mouth {
position: absolute;
left: 1em;
right: 1em;
top: 1em;
height: 0.6em;
border-radius: 1em;
background: linear-gradient(#0f0e13, #2b2832);
box-shadow: inset 0 2px 3px rgba(0, 0, 0, 0.7), 0 1px 0 #fff;
}
.receipt {
position: absolute;
left: calc(1500px - var(--rw) / 2);
top: 0;
width: var(--rw);
font-size: calc(var(--u) * 1.08);
filter: drop-shadow(0 0.7em 1.2em rgba(40, 30, 70, 0.18));
will-change: transform;
}
.paper {
--z: 0.42em;
padding: 1.7em 1.2em 1.8em;
background: #fffdf9;
text-align: center;
-webkit-mask: linear-gradient(#000 0 0) 0 var(--z) / 100% 100% no-repeat, conic-gradient(from 135deg at top, #0000, #000 1deg 90deg, #0000 91deg) 0 0 / calc(var(--z) * 2) var(--z) repeat-x;
mask: linear-gradient(#000 0 0) 0 var(--z) / 100% 100% no-repeat, conic-gradient(from 135deg at top, #0000, #000 1deg 90deg, #0000 91deg) 0 0 / calc(var(--z) * 2) var(--z) repeat-x;
}
.badge { position: relative; width: 3.3em; height: 3.3em; margin: 0 auto 0.8em; }
.pulse { position: absolute; inset: 0; border-radius: 50%; border: 2px solid var(--green); opacity: 0; }
.check { position: relative; display: block; width: 100%; height: 100%; overflow: visible; }
.disc { fill: var(--green); opacity: 0; }
.loop { fill: none; stroke: var(--green); stroke-width: 2.5; stroke-dasharray: 1; stroke-dashoffset: 1; transform: rotate(-90deg); transform-origin: center; }
.tick { fill: none; stroke: #fff; stroke-width: 4; stroke-linecap: round; stroke-linejoin: round; stroke-dasharray: 1; stroke-dashoffset: 1; }
.done { margin: 0; font-size: 0.9em; color: var(--muted); }
.paid { margin: 0.1em 0 0.7em; font-size: 2.1em; font-weight: 600; letter-spacing: -0.03em; font-variant-numeric: tabular-nums; }
.paper dl { margin: 0; padding-top: 0.75em; border-top: 1.5px dashed var(--line); font-size: 0.74em; }
.paper dl div { display: flex; justify-content: space-between; gap: 1em; padding: 0.22em 0; }
.paper dt { color: var(--muted); }
.paper dd { margin: 0; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.barcode {
display: block;
height: 1.7em;
margin: 0.9em 0.4em 0;
opacity: 0.8;
background:
repeating-linear-gradient(90deg, var(--ink) 0 2px, transparent 2px 5px, var(--ink) 5px 6px, transparent 6px 8px, var(--ink) 8px 11px, transparent 11px 13px),
repeating-linear-gradient(90deg, transparent 0 17px, #fffdf9 17px 19px);
}
.status { position: absolute; left: -12em; width: 24em; margin: 0; text-align: center; font-size: 14px; color: var(--muted); opacity: 0; }
.status span { display: inline-block; }
.again {
position: fixed;
left: 0;
top: 0;
padding: 0 18px;
height: 38px;
border: 1px solid var(--line);
border-radius: 999px;
background: rgba(255, 255, 255, 0.7);
color: var(--ink);
font: inherit;
font-size: 14px;
cursor: pointer;
opacity: 0;
visibility: hidden;
}
.again:hover { background: #fff; }
.confetti { position: fixed; inset: 0; width: 100%; height: 100%; pointer-events: none; z-index: 5; }
/* ---------- The form ---------- */
.pay {
position: relative;
padding: 24px 24px 18px;
border-radius: 24px;
background: rgba(255, 255, 255, 0.8);
box-shadow: 0 0 0 1px rgba(29, 27, 36, 0.05), 0 30px 60px -36px rgba(40, 30, 70, 0.4);
-webkit-backdrop-filter: blur(20px);
backdrop-filter: blur(20px);
will-change: transform, opacity;
}
.eyebrow { margin: 0; font-size: 12px; color: var(--muted); }
.pay h1 { margin: 0 0 14px; font-size: 22px; font-weight: 500; letter-spacing: -0.02em; }
.order { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; padding: 10px 12px; border-radius: 14px; background: rgba(244, 240, 234, 0.8); }
.order .thumb { width: 34px; height: 34px; flex: none; border-radius: 10px; background: linear-gradient(135deg, #b9a0ff, #8ccdff 60%, #ffb892); }
.order strong { display: block; font-size: 14px; font-weight: 500; line-height: 1.3; }
.order span { font-size: 12px; color: var(--muted); }
.order b { margin-left: auto; font-weight: 500; }
.field { display: grid; gap: 5px; margin-bottom: 11px; font-size: 12.5px; color: var(--muted); will-change: transform; }
.field input {
width: 100%;
height: 44px;
padding: 0 14px;
border: 1px solid var(--line);
border-radius: 12px;
background: #fff;
color: var(--ink);
font: inherit;
font-size: 15px;
font-variant-numeric: tabular-nums;
outline: none;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.field input::placeholder { color: rgba(29, 27, 36, 0.3); }
.field input:focus { border-color: rgba(var(--accent), 0.8); box-shadow: 0 0 0 4px rgba(var(--accent), 0.2); }
.field.is-wrong input { border-color: #e0685c; box-shadow: 0 0 0 4px rgba(224, 104, 92, 0.18); }
.control { position: relative; display: block; }
.control input { padding-right: 96px; }
.hint { position: absolute; right: 14px; top: 50%; translate: 0 -50%; font-size: 11px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: rgb(var(--accent)); }
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.submit {
width: 100%;
height: 48px;
margin-top: 5px;
border: 0;
border-radius: 999px;
background: var(--ink);
color: #fff;
font: inherit;
font-weight: 500;
cursor: pointer;
box-shadow: 0 12px 24px -12px rgba(29, 27, 36, 0.6);
transition: transform 0.25s cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 0.25s ease;
}
.submit:hover { transform: translateY(-1px); box-shadow: 0 16px 30px -12px rgba(29, 27, 36, 0.6); }
.submit:active { transform: translateY(1px) scale(0.99); }
.note { display: flex; align-items: center; justify-content: center; gap: 6px; margin: 11px 0 0; font-size: 12px; color: var(--muted); }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
@media (max-width: 760px) {
body { overflow: auto; }
.checkout { grid-template-columns: 1fr; align-content: start; gap: 8px; padding: 16px; }
.stage { min-height: 0; height: min(64vw, 300px); }
}
/* Short and narrow, like a demo frame on a page: drop the extras so the pay button fits. */
@media (max-width: 760px) and (max-height: 720px) {
.checkout { padding: 12px 16px; gap: 4px; }
.stage { height: min(58vw, 236px); }
.pay { padding: 18px 18px 16px; }
.eyebrow, .order, .note { display: none; }
.pay h1 { margin-bottom: 10px; font-size: 19px; }
.field { margin-bottom: 9px; }
.field input { height: 40px; }
}script.js
// Checkout card: the card fills in as you type, a soft ring follows the field
// you're in, it turns over for the security code, and when you pay it slides
// into a reader that prints a receipt. A demo only: nothing is sent or charged.
const still = matchMedia("(prefers-reduced-motion: reduce)").matches;
const AMOUNT = 129;
const PROCESSING = 1.6;
const $ = (selector, root = document) => root.querySelector(selector);
const form = $(".pay");
const fields = { name: form.elements.name, number: form.elements.number, expiry: form.elements.expiry, cvc: form.elements.cvc };
const stage = $(".stage");
const rig = $(".rig");
const pocket = $(".pocket");
const drop = $(".drop");
const card = $(".card");
const front = $(".front");
const halo = $(".halo");
const shadow = $(".shadow");
const shimmer = $(".shimmer");
const reader = $(".reader");
const receipt = $(".receipt");
const status = $(".status");
const again = $(".again");
const aura = $(".aura i");
const hint = $(".hint");
const announce = $('[role="status"]');
const canvas = $(".confetti");
const ctx = canvas.getContext("2d");
const brandParts = [...card.querySelectorAll("[data-brand]")];
// How to spot each brand from the first digits, how its number is grouped,
// how long its security code is, and the color the page takes on.
const BRANDS = {
visa: { match: /^4/, groups: [4, 4, 4, 4], code: 3, name: "Visa", glow: [79, 125, 255] },
mastercard: { match: /^(5[1-5]|2[2-7])/, groups: [4, 4, 4, 4], code: 3, name: "Mastercard", glow: [255, 128, 76] },
amex: { match: /^3[47]/, groups: [4, 6, 5], code: 4, name: "Amex", glow: [58, 196, 152] },
plain: { match: /^/, groups: [4, 4, 4, 4], code: 3, name: "Card", glow: [155, 130, 245] },
};
const brandOf = (digits) => Object.keys(BRANDS).find((key) => BRANDS[key].match.test(digits));
const digitsOf = (text) => text.replace(/\D/g, "");
const clamp = (v, lo = 0, hi = 1) => Math.min(hi, Math.max(lo, v));
const span = (t, a, b) => clamp((t - a) / (b - a));
const outCubic = (v) => 1 - (1 - v) ** 3;
const inCubic = (v) => v ** 3;
const inOut = (v) => (v < 0.5 ? 4 * v ** 3 : 1 - (-2 * v + 2) ** 3 / 2);
const outBack = (v) => 1 + 2.4 * (v - 1) ** 3 + 1.4 * (v - 1) ** 2;
const money = (n) => `$${n.toFixed(2)}`;
// A value that eases toward its target like a spring.
function spring(value, stiffness = 170, damping = 26) {
return { value, target: value, speed: 0, stiffness, damping };
}
function settle(s, dt) {
if (still) return void (s.value = s.target);
for (let left = dt; left > 0; left -= 1 / 240) {
const h = Math.min(left, 1 / 240);
s.speed += ((s.target - s.value) * s.stiffness - s.speed * s.damping) * h;
s.value += s.speed * h;
}
}
const motion = {
flip: spring(0, 105, 16),
rx: spring(0, 60, 12),
ry: spring(0, 60, 12),
away: spring(0, 120, 22),
center: spring(0, 65, 16),
lift: spring(0, 110, 15),
glow: spring(0, 60, 16),
appear: spring(1, 110, 20),
ringX: spring(0, 260, 30),
ringY: spring(0, 260, 30),
ringW: spring(0, 260, 30),
ringH: spring(0, 260, 30),
ringOn: spring(0, 150, 24),
...Object.fromEntries(Object.keys(BRANDS).map((key) => [key, spring(key === "plain" ? 1 : 0, 90, 18)])),
};
// Letters that slide into place. Each slot remembers the character it shows and
// the one it replaced, and the frame loop moves both by how long ago it changed.
class Ticker {
constructor(el) {
this.el = el;
this.slots = [];
}
show(chars, t, stagger = 0) {
chars.forEach(({ ch, dim }, i) => {
const slot = this.slots[i] ?? this.add();
if (slot.ch === ch && slot.dim === dim && !slot.gone) return;
slot.out.textContent = slot.gone ? "" : slot.in.textContent;
slot.out.className = slot.dim ? "out dim" : "out";
slot.in.textContent = ch;
slot.in.className = dim ? "dim" : "";
slot.el.classList.toggle("gap", ch === " ");
slot.el.hidden = false;
Object.assign(slot, { ch, dim, at: t + i * stagger, gone: false, done: false });
});
for (const slot of this.slots.slice(chars.length)) {
if (slot.gone) continue;
slot.out.textContent = slot.in.textContent;
slot.out.className = slot.dim ? "out dim" : "out";
slot.in.textContent = "";
Object.assign(slot, { ch: "", at: t, gone: true, done: false });
}
}
add() {
const el = document.createElement("span");
el.className = "slot";
el.innerHTML = '<span></span><span class="out"></span>';
this.el.append(el);
const slot = { el, in: el.firstChild, out: el.lastChild, ch: "", dim: false, at: 0, gone: false, done: true };
this.slots.push(slot);
return slot;
}
render(t) {
for (const slot of this.slots) {
if (slot.done) continue;
const k = still ? 1 : span(t - slot.at, 0, 420);
const e = outCubic(k);
slot.in.style.transform = k < 1 ? `translateY(${(1 - outBack(k)) * 0.7}em)` : "";
slot.in.style.filter = k < 1 ? `blur(${(1 - e) * 4}px)` : "";
slot.in.style.opacity = e;
slot.out.style.transform = `translateY(${-e * 0.7}em)`;
slot.out.style.opacity = 1 - e;
if (k < 1) continue;
slot.done = true;
slot.out.textContent = "";
slot.el.hidden = slot.gone;
}
}
}
const tickers = Object.fromEntries([...document.querySelectorAll("[data-ticker]")].map((el) => [el.dataset.ticker, new Ticker(el)]));
/* ---------- The fields ---------- */
let brand = "plain";
let clock = 0;
const complete = {
name: () => fields.name.value.trim().length > 1,
number: () => digitsOf(fields.number.value).length === BRANDS[brand].groups.reduce((a, b) => a + b),
expiry: () => /^(0[1-9]|1[0-2]) \/ \d\d$/.test(fields.expiry.value),
cvc: () => fields.cvc.value.length === BRANDS[brand].code,
};
function formatNumber(text) {
const digits = digitsOf(text);
const { groups } = BRANDS[brandOf(digits)];
let rest = digits.slice(0, groups.reduce((a, b) => a + b));
const parts = [];
for (const size of groups) {
if (!rest) break;
parts.push(rest.slice(0, size));
rest = rest.slice(size);
}
return parts.join(" ");
}
function formatExpiry(text, deleting) {
let digits = digitsOf(text).slice(0, 4);
if (/^[2-9]$/.test(digits)) digits = `0${digits}`;
if (digits.length > 2) return `${digits.slice(0, 2)} / ${digits.slice(2)}`;
return digits.length === 2 && !deleting ? `${digits} / ` : digits;
}
// Reformat a field as you type without throwing the caret to the end.
function reformat(input, format, event) {
const atEnd = input.selectionStart === input.value.length;
const before = digitsOf(input.value.slice(0, input.selectionStart)).length;
input.value = format(input.value, event.inputType?.startsWith("delete"));
if (atEnd) return true;
let caret = 0;
for (let seen = 0; caret < input.value.length && seen < before; caret++) if (/\d/.test(input.value[caret])) seen++;
input.setSelectionRange(caret, caret);
return false;
}
const typed = (value, placeholder) => [...placeholder].map((ch, i) => (value[i] ? { ch: value[i], dim: false } : { ch, dim: true }));
// Copy the form onto the card.
function refresh(stagger = 0) {
const digits = digitsOf(fields.number.value);
brand = brandOf(digits);
const { groups, code, name } = BRANDS[brand];
fields.cvc.value = fields.cvc.value.slice(0, code);
hint.textContent = brand === "plain" ? "" : name;
const number = [];
let i = 0;
groups.forEach((size, g) => {
if (g) number.push({ ch: " ", dim: false });
for (let k = 0; k < size; k++, i++) number.push(digits[i] ? { ch: digits[i], dim: false } : { ch: "•", dim: true });
});
tickers.number.show(number, clock, stagger);
const holder = fields.name.value.trim().toUpperCase();
tickers.name.show([...(holder || "YOUR NAME")].map((ch) => ({ ch, dim: !holder })), clock, stagger);
tickers.name.el.style.fontSize = holder.length > 17 ? `${(17 / holder.length).toFixed(3)}em` : "";
const expiry = digitsOf(fields.expiry.value);
tickers.expiry.show(typed(expiry.slice(0, 2), "MM").concat({ ch: "/", dim: !expiry }, typed(expiry.slice(2), "YY")), clock, stagger);
const cvc = typed(fields.cvc.value, "•".repeat(code));
tickers.cvc.show(cvc, clock);
tickers.cid.show(cvc, clock);
$(".signature").textContent = fields.name.value.trim();
}
fields.name.addEventListener("input", () => refresh());
fields.number.addEventListener("input", (event) => {
const atEnd = reformat(fields.number, formatNumber, event);
refresh();
if (atEnd && complete.number()) fields.expiry.focus();
});
fields.expiry.addEventListener("input", (event) => {
const atEnd = reformat(fields.expiry, formatExpiry, event);
refresh();
if (atEnd && complete.expiry()) fields.cvc.focus();
});
fields.cvc.addEventListener("input", () => {
fields.cvc.value = digitsOf(fields.cvc.value);
refresh();
});
let focused = null;
for (const [key, input] of Object.entries(fields)) {
input.addEventListener("focus", () => (focused = key));
input.addEventListener("blur", () => (focused = focused === key ? null : focused));
input.addEventListener("input", () => input.closest(".field").classList.remove("is-wrong"));
}
// Where the ring should sit on the card, in the card face's own pixels.
function ringTarget() {
if (!focused || phase !== "form") return null;
const el = { name: $(".holder"), number: $(".number"), expiry: $(".expiry"), cvc: brand === "amex" ? $(".cid") : $(".cvc") }[focused];
const face = el.closest(".face");
let x = 0;
let y = 0;
for (let node = el; node && node !== face; node = node.offsetParent) {
x += node.offsetLeft;
y += node.offsetTop;
}
const px = size.u * 0.55;
const py = size.u * 0.35;
return { face, x: x - px, y: y - py, w: el.offsetWidth + px * 2, h: el.offsetHeight + py * 2 };
}
/* ---------- Paying ---------- */
let phase = "form";
let phaseAt = 0;
let burstDone = false;
let wrong = null;
function setPhase(next) {
phase = next;
phaseAt = clock;
burstDone = false;
}
form.addEventListener("submit", (event) => {
event.preventDefault();
if (phase !== "form") return;
const missing = Object.keys(fields).find((key) => !complete[key]());
if (missing) {
wrong = { el: fields[missing].closest(".field"), at: clock };
wrong.el.classList.add("is-wrong");
fields[missing].focus();
return;
}
document.activeElement.blur();
const digits = digitsOf(fields.number.value);
$('[data-receipt="card"]').textContent = `${BRANDS[brand].name} •••• ${digits.slice(-4)}`;
$('[data-receipt="name"]').textContent = fields.name.value.trim();
$('[data-receipt="date"]').textContent = new Date().toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
layout();
setPhase("paying");
announce.textContent = "Processing payment";
});
again.addEventListener("click", () => {
setPhase("form");
Object.assign(motion.appear, { value: 0, speed: 0 });
pieces.length = 0;
announce.textContent = "";
});
/* ---------- Size and place ---------- */
const size = { w: 0, h: 0, u: 0, slot: 0, rh: 0, cx: 0, cy: 0, box: null };
function layout() {
const box = stage.getBoundingClientRect();
const w = Math.round(Math.min(box.width * 0.86, 440, (innerHeight - 64) / 1.12));
const h = Math.round(w / 1.586);
const u = w / 25;
Object.assign(size, { w, h, u, slot: Math.round(w * 0.53 + u * 1.7), box });
for (const [key, value] of Object.entries({ w, h, u, rw: Math.round(h * 1.1), slot: size.slot })) stage.style.setProperty(`--${key}`, `${value}px`);
size.rh = receipt.offsetHeight;
size.cx = innerWidth / 2 - (box.left + box.width / 2);
size.cy = innerHeight / 2 - (box.top + box.height / 2);
canvas.width = Math.round(innerWidth * devicePixelRatio);
canvas.height = Math.round(innerHeight * devicePixelRatio);
}
addEventListener("resize", layout);
addEventListener("scroll", layout, { passive: true });
layout();
const pointer = { x: innerWidth * 0.75, y: innerHeight * 0.5 };
addEventListener("pointermove", (event) => {
pointer.x = event.clientX;
pointer.y = event.clientY;
});
/* ---------- Every frame ---------- */
let last = null;
let accent = "";
function frame(time) {
requestAnimationFrame(frame);
const dt = last === null ? 0 : Math.min(0.05, (time - last) / 1000);
if (last === null) refresh(28);
last = clock = time;
const s = (time - phaseAt) / 1000;
if (phase === "paying" && s >= PROCESSING) setPhase("paid");
const editing = phase === "form";
// The card leans toward your pointer and drifts a little when you're still.
const cardX = size.box.left + size.box.width / 2 + size.cx * motion.center.value;
const cardY = size.box.top + size.box.height / 2;
const look = editing && !still ? 1 : 0;
motion.ry.target = look * (clamp((pointer.x - cardX) / innerWidth, -0.5, 0.5) * 20 + Math.sin(time / 1700) * 2);
motion.rx.target = look * (clamp((cardY - pointer.y) / innerHeight, -0.5, 0.5) * 16 + Math.cos(time / 1400) * 1.5);
// It turns over while you type the code on the back, and steps forward to pay.
motion.flip.target = editing && focused === "cvc" && brand !== "amex" ? 1 : 0;
motion.away.target = motion.center.target = motion.lift.target = editing ? 0 : 1;
motion.glow.target = phase === "paying" ? 1 : 0;
motion.appear.target = 1;
for (const key of Object.keys(BRANDS)) motion[key].target = key === brand ? 1 : 0;
const ring = ringTarget();
motion.ringOn.target = ring ? 1 : 0;
if (ring) {
const snap = motion.ringOn.value < 0.05;
for (const [key, value] of [["ringX", ring.x], ["ringY", ring.y], ["ringW", ring.w], ["ringH", ring.h]]) {
motion[key].target = value;
if (snap) Object.assign(motion[key], { value, speed: 0 });
}
ringFace = ring.face;
}
for (const value of Object.values(motion)) settle(value, dt);
paint(time, s);
tickers.number.render(time);
tickers.name.render(time);
tickers.expiry.render(time);
tickers.cvc.render(time);
tickers.cid.render(time);
confetti(dt);
}
let ringFace = front;
function paint(time, s) {
const m = Object.fromEntries(Object.entries(motion).map(([key, value]) => [key, value.value]));
const paid = phase === "paid";
const t = still ? 99 : s;
// The form steps aside while you pay.
form.style.opacity = 1 - m.away;
form.style.transform = `translateX(${m.away * 32}px) scale(${1 - m.away * 0.03})`;
form.style.filter = m.away > 0.01 ? `blur(${m.away * 8}px)` : "";
form.inert = phase !== "form";
// Paid: the card turns upright and drops into the reader, then the receipt prints.
const turn = paid ? inOut(span(t, 0, 0.55)) : 0;
const fall = paid ? inCubic(span(t, 0.6, 1.0)) : 0;
const print = paid ? span(t, 1.05, 2.0) : 0;
const rise = outCubic(print) + Math.sin(print * Math.PI * 14) * (1 - print) * 0.006;
const settleUp = paid ? inOut(span(t, 0.95, 2.0)) : 0;
const top = size.slot - size.rh + size.u * 0.6;
const middle = (top + size.slot + size.u * 2.1 + 52) / 2;
rig.style.transform = `translate(${size.cx * m.center}px, ${size.cy * m.center - middle * settleUp}px)`;
pocket.style.setProperty("--clip", paid ? `${size.slot}px` : "1500px");
const lift = m.lift * (1 - turn);
const sink = fall * (size.slot + size.w * 0.53 + 20);
drop.style.transform =
`translateY(${-lift * 14 + sink + (1 - m.appear) * 18}px) rotate(${-90 * turn}deg) scale(${(1 + m.lift * 0.06) * (0.94 + 0.06 * m.appear)})`;
drop.style.opacity = m.appear;
card.style.transform = `rotateX(${m.rx}deg) rotateY(${m.ry + m.flip * 180}deg)`;
// Light: a highlight that slides with the tilt, and a shadow that spreads as the card lifts.
card.style.setProperty("--sx", `${30 - m.ry * 3}%`);
card.style.setProperty("--sy", `${20 + m.rx * 3}%`);
card.style.setProperty("--gx", `${45 - m.ry * 4}%`);
card.style.setProperty("--holo", `${m.ry * 8 + time * 0.03}deg`);
shadow.style.transform = `translate(${-m.ry * 1.5}px, ${m.lift * 18 + m.rx * 0.8}px) scale(${1 + m.lift * 0.12})`;
shadow.style.opacity = (0.55 - m.lift * 0.2) * (1 - turn);
halo.style.opacity = m.glow * 0.9;
halo.style.setProperty("--spin", `${time * 0.14}deg`);
shimmer.style.opacity = m.glow;
shimmer.style.transform = `translateX(${(((time - phaseAt) / 1000 / 1.1) % 1) * 240 - 120}%)`;
status.style.top = `${size.h * 0.53 + 22}px`;
status.style.opacity = m.glow;
[...status.children].forEach((dot, i) => (dot.style.opacity = 0.25 + 0.75 * (0.5 + 0.5 * Math.sin(time * 0.008 - i * 1.1))));
// The ring and the brand.
for (const face of card.children) {
const el = $(".ring", face);
el.style.opacity = face === ringFace ? m.ringOn : 0;
el.style.transform = `translate(${m.ringX}px, ${m.ringY}px)`;
el.style.width = `${m.ringW}px`;
el.style.height = `${m.ringH}px`;
}
for (const el of brandParts) {
const w = m[el.dataset.brand];
el.style.opacity = el.classList.contains("skin") && el.dataset.brand === "plain" ? 1 : w;
if (el.classList.contains("logo")) el.style.transform = `translateY(${(1 - w) * 0.5}em) scale(${0.85 + 0.15 * w})`;
}
$(".cid").style.opacity = m.amex;
const glow = [0, 1, 2].map((c) => Math.round(Object.keys(BRANDS).reduce((sum, key) => sum + BRANDS[key].glow[c] * m[key], 0)));
if (glow.join() !== accent) document.documentElement.style.setProperty("--accent", (accent = glow.join()));
aura.style.transform = `translate(${size.box.left + size.box.width / 2 + size.cx * m.center}px, ${size.box.top + size.box.height / 2 + size.cy * m.center}px)`;
// The reader, the receipt and the check.
const show = paid ? outCubic(span(t, 0.1, 0.6)) : 0;
const bump = Math.sin(span(t, 0.98, 1.25) * Math.PI) * size.u * 0.16;
reader.style.opacity = show;
const blink = paid && t >= 1.0 && t < 2.2 && Math.sin(t * 18) > 0;
reader.style.setProperty("--led", paid && t >= 2.2 ? "#2fb37a" : blink ? `rgb(${accent})` : "");
reader.style.transform = `translateY(${(1 - show) * size.u * 1.2 + (paid ? bump : 0)}px)`;
receipt.style.transform = `translateY(${1500 + top + (1 - rise) * (size.rh + 8)}px)`;
receipt.style.opacity = paid ? (still ? span(s, 0, 0.3) : 1) : 0;
const loop = outCubic(span(t, 1.9, 2.3));
const pop = span(t, 2.15, 2.6);
const ping = span(t, 2.35, 3.2);
$(".loop").style.strokeDashoffset = 1 - loop;
$(".disc").style.opacity = outCubic(span(t, 2.15, 2.4));
$(".tick").style.strokeDashoffset = 1 - outCubic(span(t, 2.25, 2.55));
$(".check").style.transform = `scale(${0.82 + 0.18 * outBack(pop)})`;
$(".pulse").style.opacity = ping > 0 && ping < 1 ? (1 - ping) * 0.6 : 0;
$(".pulse").style.transform = `scale(${1 + outCubic(ping) * 1.2})`;
$(".paid").textContent = money(AMOUNT * (paid ? outCubic(span(t, 1.2, 2.35)) : 0));
if (paid && t >= 2.3 && !burstDone) {
burstDone = true;
const b = $(".badge").getBoundingClientRect();
burst(b.left + b.width / 2, b.top + b.height / 2);
announce.textContent = `Payment complete, ${money(AMOUNT)}`;
}
const back = paid ? outCubic(span(t, 3.0, 3.4)) : 0;
again.style.opacity = back;
again.style.visibility = back > 0 ? "visible" : "hidden";
again.style.transform = `translate(${size.box.left + size.box.width / 2 + size.cx - again.offsetWidth / 2}px, ${
size.box.top + size.box.height / 2 + size.cy - middle * settleUp + size.slot + size.u * 2.1 + 14 + (1 - back) * 8
}px)`;
// A wrong or missing field gives a small shake.
if (wrong) {
const k = span(time - wrong.at, 0, 450);
wrong.el.style.transform = k < 1 ? `translateX(${Math.sin(k * Math.PI * 6) * (1 - k) * 7}px)` : "";
if (k === 1) wrong = null;
}
}
/* ---------- Confetti ---------- */
const pieces = [];
const COLORS = ["#b9a0ff", "#8ccdff", "#ffb892", "#ffd36e", "#7fe0c0", "#ff9fc4", "#7c6cf0"];
// The same burst every time, so a recording of it is repeatable.
function seeded(seed) {
return () => ((seed = (seed * 16807) % 2147483647) - 1) / 2147483646;
}
function burst(x, y) {
if (still) return;
const rand = seeded(11);
for (let i = 0; i < 150; i++) {
const side = i % 2 ? 1 : -1;
const angle = -Math.PI / 2 + side * (0.2 + rand() * 0.95);
const speed = 380 + rand() * 720;
pieces.push({
x, y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
turn: rand() * Math.PI * 2,
spin: (rand() - 0.5) * 12,
wobble: 5 + rand() * 9,
size: 7 + rand() * 7,
color: i % 9 === 0 ? `rgb(${accent})` : COLORS[i % COLORS.length],
round: rand() < 0.3,
age: 0,
});
}
}
let drawn = false;
function confetti(dt) {
if (!pieces.length && !drawn) return;
ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
ctx.clearRect(0, 0, innerWidth, innerHeight);
drawn = pieces.length > 0;
const drag = Math.exp(-2.4 * dt);
for (let i = pieces.length - 1; i >= 0; i--) {
const p = pieces[i];
p.age += dt;
p.vx *= drag;
p.vy = p.vy * drag + 1150 * dt;
p.x += p.vx * dt;
p.y += p.vy * dt;
p.turn += p.spin * dt;
const fade = 1 - span(p.age, 2.0, 2.8);
if (fade <= 0 || p.y > innerHeight + 40) {
pieces.splice(i, 1);
continue;
}
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(p.turn);
ctx.scale(1, Math.cos(p.age * p.wobble));
ctx.globalAlpha = fade;
ctx.fillStyle = p.color;
if (p.round) {
ctx.beginPath();
ctx.arc(0, 0, p.size * 0.42, 0, Math.PI * 2);
ctx.fill();
} else ctx.fillRect(-p.size / 2, -p.size * 0.3, p.size, p.size * 0.6);
ctx.restore();
}
}
requestAnimationFrame(frame);