Coin
- Source
- After bencho.dev ↗
- Author
- Interaction after bencho.dev
- Licence
- Original code — interaction after bencho.dev
Loading…
Click the coin to flip it and keep a tally of heads and tails.
Code
"use client";
import { animate, motion, useMotionValue, useTransform } from "motion/react";
import { useRef, useState } from "react";
import { Surface } from "../ui";
const LAYERS = 7;
function Face({ back }: { back?: boolean }) {
return (
<div
className="absolute inset-0 grid place-items-center rounded-full [backface-visibility:hidden]"
style={{
transform: back ? `rotateX(180deg) translateZ(${LAYERS / 2}px)` : `translateZ(${LAYERS / 2}px)`,
background: "radial-gradient(circle at 35% 30%, #fdfdfb 0, #e3e2dc 45%, #b9b8b1 100%)",
boxShadow: "inset 0 0 0 0.35em #d2d1cb, inset 0 0 0 0.45em #f7f6f2, inset 0 0 0 0.9em #dddcd6",
}}
>
{back ? (
<svg viewBox="0 0 24 24" className="h-[2.6em] w-[2.6em] text-[#8a8984]" fill="currentColor">
<path d="m12 2.8 2.7 5.6 6.1.9-4.4 4.3 1 6.1L12 16.8l-5.4 2.9 1-6.1-4.4-4.3 6.1-.9z" />
</svg>
) : (
<span className="font-serif text-[3em] leading-none text-[#77766f] [text-shadow:0_1px_0_#fff,0_-1px_0_rgba(0,0,0,0.15)]">B</span>
)}
</div>
);
}
export default function Coin() {
const rot = useMotionValue(0);
const lift = useMotionValue(0);
const [tally, setTally] = useState({ heads: 0, tails: 0 });
const [last, setLast] = useState<"heads" | "tails" | null>(null);
const busy = useRef(false);
const shadowScale = useTransform(lift, [-110, 0], [0.45, 1]);
const shadowOpacity = useTransform(lift, [-110, 0], [0.08, 0.22]);
const flip = async (e: React.MouseEvent<HTMLButtonElement>) => {
const peak = -e.currentTarget.getBoundingClientRect().height * 0.85;
if (busy.current) return;
busy.current = true;
setLast(null);
const heads = Math.random() < 0.5;
const base = Math.ceil(rot.get() / 360) * 360;
const target = base + 360 * 4 + (heads ? 0 : 180);
animate(lift, [0, peak, 0], { duration: 1.05, times: [0, 0.45, 1], ease: ["easeOut", "easeIn"] });
await animate(rot, target, { duration: 1.05, ease: [0.25, 0.6, 0.35, 1] });
await animate(lift, [0, -14, 0], { duration: 0.28, ease: "easeOut" });
const side = heads ? "heads" : "tails";
setLast(side);
setTally((t) => ({ ...t, [side]: t[side] + 1 }));
busy.current = false;
};
return (
<Surface>
<div className="relative flex translate-y-[1.6em] flex-col items-center">
<motion.button
type="button"
aria-label="Flip the coin"
onClick={flip}
whileHover={{ scale: 1.04 }}
whileTap={{ scale: 0.95 }}
style={{ y: lift }}
className="relative h-[7em] w-[7em] cursor-pointer [perspective:600px]"
>
<motion.div className="absolute inset-0 [transform-style:preserve-3d]" style={{ rotateX: rot }}>
{Array.from({ length: LAYERS }, (_, i) => (
<div
key={i}
className="absolute inset-0 rounded-full bg-[#a9a8a1]"
style={{ transform: `translateZ(${i - LAYERS / 2 + 0.5}px)` }}
/>
))}
<Face />
<Face back />
</motion.div>
</motion.button>
<motion.div
className="mt-[1.4em] h-[0.9em] w-[6em] rounded-[50%] bg-black blur-[4px]"
style={{ scale: shadowScale, opacity: shadowOpacity }}
/>
<div className="mt-[1.2em] flex items-center gap-[1em] font-mono text-[0.72em] text-[#8a8984]">
<span className={last === "heads" ? "text-[#151515]" : ""}>heads {tally.heads}</span>
<span className="h-[1em] w-px bg-[#c9c8c3]" />
<span className={last === "tails" ? "text-[#151515]" : ""}>tails {tally.tails}</span>
</div>
</div>
</Surface>
);
}
Prompt
Build "Coin", a button animation. What it does: Click the coin to flip it and keep a tally of heads and tails. Write it as a single self-contained React component in TypeScript that fills its parent (w-full h-full), styled with Tailwind CSS, using motion. Technique: an HTML canvas, the Motion library, inline SVG, pointer tracking. It sits on a light background, in a 16:10 frame. Respect prefers-reduced-motion with a still state, pause work when the element is off screen, and keep it accessible. Original: https://bencho.dev (Interaction after bencho.dev, Original code — interaction after bencho.dev).