makeui
DocsComponents

© 2026 makeui. Open source, MIT licensed.

DocsComponentsGitHub

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.json

Dependencies

  • 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

PropTypeDescription
coins{ symbol; label; address }[]Coins to choose from. Defaults to BTC, ETH, SOL and USDT with sample addresses.
defaultSymbolCoinSymbolCoin shown first.
onChange(symbol) => voidCalled when the user picks a coin.
classNamestringExtra 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>
  )
}

On this page

₿