Coin flip toggle
A billing switch drawn as a 3D coin that flips between monthly and yearly while the price rolls.
It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.
Demo.tsx
"use client";
import { useState, type CSSProperties, type KeyboardEvent } from "react";
import styles from "./demo.module.css";
const PRICE = { monthly: 24, yearly: 20 };
const DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
const EDGE = 10; // stacked discs that give the coin its thickness
// Each digit is a 0 to 9 strip that slides to its value, so a price change rolls instead of swapping.
function Roll({ value }: { value: string }) {
const chars = [...value];
return (
<span className={styles.digits} aria-hidden="true">
{chars.map((c, i) => {
const place = chars.length - i;
if (!/\d/.test(c)) return <span key={`s${place}`} className={styles.sym}>{c}</span>;
return (
<span key={`d${place}`} className={styles.digit}>
<span className={styles.strip} style={{ "--d": Number(c), "--i": place } as CSSProperties}>
{DIGITS.map((d) => <span key={d}>{d}</span>)}
</span>
</span>
);
})}
</span>
);
}
export default function Demo() {
// Every toggle adds half a turn, so the coin always flips the same way round.
const [turns, setTurns] = useState(0);
const yearly = turns % 2 === 1;
const set = (next: boolean) => { if (next !== yearly) setTurns((t) => t + 1); };
const keyDown = (e: KeyboardEvent) => {
if (e.key === "ArrowLeft") { e.preventDefault(); set(false); }
if (e.key === "ArrowRight") { e.preventDefault(); set(true); }
};
const hop = turns === 0 ? undefined : turns % 2 ? "a" : "b";
const price = yearly ? PRICE.yearly : PRICE.monthly;
return (
<div className={styles.root}>
<div className={styles.stack}>
<div className={styles.toggle}>
<span className={styles.side} data-on={!yearly} onClick={() => set(false)}>Monthly</span>
<button
type="button"
role="switch"
aria-checked={yearly}
aria-label="Bill yearly"
className={styles.coinButton}
onClick={() => setTurns((t) => t + 1)}
onKeyDown={keyDown}
>
<span className={styles.shadow} data-hop={hop} />
<span className={styles.hop} data-hop={hop}>
<span className={styles.coin} style={{ "--turns": turns } as CSSProperties}>
{Array.from({ length: EDGE }, (_, i) => (
<span key={i} className={styles.edge} style={{ "--z": i - (EDGE - 1) / 2 } as CSSProperties} />
))}
<span className={`${styles.face} ${styles.front}`}>mo</span>
<span className={`${styles.face} ${styles.back}`}>yr</span>
</span>
</span>
</button>
<span className={styles.side} data-on={yearly} onClick={() => set(true)}>Yearly</span>
</div>
<div className={styles.plan}>
<p className={styles.name}>Team plan</p>
<p className={styles.price}>
<Roll value={`$${price}`} />
<span className={styles.per}>/ seat / month</span>
</p>
<p className={styles.sr} aria-live="polite">
{yearly ? `$${PRICE.yearly} per seat per month, billed yearly, 2 months free` : `$${PRICE.monthly} per seat per month, billed monthly`}
</p>
<div className={styles.foot} aria-hidden="true">
<span className={styles.note}>{yearly ? `Billed $${PRICE.yearly * 12} yearly` : "Billed monthly"}</span>
{yearly && <span className={styles.badge}>2 months free</span>}
</div>
</div>
</div>
</div>
);
}
demo.module.css
.root {
container-type: size; width: 100%; height: 100%; display: grid; place-items: center; padding: 16px;
box-sizing: border-box; background: var(--surface);
}
.stack { display: grid; justify-items: center; gap: clamp(12px, 5cqh, 26px); }
.toggle { display: flex; align-items: center; gap: 14px; }
.side {
min-width: 64px; font-size: 14px; font-weight: 650; color: var(--muted); cursor: pointer; user-select: none;
transition: color .3s;
}
.side:first-child { text-align: right; }
.side[data-on="true"] { color: var(--ink); }
/* The switch is the coin. Perspective lives on the button so the flip has depth. */
.coinButton {
--size: clamp(56px, 22cqh, 96px);
position: relative; display: grid; place-items: center; width: calc(var(--size) + 20px); height: calc(var(--size) + 20px);
padding: 0; border: 0; border-radius: 50%; background: none; cursor: pointer; perspective: 480px;
-webkit-tap-highlight-color: transparent;
}
.coinButton:focus-visible { outline: 2px solid var(--accent-text); outline-offset: 2px; }
.hop, .shadow { grid-area: 1 / 1; }
.hop { display: grid; place-items: center; transform-style: preserve-3d; }
/* Two identical keyframes, alternated, so every flip restarts the hop. */
.hop[data-hop="a"] { animation: hop-a .8s var(--out); }
.hop[data-hop="b"] { animation: hop-b .8s var(--out); }
@keyframes hop-a { 35% { transform: translateY(-16px) scale(1.08); } }
@keyframes hop-b { 35% { transform: translateY(-16px) scale(1.08); } }
.shadow {
align-self: end; width: 70%; height: 8px; border-radius: 50%; translate: 0 -2px;
background: radial-gradient(closest-side, color-mix(in srgb, var(--ink) 22%, transparent), transparent);
}
.shadow[data-hop="a"] { animation: shade-a .8s var(--out); }
.shadow[data-hop="b"] { animation: shade-b .8s var(--out); }
@keyframes shade-a { 35% { scale: .6; opacity: .5; } }
@keyframes shade-b { 35% { scale: .6; opacity: .5; } }
.coin {
position: relative; width: var(--size); height: var(--size); transform-style: preserve-3d;
transform: rotateX(14deg) rotateY(calc(var(--turns) * 180deg));
transition: transform .9s var(--out);
}
/* Stacked discs, 1px apart, read as the coin's edge while it turns. */
.edge {
position: absolute; inset: 0; border-radius: 50%; background: var(--accent-text);
transform: translateZ(calc(var(--z) * 1px));
}
.face {
position: absolute; inset: 0; display: grid; place-items: center; border-radius: 50%; backface-visibility: hidden;
background: radial-gradient(circle at 34% 28%, var(--accent-pale), var(--accent-soft) 70%);
box-shadow: inset 0 0 0 4px var(--accent-soft), inset 0 0 0 5px var(--accent), inset 0 -8px 14px -8px var(--accent-text);
color: var(--accent-text); font-size: calc(var(--size) * .28); font-weight: 800; letter-spacing: -0.02em;
}
.front { transform: translateZ(5.5px); }
.back { transform: rotateY(180deg) translateZ(5.5px); }
.plan { display: grid; justify-items: center; gap: 6px; text-align: center; }
.plan p { margin: 0; }
.name { font-size: 12px; font-weight: 700; color: var(--muted); letter-spacing: .04em; text-transform: uppercase; }
.price { display: flex; align-items: baseline; gap: 6px; font-size: clamp(32px, min(10cqi, 14cqh), 52px); font-weight: 750; letter-spacing: -0.03em; font-variant-numeric: tabular-nums; }
.per { font-size: 14px; font-weight: 600; letter-spacing: 0; color: var(--muted); }
.digits { display: inline-flex; height: 1.1em; line-height: 1.1em; }
.digit { display: inline-block; height: 1.1em; overflow: hidden; }
.strip { display: flex; flex-direction: column; translate: 0 calc(var(--d) * -1.1em); transition: translate .7s var(--out) calc(var(--i) * 50ms); }
.strip span, .sym { display: block; height: 1.1em; }
.sym { display: inline-block; }
.foot { display: flex; align-items: center; gap: 8px; min-height: 24px; }
.note { font-size: 13px; color: var(--muted); }
.badge {
padding: 3px 9px; border-radius: 999px; background: var(--accent-pale); color: var(--accent-text); border: 1px solid var(--accent-soft);
font-size: 12px; font-weight: 700; white-space: nowrap; animation: pop .5s var(--out) .25s both;
}
@keyframes pop { 0% { opacity: 0; scale: .5; } 60% { opacity: 1; scale: 1.08; } 100% { scale: 1; } }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
@container (max-width: 300px) {
.toggle { gap: 8px; }
.side { min-width: 54px; font-size: 13px; }
}
@media (prefers-reduced-motion: reduce) {
.coin, .strip, .side { transition: none; }
.hop[data-hop], .shadow[data-hop] { animation: none; }
.badge { animation: none; }
}
Use it for
- Monthly and yearly billing switches on pricing pages
- Any two state choice that deserves a little delight
- Plans where the yearly saving should be noticed
Avoid
- Dense settings lists, where a plain switch scans faster
- Choices with more than two options
How it works
The coin is a preserve-3d element with two faces (backface-visibility hidden) and eight stacked discs 1px apart that read as its edge mid turn. Every toggle adds half a turn to a counter, so rotateY always flips the same way, and alternating two identical hop keyframes restarts the lift without remounting the coin. Each price digit is a 0 to 9 strip that slides with translate, and the savings badge pops in with a short scale overshoot.
Accessibility
The coin is a real button with role switch, aria-checked and the label Bill yearly, so Space and Enter toggle it, and the arrow keys pick monthly or yearly. The new price is announced through a polite live region. Reduced motion swaps the face, price and badge instantly.