Crypto QR
A wallet QR code that blooms from the coin logo whenever you switch coins.
Preview
Installation
pnpm dlx shadcn@latest add https://makeui.dev/r/crypto-qr.jsonDependencies
- motion
- lucide-react
- qrcode
- @types/qrcode
Interaction
Choose a coin and the logo makes one smooth turn and settles in place while waves spread out from it, the dots ripple from the centre and a band of light travels once around the frame. Grey in light mode, white in dark mode. With a single coin the picker hides.
Props
| Prop | Type | Description |
|---|---|---|
| coins | { symbol; label; address }[] | Coins to choose from. Defaults to BTC, ETH, SOL and USDT with sample addresses. |
| defaultSymbol | CoinSymbol | Coin shown first. |
| onChange | (symbol) => void | Called when the user picks a coin. |
| className | string | Extra classes merged onto the root element. |
How to use
import CryptoQr from "@/components/ui/crypto-qr"
export function Example() {
return (
<CryptoQr
coins={[
{
symbol: "BTC",
label: "Bitcoin (BTC)",
address: "bc1...",
},
]}
/>
)
}Source code
components/ui/crypto-qr.tsx
"use client"
import { useEffect, useMemo, useRef, useState } from "react"
import {
AnimatePresence,
motion,
useAnimationControls,
useReducedMotion,
} from "motion/react"
import QRCode from "qrcode"
import { Check, ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
import { CoinIcon, type CoinSymbol } from "./coin-icon"
export interface QrCoin {
symbol: CoinSymbol
label: string
address: string
}
export interface CryptoQrProps {
coins?: QrCoin[]
defaultSymbol?: CoinSymbol
onChange?: (symbol: CoinSymbol) => void
waves?: boolean
className?: string
}
const COINS: QrCoin[] = [
{
symbol: "BTC",
label: "Bitcoin (BTC)",
address: "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh",
},
{
symbol: "ETH",
label: "Ethereum (ETH)",
address: "0x71C7656EC7ab88b098defB751B7401B5f6d8976F",
},
{
symbol: "SOL",
label: "Solana (SOL)",
address: "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU",
},
{
symbol: "USDT",
label: "Tether (USDT)",
address: "TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE",
},
]
type Matrix = {
modules: {
size: number
get: (row: number, col: number) => number
}
}
const SPIN_DURATION = 0.75
function CoinDropdown({
coins,
symbol,
onPick,
reduce,
}: {
coins: QrCoin[]
symbol: CoinSymbol
onPick: (symbol: CoinSymbol) => void
reduce: boolean | null
}) {
const [open, setOpen] = useState(false)
const [highlighted, setHighlighted] = useState(0)
const root = useRef<HTMLDivElement>(null)
const trigger = useRef<HTMLButtonElement>(null)
const selected = coins.find((coin) => coin.symbol === symbol) ?? coins[0]
useEffect(() => {
if (!open) return
const handlePointerDown = (event: PointerEvent) => {
if (
root.current &&
!root.current.contains(event.target as Node)
) {
setOpen(false)
}
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setOpen(false)
trigger.current?.focus()
}
}
document.addEventListener("pointerdown", handlePointerDown)
document.addEventListener("keydown", handleKeyDown)
return () => {
document.removeEventListener("pointerdown", handlePointerDown)
document.removeEventListener("keydown", handleKeyDown)
}
}, [open])
const choose = (coin: QrCoin) => {
onPick(coin.symbol)
setOpen(false)
trigger.current?.focus()
}
const handleTriggerKeyDown = (
event: React.KeyboardEvent<HTMLButtonElement>
) => {
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault()
setOpen(true)
const index = coins.findIndex(
(coin) => coin.symbol === symbol
)
setHighlighted(
event.key === "ArrowDown"
? (index + 1) % coins.length
: (index - 1 + coins.length) % coins.length
)
}
}
return (
<div ref={root} className="relative z-30 w-full">
<button
ref={trigger}
type="button"
aria-label="Choose cryptocurrency"
aria-haspopup="listbox"
aria-expanded={open}
onClick={() => {
setOpen((value) => !value)
setHighlighted(
Math.max(
0,
coins.findIndex((coin) => coin.symbol === symbol)
)
)
}}
onKeyDown={handleTriggerKeyDown}
className={cn(
"group flex w-full items-center justify-between gap-3 rounded-2xl border px-3.5 py-3 text-left outline-none",
"border-neutral-200 bg-white text-neutral-900 shadow-sm",
"transition-[border-color,background-color,box-shadow] duration-200",
"hover:border-neutral-300 hover:bg-neutral-50",
"focus-visible:ring-2 focus-visible:ring-blue-500/40",
"dark:border-neutral-800 dark:bg-neutral-950 dark:text-neutral-100",
"dark:hover:border-neutral-700 dark:hover:bg-neutral-900"
)}
>
<span className="flex min-w-0 items-center gap-3">
<span className="grid size-9 shrink-0 place-items-center rounded-full bg-neutral-100 dark:bg-neutral-900">
<CoinIcon symbol={selected.symbol} />
</span>
<span className="flex min-w-0 flex-col gap-0.5">
<span className="truncate text-sm font-medium">
{selected.label}
</span>
<span className="text-xs text-neutral-500 dark:text-neutral-400">
Select network asset
</span>
</span>
</span>
<motion.span
animate={{ rotate: open ? 180 : 0 }}
transition={{
duration: reduce ? 0 : 0.22,
ease: [0.22, 1, 0.36, 1],
}}
className="shrink-0 text-neutral-500 dark:text-neutral-400"
>
<ChevronDown className="size-4" />
</motion.span>
</button>
<AnimatePresence>
{open && (
<motion.div
initial={
reduce
? { opacity: 0 }
: { opacity: 0, y: -6, scale: 0.98 }
}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={
reduce
? { opacity: 0 }
: { opacity: 0, y: -4, scale: 0.985 }
}
transition={{
duration: reduce ? 0.01 : 0.18,
ease: [0.22, 1, 0.36, 1],
}}
style={{ transformOrigin: "top center" }}
role="listbox"
aria-label="Cryptocurrency options"
className={cn(
"absolute inset-x-0 top-[calc(100%+8px)] overflow-hidden rounded-2xl border p-1.5",
"border-neutral-200 bg-white shadow-xl shadow-black/[0.07]",
"dark:border-neutral-800 dark:bg-neutral-950 dark:shadow-black/30"
)}
>
<div className="px-2.5 pb-2 pt-1.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-neutral-400 dark:text-neutral-500">
Your assets
</div>
<div className="space-y-0.5">
{coins.map((coin, index) => {
const active = coin.symbol === symbol
const focused = highlighted === index
return (
<motion.button
key={coin.symbol}
type="button"
role="option"
aria-selected={active}
onMouseEnter={() => setHighlighted(index)}
onClick={() => choose(coin)}
onKeyDown={(event) => {
if (event.key === "ArrowDown") {
event.preventDefault()
setHighlighted((index + 1) % coins.length)
}
if (event.key === "ArrowUp") {
event.preventDefault()
setHighlighted(
(index - 1 + coins.length) % coins.length
)
}
if (event.key === "Enter" || event.key === " ") {
event.preventDefault()
choose(coin)
}
}}
initial={reduce ? false : { opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: reduce ? 0 : 0.16,
delay: reduce ? 0 : index * 0.025,
}}
className={cn(
"flex w-full items-center gap-3 rounded-xl px-2.5 py-2.5 text-left outline-none",
"transition-colors duration-150",
active
? "bg-neutral-100 dark:bg-neutral-900"
: focused
? "bg-neutral-50 dark:bg-neutral-900/60"
: "hover:bg-neutral-50 dark:hover:bg-neutral-900/60"
)}
>
<span
className={cn(
"grid size-9 shrink-0 place-items-center rounded-full transition-colors",
active
? "bg-white dark:bg-neutral-800"
: "bg-neutral-100 dark:bg-neutral-900"
)}
>
<CoinIcon symbol={coin.symbol} />
</span>
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span
className={cn(
"truncate text-sm",
active
? "font-semibold text-neutral-950 dark:text-neutral-50"
: "font-medium text-neutral-700 dark:text-neutral-300"
)}
>
{coin.label}
</span>
<span className="text-xs text-neutral-500 dark:text-neutral-500">
{coin.symbol}
</span>
</span>
<span className="grid size-5 shrink-0 place-items-center">
{active && (
<Check className="size-4 text-blue-600 dark:text-blue-400" />
)}
</span>
</motion.button>
)
})}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}
export default function CryptoQr({
coins = COINS,
defaultSymbol,
onChange,
waves = true,
className,
}: CryptoQrProps) {
const reduce = useReducedMotion()
const [symbol, setSymbol] = useState<CoinSymbol>(
defaultSymbol ?? coins[0].symbol
)
const [turn, setTurn] = useState(0)
const coin =
coins.find((item) => item.symbol === symbol) ?? coins[0]
const spin = useAnimationControls()
const firstRun = useRef(true)
const { n, dots } = useMemo(() => {
const { modules } = QRCode.create(coin.address, {
errorCorrectionLevel: "H",
}) as unknown as Matrix
const n = modules.size
const dots: { x: number; y: number; d: number }[] = []
for (let r = 0; r < n; r++) {
for (let c = 0; c < n; c++) {
if (!modules.get(r, c)) continue
if (
(r < 7 && c < 7) ||
(r < 7 && c >= n - 7) ||
(r >= n - 7 && c < 7)
) {
continue
}
const dist = Math.hypot(
c + 0.5 - n / 2,
r + 0.5 - n / 2
)
if (dist < n * 0.15) continue
dots.push({
x: c + 0.5,
y: r + 0.5,
d: dist / (n * 0.72),
})
}
}
return { n, dots }
}, [coin.address])
useEffect(() => {
if (firstRun.current) {
firstRun.current = false
return
}
if (reduce) return
let cancelled = false
spin.set({ rotate: 0, scale: 1 })
spin
.start({
rotate: 360,
scale: [1, 0.86, 1],
transition: {
rotate: {
duration: SPIN_DURATION,
ease: [0.65, 0, 0.35, 1],
},
scale: {
duration: SPIN_DURATION,
times: [0, 0.5, 1],
ease: "easeInOut",
},
},
})
.then(() => {
if (!cancelled) {
spin.set({ rotate: 0, scale: 1 })
}
})
return () => {
cancelled = true
}
}, [turn, reduce, spin])
const finders = [
[0, 0],
[n - 7, 0],
[0, n - 7],
]
const pick = (next: CoinSymbol) => {
if (next === symbol) return
setSymbol(next)
setTurn((value) => value + 1)
onChange?.(next)
}
return (
<div
className={cn(
"w-full max-w-[15rem] space-y-3",
className
)}
>
{coins.length > 1 && (
<CoinDropdown
coins={coins}
symbol={symbol}
onPick={pick}
reduce={reduce}
/>
)}
<div
className={cn(
"relative aspect-square rounded-3xl border p-5 shadow-sm transition-colors duration-200",
"border-neutral-200 bg-white",
"dark:border-neutral-800 dark:bg-neutral-950",
"[--wave:rgba(150,150,162,.55)] dark:[--wave:rgba(205,205,215,.38)]"
)}
>
{!reduce && waves && turn > 0 && (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 overflow-hidden rounded-3xl"
>
{[0, 1].map((i) => (
<motion.span
key={`${turn}-${i}`}
className="absolute inset-0 rounded-3xl border border-[var(--wave)] will-change-transform"
style={{ transformOrigin: "50% 50%" }}
initial={{ scale: 1, opacity: 0 }}
animate={{
scale: 0.2,
opacity: [0, 0.8, 0],
}}
transition={{
duration: 1,
delay: i * 0.2,
scale: {
duration: 1,
delay: i * 0.2,
ease: [0.33, 0, 0.2, 1],
},
opacity: {
duration: 1,
delay: i * 0.2,
times: [0, 0.25, 1],
ease: "easeOut",
},
}}
/>
))}
</div>
)}
<svg
key={symbol}
viewBox={`0 0 ${n} ${n}`}
role="img"
aria-label={`QR code for ${coin.label}`}
className="relative size-full text-neutral-950 dark:text-neutral-50"
>
{finders.map(([x, y]) => (
<motion.g
key={`${x}-${y}`}
initial={
reduce
? false
: { opacity: 0, scale: 0.6 }
}
animate={{ opacity: 1, scale: 1 }}
transition={{
duration: 0.4,
ease: [0.2, 0.8, 0.2, 1],
}}
style={{
transformBox: "fill-box",
transformOrigin: "center",
}}
>
<rect
x={x + 0.55}
y={y + 0.55}
width={5.9}
height={5.9}
rx={1.8}
fill="none"
stroke="currentColor"
strokeWidth={1.1}
/>
<rect
x={x + 2}
y={y + 2}
width={3}
height={3}
rx={0.9}
fill="currentColor"
/>
</motion.g>
))}
{dots.map((point, index) => (
<motion.circle
key={index}
cx={point.x}
cy={point.y}
r={0.36}
fill="currentColor"
initial={
reduce
? false
: { scale: 0, opacity: 0 }
}
animate={
reduce
? { scale: 1, opacity: 1 }
: {
scale: [0, 1.45, 1],
opacity: [0, 1, 1],
}
}
transition={{
delay: reduce ? 0 : point.d * 0.6,
duration: 0.55,
times: [0, 0.5, 1],
ease: "easeOut",
}}
style={{
transformBox: "fill-box",
transformOrigin: "center",
}}
/>
))}
</svg>
<div className="pointer-events-none absolute inset-0 grid place-items-center overflow-hidden rounded-3xl">
<motion.div
animate={spin}
initial={{ rotate: 0, scale: 1 }}
style={{ transformOrigin: "50% 50%" }}
className="relative aspect-square w-[19%] rounded-full bg-white p-[3px] dark:bg-neutral-950"
>
<AnimatePresence initial={false}>
<motion.div
key={symbol}
className="absolute inset-[3px]"
initial={{ opacity: 0 }}
animate={{
opacity: 1,
transition: {
duration: 0.35,
delay: reduce
? 0
: SPIN_DURATION * 0.3,
},
}}
exit={{
opacity: 0,
transition: {
duration: 0.35,
delay: reduce
? 0
: SPIN_DURATION * 0.2,
},
}}
>
<CoinIcon
symbol={symbol}
className="size-full"
/>
</motion.div>
</AnimatePresence>
</motion.div>
</div>
</div>
</div>
)
}
components/ui/coin-icon.tsx
import { cn } from "@/lib/utils"
export const COIN_META = {
BTC: { name: "Bitcoin", color: "#F7931A" },
ETH: { name: "Ethereum", color: "#627EEA" },
SOL: { name: "Solana", color: "#0B0B0F" },
USDT: { name: "Tether", color: "#26A17B" },
} as const
export type CoinSymbol = keyof typeof COIN_META
export function CoinIcon({
symbol,
className,
}: {
symbol: CoinSymbol
className?: string
}) {
return (
<svg
viewBox="0 0 32 32"
role="img"
aria-label={COIN_META[symbol].name}
className={cn("size-6 shrink-0", className)}
>
<defs>
<linearGradient id="coin-sol" x1="0" y1="1" x2="1" y2="0">
<stop offset="0" stopColor="#9945FF" />
<stop offset="1" stopColor="#14F195" />
</linearGradient>
</defs>
<circle cx="16" cy="16" r="16" fill={COIN_META[symbol].color} />
{symbol === "BTC" && (
<text
x="16"
y="22.5"
textAnchor="middle"
fontSize="19"
fontWeight="700"
fill="#fff"
>
₿
</text>
)}
{symbol === "ETH" && (
<g fill="#fff">
<path d="M16 5.5 9.8 16.2 16 19.8l6.2-3.6z" opacity=".95" />
<path d="m16 21.2-6.2-3.6L16 26.5l6.2-8.9z" opacity=".8" />
</g>
)}
{symbol === "SOL" && (
<g fill="url(#coin-sol)">
<path d="M11 10h13l-2.500 3H8.500z" />
<path d="M8.500 14.500h13l2.500 3H11z" />
<path d="M11 19h13l-2.500 3H8.500z" />
</g>
)}
{symbol === "USDT" && (
<path
fill="#fff"
d="M9 9h14v3h-5.500v2.200c3.300.2 5.500.8 5.500 1.600s-2.200 1.400-5.500 1.600V24h-3v-6.600c-3.300-.2-5.500-.8-5.500-1.600s2.200-1.400 5.500-1.600V12H9z"
/>
)}
</svg>
)
}