{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "author": "DesignX ", "name": "wallet-card", "type": "registry:component", "title": "Wallet Card", "description": "A crypto wallet card with account switcher and balances.", "dependencies": [ "lucide-react@^0.577.0", "motion@^14.0.0" ], "registryDependencies": [ "@dx/action-swap", "@dx/lib-ease", "@dx/motion-button", "@dx/utils" ], "files": [ { "path": "components/motion/wallet-card/index.tsx", "type": "registry:component", "target": "components/motion/wallet-card/index.tsx", "content": "import { Bell, Eye, EyeOff } from \"lucide-react\";\nimport { useState } from \"react\";\nimport { ActionSwapText } from \"@/components/motion/action-swap\";\nimport { Button } from \"@/components/motion/button\";\nimport { cn } from \"@/lib/utils\";\nimport { AccountSwitcher } from \"./account-switcher\";\nimport { WalletActions } from \"./actions\";\nimport { BalanceDelta } from \"./balance-delta\";\nimport { SearchBar } from \"./search-bar\";\nimport type { WalletCardProps } from \"./types\";\n\nexport type { WalletAccount, WalletCardProps } from \"./types\";\n\n/**\n * Composed wallet overview card: an account switcher whose trigger morphs open\n * into a full-width panel, a search icon that morphs into a search bar, a\n * rolling balance with a transient change indicator, and Send / Deposit\n * actions. Actions and search are plain callbacksthe resulting flow is left\n * to the consumer.\n */\nexport function WalletCard({\n accounts,\n accountId,\n defaultAccountId,\n onAccountChange,\n balance,\n balancePrefix = \"$\",\n defaultChange,\n defaultBalanceHidden = false,\n onSend,\n onDeposit,\n onSwap,\n onBuy,\n searchPlaceholder,\n searchRecent,\n onSearchChange,\n onSearchSubmit,\n hasNotifications = false,\n onNotifications,\n className,\n}: WalletCardProps) {\n const accountControlled = accountId !== undefined;\n const [internalAccountId, setInternalAccountId] = useState(\n defaultAccountId ?? accounts[0]?.id,\n );\n const [balanceHidden, setBalanceHidden] = useState(defaultBalanceHidden);\n\n const shownBalance = `${balancePrefix}${balance.toLocaleString(undefined, {\n minimumFractionDigits: 2,\n maximumFractionDigits: 2,\n })}`;\n const maskedBalance = \"*\".repeat(7);\n const activeAccountId = accountControlled ? accountId : internalAccountId;\n const activeAccount =\n accounts.find((a) => a.id === activeAccountId) ?? accounts[0];\n\n const handleAccountChange = (id: string) => {\n if (!accountControlled) setInternalAccountId(id);\n onAccountChange?.(id);\n };\n\n return (\n \n {/* relative anchor so the switcher + search panels span the whole row */}\n
\n \n\n
\n \n \n \n {hasNotifications ? (\n \n \n \n \n ) : null}\n \n
\n
\n\n
\n
\n

Balance

\n setBalanceHidden((h) => !h)}\n aria-label={balanceHidden ? \"Show balance\" : \"Hide balance\"}\n aria-pressed={balanceHidden}\n className=\"text-muted-foreground outline-none transition-colors hover:text-foreground\"\n >\n {balanceHidden ? (\n \n ) : (\n \n )}\n \n
\n {/* One ActionSwapText swaps the number and the asterisk mask with a\n per-letter cascadesame baseline, no overlap or layout shift. */}\n \n {balanceHidden ? maskedBalance : shownBalance}\n \n {balanceHidden ? (\n
\n \n *****\n \n
\n ) : (\n \n )}\n
\n\n
\n \n
\n \n );\n}\n" }, { "path": "components/motion/wallet-card/account-switcher.tsx", "type": "registry:component", "target": "components/motion/wallet-card/account-switcher.tsx", "content": "import { Check, ChevronDown } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useCallback, useEffect, useId, useRef, useState } from \"react\";\nimport { useDismiss } from \"@/lib/hooks/use-dismiss\";\nimport { cn } from \"@/lib/utils\";\nimport { AccountAvatar } from \"./account-avatar\";\nimport { HEAD, ITEM, LIST, MORPH } from \"./constants\";\nimport { CopyButton } from \"./copy-button\";\nimport type { WalletAccount } from \"./types\";\nimport { truncateAddress } from \"./utils\";\n\n/**\n * Account switcher whose trigger morphs into a panel that grows rightward to\n * full width (covering the header icons) and downward at the same time, via a\n * shared layoutId. Self-containednot the generic MorphSelect.\n */\nexport function AccountSwitcher({\n accounts,\n activeAccount,\n onSelect,\n}: {\n accounts: WalletAccount[];\n activeAccount: WalletAccount | undefined;\n onSelect: (id: string) => void;\n}) {\n const reduce = useReducedMotion() ?? false;\n const layoutId = `${useId()}-account`;\n const rootRef = useRef(null);\n const [open, setOpen] = useState(false);\n // Hover only arms after the morph settlesotherwise the panel expands under\n // the cursor and flashes a phantom hover bg on whatever item it lands on.\n const [armed, setArmed] = useState(false);\n const close = useCallback(() => setOpen(false), []);\n\n useDismiss(open, close, rootRef);\n\n useEffect(() => {\n if (!open) {\n setArmed(false);\n return;\n }\n const t = window.setTimeout(() => setArmed(true), reduce ? 0 : 280);\n return () => window.clearTimeout(t);\n }, [open, reduce]);\n\n const morph = reduce ? { duration: 0 } : MORPH;\n\n return (\n // static (not relative) so the absolute trigger + panel anchor to the\n // header row and can span its full width, covering the icons.\n
\n {/* in-flow sizer: reserves the widest possible trigger footprint (every\n account name stacked in one grid cell) so the header row width never\n changesneither when the trigger leaves the flow nor when a shorter\n account name is selected. */}\n
\n \n \n {accounts.map((account) => (\n \n {account.name}\n \n ))}\n \n \n
\n\n \n {open ? null : (\n setOpen(true)}\n transition={morph}\n style={{ borderRadius: 16 }}\n className={cn(\n HEAD,\n // shifted left by the padding so the avatar sits flush with the\n // card content edge (aligned with the balance + buttons below).\n \"absolute top-0 -left-2 z-20 max-w-full outline-none transition-colors hover:bg-muted/60\",\n )}\n >\n {activeAccount ? (\n <>\n \n \n {activeAccount.name}\n \n \n \n \n \n ) : null}\n \n )}\n \n\n \n {open ? (\n \n setOpen(false)}\n className={cn(HEAD, \"w-full outline-none\")}\n >\n {activeAccount ? : null}\n \n {activeAccount?.name ?? \"Select account\"}\n \n \n \n \n \n\n \n {accounts.map((account) => {\n const selected = account.id === activeAccount?.id;\n return (\n \n {\n onSelect(account.id);\n setOpen(false);\n }}\n className=\"flex min-w-0 flex-1 items-center gap-2.5 rounded-xl px-2 py-2 text-left outline-none\"\n >\n \n \n \n {account.name}\n \n \n {truncateAddress(account.address)}\n \n \n {selected ? (\n \n ) : null}\n \n \n \n );\n })}\n \n \n ) : null}\n \n
\n );\n}\n" }, { "path": "components/motion/wallet-card/actions.tsx", "type": "registry:component", "target": "components/motion/wallet-card/actions.tsx", "content": "import { ArrowDownToLine, ArrowUp, CreditCard, Repeat } from \"lucide-react\";\nimport { motion, useReducedMotion } from \"motion/react\";\nimport type { ComponentType } from \"react\";\nimport { SPRING_PRESS } from \"@/lib/ease\";\n\ntype WalletAction = {\n key: string;\n label: string;\n icon: ComponentType<{ className?: string }>;\n onClick?: () => void;\n};\n\n/**\n * Row of primary wallet actions rendered icon-over-label, with a spring press.\n */\nexport function WalletActions({\n onSend,\n onDeposit,\n onSwap,\n onBuy,\n}: {\n onSend?: () => void;\n onDeposit?: () => void;\n onSwap?: () => void;\n onBuy?: () => void;\n}) {\n const reduce = useReducedMotion();\n\n const actions: WalletAction[] = [\n { key: \"send\", label: \"Send\", icon: ArrowUp, onClick: onSend },\n { key: \"deposit\", label: \"Deposit\", icon: ArrowDownToLine, onClick: onDeposit },\n { key: \"swap\", label: \"Swap\", icon: Repeat, onClick: onSwap },\n { key: \"buy\", label: \"Buy\", icon: CreditCard, onClick: onBuy },\n ];\n\n return (\n
\n {actions.map(({ key, label, icon: Icon, onClick }) => (\n \n \n \n \n \n {label}\n \n \n ))}\n
\n );\n}\n" }, { "path": "components/motion/wallet-card/balance-delta.tsx", "type": "registry:component", "target": "components/motion/wallet-card/balance-delta.tsx", "content": "import { TrendingDown, TrendingUp } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useEffect, useRef, useState } from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * A transient change indicator for the balance: a tinted pill with a trend\n * arrow that pops in whenever the balance moves and persists until it moves\n * again.\n */\nexport function BalanceDelta({\n balance,\n initialChange,\n}: {\n balance: number;\n initialChange?: number;\n}) {\n const reduce = useReducedMotion();\n const prevRef = useRef(balance);\n const [delta, setDelta] = useState<{ id: number; amount: number } | null>(\n initialChange ? { id: 0, amount: initialChange } : null,\n );\n\n // Persist the last change until the balance moves againdon't auto-hide.\n useEffect(() => {\n const diff = balance - prevRef.current;\n prevRef.current = balance;\n if (diff === 0) return;\n setDelta({ id: Date.now(), amount: diff });\n }, [balance]);\n\n const up = (delta?.amount ?? 0) > 0;\n\n return (\n
\n \n {delta ? (\n \n {up ? (\n \n ) : (\n \n )}\n {up ? \"+\" : \"-\"}$\n {Math.abs(delta.amount).toLocaleString(undefined, {\n minimumFractionDigits: 2,\n maximumFractionDigits: 2,\n })}\n \n ) : null}\n \n
\n );\n}\n" }, { "path": "components/motion/wallet-card/search-bar.tsx", "type": "registry:component", "target": "components/motion/wallet-card/search-bar.tsx", "content": "import { History, Search } from \"lucide-react\";\nimport {\n AnimatePresence,\n motion,\n type Transition,\n useReducedMotion,\n} from \"motion/react\";\nimport { useCallback, useEffect, useId, useRef, useState } from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { useDismiss } from \"@/lib/hooks/use-dismiss\";\nimport { cn } from \"@/lib/utils\";\nimport { ITEM, LIST, MORPH } from \"./constants\";\n\n/**\n * Search icon that morphs into a full-width search bar via a shared layoutId,\n * growing leftward across the header row. The recent-searches results render as\n * a SEPARATE dropdown below the barnot part of the morphing elementso\n * filtering as you type resizes only the dropdown and never re-fires the morph\n * (which would scale-distort the input text). The in-flow slot keeps its width\n * whether open or closed so the header row (and card) never shifts.\n */\nexport function SearchBar({\n placeholder = \"Search\",\n recent = [],\n onChange,\n onSubmit,\n}: {\n placeholder?: string;\n recent?: string[];\n onChange?: (value: string) => void;\n onSubmit?: (value: string) => void;\n}) {\n const reduce = useReducedMotion() ?? false;\n const layoutId = `${useId()}-search`;\n const rootRef = useRef(null);\n const inputRef = useRef(null);\n const [open, setOpen] = useState(false);\n const [value, setValue] = useState(\"\");\n // Hover arms after the dropdown reveals so it doesn't flash a phantom hover.\n const [armed, setArmed] = useState(false);\n const close = useCallback(() => setOpen(false), []);\n\n useDismiss(open, close, rootRef);\n\n useEffect(() => {\n if (open) inputRef.current?.focus();\n }, [open]);\n\n useEffect(() => {\n if (!open) {\n setArmed(false);\n return;\n }\n const t = window.setTimeout(() => setArmed(true), reduce ? 0 : 260);\n return () => window.clearTimeout(t);\n }, [open, reduce]);\n\n // The box keeps the bouncy morph (same feel as the account switcher)...\n const morph: Transition = reduce ? { duration: 0 } : MORPH;\n // ...but the leading icon + input travel across the row, so their own layout\n // uses a critically-damped springthey glide to place without inheriting the\n // box's overshoot (which read as the icon jittering right-then-left).\n const glide: Transition = reduce\n ? { duration: 0 }\n : { type: \"spring\", duration: 0.5, bounce: 0 };\n\n const query = value.trim().toLowerCase();\n const filtered = query\n ? recent.filter((r) => r.toLowerCase().includes(query))\n : recent;\n\n const submit = (next: string) => {\n onSubmit?.(next);\n setOpen(false);\n };\n\n return (\n // static so the open bar + dropdown anchor to the header row (spanning its\n // width), while the slot below reserves the icon's footprint.\n
\n {/* reserve the icon's width while open (the icon has left the flow) */}\n {open ?
: null}\n\n \n {open ? null : (\n setOpen(true)}\n transition={morph}\n style={{ borderRadius: 12 }}\n className=\"inline-flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground\"\n >\n \n \n )}\n \n\n {/* one connected panel: input + results grow out of the trigger. The text\n nodes use layout=\"position\" so when the panel resizes (filtering) they\n reposition without scalingno re-morph distortion, still connected. */}\n \n {open ? (\n \n
\n \n \n \n {\n setValue(e.target.value);\n onChange?.(e.target.value);\n }}\n onKeyDown={(e) => {\n if (e.key === \"Enter\" && value.trim()) submit(value.trim());\n if (e.key === \"Escape\") close();\n }}\n placeholder={placeholder}\n className=\"min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground\"\n />\n
\n\n
\n\n {filtered.length > 0 ? (\n \n {filtered.map((term) => (\n \n {\n setValue(term);\n onChange?.(term);\n submit(term);\n }}\n className={cn(\n \"flex w-full items-center gap-2.5 rounded-xl px-2.5 py-2 text-left text-sm text-muted-foreground outline-none transition-colors\",\n armed && \"hover:bg-muted hover:text-foreground\",\n )}\n >\n \n {term}\n \n \n ))}\n \n ) : (\n \n \n

\n {query ? \"No matches\" : \"No recent searches\"}\n

\n \n )}\n \n ) : null}\n \n
\n );\n}\n" }, { "path": "components/motion/wallet-card/types.ts", "type": "registry:component", "target": "components/motion/wallet-card/types.ts", "content": "import type { ReactNode } from \"react\";\n\nexport type WalletAccount = {\n id: string;\n name: string;\n address: string;\n avatar?: ReactNode;\n};\n\nexport interface WalletCardProps {\n accounts: WalletAccount[];\n accountId?: string;\n defaultAccountId?: string;\n onAccountChange?: (id: string) => void;\n balance: number;\n balancePrefix?: string;\n /** Initial balance change shown in the pill before any live change. */\n defaultChange?: number;\n /** Start with the balance hidden behind dots. */\n defaultBalanceHidden?: boolean;\n onSend?: () => void;\n onDeposit?: () => void;\n onSwap?: () => void;\n onBuy?: () => void;\n searchPlaceholder?: string;\n /** Recent searches shown in the expanded search panel. */\n searchRecent?: string[];\n onSearchChange?: (value: string) => void;\n onSearchSubmit?: (value: string) => void;\n /** Show an unread pulse on the notifications bell. */\n hasNotifications?: boolean;\n onNotifications?: () => void;\n className?: string;\n}\n" }, { "path": "components/motion/wallet-card/account-avatar.tsx", "type": "registry:component", "target": "components/motion/wallet-card/account-avatar.tsx", "content": "import { cn } from \"@/lib/utils\";\nimport type { WalletAccount } from \"./types\";\nimport { diceBearGlassUrl } from \"./utils\";\n\nexport function AccountAvatar({\n account,\n className,\n}: {\n account: WalletAccount;\n className?: string;\n}) {\n if (account.avatar) return <>{account.avatar};\n return (\n \n );\n}\n" }, { "path": "components/motion/wallet-card/constants.ts", "type": "registry:component", "target": "components/motion/wallet-card/constants.ts", "content": "import type { Transition, Variants } from \"motion/react\";\n\n// Trigger box grows into the full-width panel and back — one shared surface.\nexport const MORPH: Transition = { type: \"spring\", duration: 0.5, bounce: 0.22 };\n\nexport const LIST: Variants = {\n hidden: {},\n show: { transition: { staggerChildren: 0.035, delayChildren: 0.12 } },\n};\n\nexport const ITEM: Variants = {\n hidden: { opacity: 0, y: -6, filter: \"blur(3px)\" },\n show: { opacity: 1, y: 0, filter: \"blur(0px)\" },\n};\n\n// Shared padding for the account trigger + panel header so the avatar/name stay\n// put as the box morphs — the panel reads as the trigger itself growing open.\nexport const HEAD = \"flex items-center gap-2 px-2 py-1.5 text-left\";\n" }, { "path": "components/motion/wallet-card/copy-button.tsx", "type": "registry:component", "target": "components/motion/wallet-card/copy-button.tsx", "content": "import { Check, Copy } from \"lucide-react\";\nimport { useState } from \"react\";\nimport { ActionSwapIcon } from \"@/components/motion/action-swap\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * Copies `value` to the clipboard and swaps the copy icon for a check via the\n * library's ActionSwapIcon. Stops click propagation so it can sit inside a\n * selectable row.\n */\nexport function CopyButton({\n value,\n className,\n}: {\n value: string;\n className?: string;\n}) {\n const [copied, setCopied] = useState(false);\n\n const copy = async () => {\n try {\n await navigator.clipboard.writeText(value);\n } catch {\n // clipboard may be unavailable (insecure context)swap anyway\n }\n setCopied(true);\n window.setTimeout(() => setCopied(false), 1400);\n };\n\n return (\n {\n e.stopPropagation();\n copy();\n }}\n className={cn(\n \"inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-lg text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground\",\n className,\n )}\n >\n \n {copied ? (\n \n ) : (\n \n )}\n \n \n );\n}\n" }, { "path": "components/motion/wallet-card/utils.ts", "type": "registry:component", "target": "components/motion/wallet-card/utils.ts", "content": "export function diceBearGlassUrl(seed: string) {\n return `https://api.dicebear.com/9.x/glass/svg?seed=${encodeURIComponent(seed)}`;\n}\n\nexport function truncateAddress(address: string) {\n return address.length > 12\n ? `${address.slice(0, 6)}…${address.slice(-4)}`\n : address;\n}\n" } ], "categories": [ "motion-kit" ], "meta": { "group": "motion", "docs": "https://dxuireact.com/docs/components/wallet-card" } }