diff --git a/components/motion/wallet-card/account-avatar.tsx b/components/motion/wallet-card/account-avatar.tsx new file mode 100644 index 0000000..9e79ad9 --- /dev/null +++ b/components/motion/wallet-card/account-avatar.tsx @@ -0,0 +1,21 @@ +import { cn } from "@/lib/utils"; +import type { WalletAccount } from "./types"; +import { diceBearGlassUrl } from "./utils"; + +export function AccountAvatar({ + account, + className, +}: { + account: WalletAccount; + className?: string; +}) { + if (account.avatar) return <>{account.avatar}; + return ( + // biome-ignore lint/performance/noImgElement: remote DiceBear SVG, no next/image benefit + {account.name} + ); +} diff --git a/components/motion/wallet-card/account-switcher.tsx b/components/motion/wallet-card/account-switcher.tsx new file mode 100644 index 0000000..46d465f --- /dev/null +++ b/components/motion/wallet-card/account-switcher.tsx @@ -0,0 +1,199 @@ +"use client"; + +import { Check, ChevronDown } from "lucide-react"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import { useCallback, useEffect, useId, useRef, useState } from "react"; +import { cn } from "@/lib/utils"; +import { AccountAvatar } from "./account-avatar"; +import { HEAD, ITEM, LIST, MORPH } from "./constants"; +import { CopyButton } from "./copy-button"; +import type { WalletAccount } from "./types"; +import { useDismiss } from "./use-dismiss"; +import { truncateAddress } from "./utils"; + +/** + * Account switcher whose trigger morphs into a panel that grows rightward to + * full width (covering the header icons) and downward at the same time, via a + * shared layoutId. Self-contained — not the generic MorphSelect. + */ +export function AccountSwitcher({ + accounts, + activeAccount, + onSelect, +}: { + accounts: WalletAccount[]; + activeAccount: WalletAccount | undefined; + onSelect: (id: string) => void; +}) { + const reduce = useReducedMotion() ?? false; + const layoutId = `${useId()}-account`; + const rootRef = useRef(null); + const [open, setOpen] = useState(false); + // Hover only arms after the morph settles — otherwise the panel expands under + // the cursor and flashes a phantom hover bg on whatever item it lands on. + const [armed, setArmed] = useState(false); + const close = useCallback(() => setOpen(false), []); + + useDismiss(open, close, rootRef); + + useEffect(() => { + if (!open) { + setArmed(false); + return; + } + const t = window.setTimeout(() => setArmed(true), reduce ? 0 : 280); + return () => window.clearTimeout(t); + }, [open, reduce]); + + const morph = reduce ? { duration: 0 } : MORPH; + + return ( + // static (not relative) so the absolute trigger + panel anchor to the + // header row and can span its full width, covering the icons. +
+ {/* in-flow sizer: reserves the widest possible trigger footprint (every + account name stacked in one grid cell) so the header row width never + changes — neither when the trigger leaves the flow nor when a shorter + account name is selected. */} +
+ + + {accounts.map((account) => ( + + {account.name} + + ))} + + +
+ + + {open ? null : ( + setOpen(true)} + transition={morph} + style={{ borderRadius: 16 }} + className={cn( + HEAD, + // shifted left by the padding so the avatar sits flush with the + // card content edge (aligned with the balance + buttons below). + "absolute top-0 -left-2 z-20 max-w-full outline-none transition-colors hover:bg-muted/60", + )} + > + {activeAccount ? ( + <> + + + {activeAccount.name} + + + + + + ) : null} + + )} + + + + {open ? ( + + + + + {accounts.map((account) => { + const selected = account.id === activeAccount?.id; + return ( + + + + + ); + })} + + + ) : null} + +
+ ); +} diff --git a/components/motion/wallet-card/actions.tsx b/components/motion/wallet-card/actions.tsx new file mode 100644 index 0000000..e4d8dc0 --- /dev/null +++ b/components/motion/wallet-card/actions.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { ArrowDownToLine, ArrowUp, CreditCard, Repeat } from "lucide-react"; +import { motion, useReducedMotion } from "motion/react"; +import type { ComponentType } from "react"; +import { SPRING_PRESS } from "@/lib/ease"; + +type WalletAction = { + key: string; + label: string; + icon: ComponentType<{ className?: string }>; + onClick?: () => void; +}; + +/** + * Row of primary wallet actions rendered icon-over-label, with a spring press. + */ +export function WalletActions({ + onSend, + onDeposit, + onSwap, + onBuy, +}: { + onSend?: () => void; + onDeposit?: () => void; + onSwap?: () => void; + onBuy?: () => void; +}) { + const reduce = useReducedMotion(); + + const actions: WalletAction[] = [ + { key: "send", label: "Send", icon: ArrowUp, onClick: onSend }, + { key: "deposit", label: "Deposit", icon: ArrowDownToLine, onClick: onDeposit }, + { key: "swap", label: "Swap", icon: Repeat, onClick: onSwap }, + { key: "buy", label: "Buy", icon: CreditCard, onClick: onBuy }, + ]; + + return ( +
+ {actions.map(({ key, label, icon: Icon, onClick }) => ( + + + + + + {label} + + + ))} +
+ ); +} diff --git a/components/motion/wallet-card/balance-delta.tsx b/components/motion/wallet-card/balance-delta.tsx new file mode 100644 index 0000000..16148ba --- /dev/null +++ b/components/motion/wallet-card/balance-delta.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { TrendingDown, TrendingUp } from "lucide-react"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import { useEffect, useRef, useState } from "react"; +import { EASE_OUT } from "@/lib/ease"; +import { cn } from "@/lib/utils"; + +/** + * A transient change indicator for the balance: a tinted pill with a trend + * arrow that pops in whenever the balance moves and persists until it moves + * again. + */ +export function BalanceDelta({ + balance, + initialChange, +}: { + balance: number; + initialChange?: number; +}) { + const reduce = useReducedMotion(); + const prevRef = useRef(balance); + const [delta, setDelta] = useState<{ id: number; amount: number } | null>( + initialChange ? { id: 0, amount: initialChange } : null, + ); + + // Persist the last change until the balance moves again — don't auto-hide. + useEffect(() => { + const diff = balance - prevRef.current; + prevRef.current = balance; + if (diff === 0) return; + setDelta({ id: Date.now(), amount: diff }); + }, [balance]); + + const up = (delta?.amount ?? 0) > 0; + + return ( +
+ + {delta ? ( + + {up ? ( + + ) : ( + + )} + {up ? "+" : "-"}$ + {Math.abs(delta.amount).toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} + + ) : null} + +
+ ); +} diff --git a/components/motion/wallet-card/constants.ts b/components/motion/wallet-card/constants.ts new file mode 100644 index 0000000..b2f7037 --- /dev/null +++ b/components/motion/wallet-card/constants.ts @@ -0,0 +1,18 @@ +import type { Transition, Variants } from "motion/react"; + +// Trigger box grows into the full-width panel and back — one shared surface. +export const MORPH: Transition = { type: "spring", duration: 0.5, bounce: 0.22 }; + +export const LIST: Variants = { + hidden: {}, + show: { transition: { staggerChildren: 0.035, delayChildren: 0.12 } }, +}; + +export const ITEM: Variants = { + hidden: { opacity: 0, y: -6, filter: "blur(3px)" }, + show: { opacity: 1, y: 0, filter: "blur(0px)" }, +}; + +// Shared padding for the account trigger + panel header so the avatar/name stay +// put as the box morphs — the panel reads as the trigger itself growing open. +export const HEAD = "flex items-center gap-2 px-2 py-1.5 text-left"; diff --git a/components/motion/wallet-card/copy-button.tsx b/components/motion/wallet-card/copy-button.tsx new file mode 100644 index 0000000..d74c56c --- /dev/null +++ b/components/motion/wallet-card/copy-button.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { Check, Copy } from "lucide-react"; +import { useState } from "react"; +import { ActionSwapIcon } from "@/components/motion/action-swap"; +import { cn } from "@/lib/utils"; + +/** + * Copies `value` to the clipboard and swaps the copy icon for a check via the + * library's ActionSwapIcon. Stops click propagation so it can sit inside a + * selectable row. + */ +export function CopyButton({ + value, + className, +}: { + value: string; + className?: string; +}) { + const [copied, setCopied] = useState(false); + + const copy = async () => { + try { + await navigator.clipboard.writeText(value); + } catch { + // clipboard may be unavailable (insecure context) — swap anyway + } + setCopied(true); + window.setTimeout(() => setCopied(false), 1400); + }; + + return ( + + ); +} diff --git a/components/motion/wallet-card/index.tsx b/components/motion/wallet-card/index.tsx new file mode 100644 index 0000000..d876d0c --- /dev/null +++ b/components/motion/wallet-card/index.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { Bell, Eye, EyeOff } from "lucide-react"; +import { useState } from "react"; +import { ActionSwapText } from "@/components/motion/action-swap"; +import { Button } from "@/components/motion/button"; +import { cn } from "@/lib/utils"; +import { AccountSwitcher } from "./account-switcher"; +import { WalletActions } from "./actions"; +import { BalanceDelta } from "./balance-delta"; +import { SearchBar } from "./search-bar"; +import type { WalletCardProps } from "./types"; + +export type { WalletAccount, WalletCardProps } from "./types"; + +/** + * Composed wallet overview card: an account switcher whose trigger morphs open + * into a full-width panel, a search icon that morphs into a search bar, a + * rolling balance with a transient change indicator, and Send / Deposit + * actions. Actions and search are plain callbacks — the resulting flow is left + * to the consumer. + */ +export function WalletCard({ + accounts, + accountId, + defaultAccountId, + onAccountChange, + balance, + balancePrefix = "$", + defaultChange, + defaultBalanceHidden = false, + onSend, + onDeposit, + onSwap, + onBuy, + searchPlaceholder, + searchRecent, + onSearchChange, + onSearchSubmit, + hasNotifications = false, + onNotifications, + className, +}: WalletCardProps) { + const accountControlled = accountId !== undefined; + const [internalAccountId, setInternalAccountId] = useState( + defaultAccountId ?? accounts[0]?.id, + ); + const [balanceHidden, setBalanceHidden] = useState(defaultBalanceHidden); + + const shownBalance = `${balancePrefix}${balance.toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}`; + const maskedBalance = "*".repeat(7); + const activeAccountId = accountControlled ? accountId : internalAccountId; + const activeAccount = + accounts.find((a) => a.id === activeAccountId) ?? accounts[0]; + + const handleAccountChange = (id: string) => { + if (!accountControlled) setInternalAccountId(id); + onAccountChange?.(id); + }; + + return ( +
+ {/* relative anchor so the switcher + search panels span the whole row */} +
+ + +
+ + +
+
+ +
+
+

Balance

+ +
+ {/* One ActionSwapText swaps the number and the asterisk mask with a + per-letter cascade — same baseline, no overlap or layout shift. */} + + {balanceHidden ? maskedBalance : shownBalance} + + {balanceHidden ? ( +
+ + ***** + +
+ ) : ( + + )} +
+ +
+ +
+
+ ); +} diff --git a/components/motion/wallet-card/search-bar.tsx b/components/motion/wallet-card/search-bar.tsx new file mode 100644 index 0000000..99cf2ba --- /dev/null +++ b/components/motion/wallet-card/search-bar.tsx @@ -0,0 +1,198 @@ +"use client"; + +import { History, Search } from "lucide-react"; +import { + AnimatePresence, + motion, + type Transition, + useReducedMotion, +} from "motion/react"; +import { useCallback, useEffect, useId, useRef, useState } from "react"; +import { EASE_OUT } from "@/lib/ease"; +import { cn } from "@/lib/utils"; +import { ITEM, LIST, MORPH } from "./constants"; +import { useDismiss } from "./use-dismiss"; + +/** + * Search icon that morphs into a full-width search bar via a shared layoutId, + * growing leftward across the header row. The recent-searches results render as + * a SEPARATE dropdown below the bar — not part of the morphing element — so + * filtering as you type resizes only the dropdown and never re-fires the morph + * (which would scale-distort the input text). The in-flow slot keeps its width + * whether open or closed so the header row (and card) never shifts. + */ +export function SearchBar({ + placeholder = "Search", + recent = [], + onChange, + onSubmit, +}: { + placeholder?: string; + recent?: string[]; + onChange?: (value: string) => void; + onSubmit?: (value: string) => void; +}) { + const reduce = useReducedMotion() ?? false; + const layoutId = `${useId()}-search`; + const rootRef = useRef(null); + const inputRef = useRef(null); + const [open, setOpen] = useState(false); + const [value, setValue] = useState(""); + // Hover arms after the dropdown reveals so it doesn't flash a phantom hover. + const [armed, setArmed] = useState(false); + const close = useCallback(() => setOpen(false), []); + + useDismiss(open, close, rootRef); + + useEffect(() => { + if (open) inputRef.current?.focus(); + }, [open]); + + useEffect(() => { + if (!open) { + setArmed(false); + return; + } + const t = window.setTimeout(() => setArmed(true), reduce ? 0 : 260); + return () => window.clearTimeout(t); + }, [open, reduce]); + + // The box keeps the bouncy morph (same feel as the account switcher)... + const morph: Transition = reduce ? { duration: 0 } : MORPH; + // ...but the leading icon + input travel across the row, so their own layout + // uses a critically-damped spring — they glide to place without inheriting the + // box's overshoot (which read as the icon jittering right-then-left). + const glide: Transition = reduce + ? { duration: 0 } + : { type: "spring", duration: 0.5, bounce: 0 }; + + const query = value.trim().toLowerCase(); + const filtered = query + ? recent.filter((r) => r.toLowerCase().includes(query)) + : recent; + + const submit = (next: string) => { + onSubmit?.(next); + setOpen(false); + }; + + return ( + // static so the open bar + dropdown anchor to the header row (spanning its + // width), while the slot below reserves the icon's footprint. +
+ {/* reserve the icon's width while open (the icon has left the flow) */} + {open ?
: null} + + + {open ? null : ( + setOpen(true)} + transition={morph} + style={{ borderRadius: 12 }} + 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" + > + + + )} + + + {/* one connected panel: input + results grow out of the trigger. The text + nodes use layout="position" so when the panel resizes (filtering) they + reposition without scaling — no re-morph distortion, still connected. */} + + {open ? ( + +
+ + + + { + setValue(e.target.value); + onChange?.(e.target.value); + }} + onKeyDown={(e) => { + if (e.key === "Enter" && value.trim()) submit(value.trim()); + if (e.key === "Escape") close(); + }} + placeholder={placeholder} + className="min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground" + /> +
+ +
+ + {filtered.length > 0 ? ( + + {filtered.map((term) => ( + + + + ))} + + ) : ( + + +

+ {query ? "No matches" : "No recent searches"} +

+
+ )} + + ) : null} + +
+ ); +} diff --git a/components/motion/wallet-card/types.ts b/components/motion/wallet-card/types.ts new file mode 100644 index 0000000..cfecf9b --- /dev/null +++ b/components/motion/wallet-card/types.ts @@ -0,0 +1,34 @@ +import type { ReactNode } from "react"; + +export type WalletAccount = { + id: string; + name: string; + address: string; + avatar?: ReactNode; +}; + +export interface WalletCardProps { + accounts: WalletAccount[]; + accountId?: string; + defaultAccountId?: string; + onAccountChange?: (id: string) => void; + balance: number; + balancePrefix?: string; + /** Initial balance change shown in the pill before any live change. */ + defaultChange?: number; + /** Start with the balance hidden behind dots. */ + defaultBalanceHidden?: boolean; + onSend?: () => void; + onDeposit?: () => void; + onSwap?: () => void; + onBuy?: () => void; + searchPlaceholder?: string; + /** Recent searches shown in the expanded search panel. */ + searchRecent?: string[]; + onSearchChange?: (value: string) => void; + onSearchSubmit?: (value: string) => void; + /** Show an unread pulse on the notifications bell. */ + hasNotifications?: boolean; + onNotifications?: () => void; + className?: string; +} diff --git a/components/motion/wallet-card/use-dismiss.ts b/components/motion/wallet-card/use-dismiss.ts new file mode 100644 index 0000000..f2b1a46 --- /dev/null +++ b/components/motion/wallet-card/use-dismiss.ts @@ -0,0 +1,28 @@ +import { type RefObject, useEffect } from "react"; + +/** + * Close an open overlay on Escape or a pointerdown outside `ref`. `onDismiss` + * must be stable (wrap in useCallback) so the listeners aren't re-bound every + * render while open. + */ +export function useDismiss( + open: boolean, + onDismiss: () => void, + ref: RefObject, +) { + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onDismiss(); + }; + const onPointer = (e: PointerEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) onDismiss(); + }; + window.addEventListener("keydown", onKey); + window.addEventListener("pointerdown", onPointer); + return () => { + window.removeEventListener("keydown", onKey); + window.removeEventListener("pointerdown", onPointer); + }; + }, [open, onDismiss, ref]); +} diff --git a/components/motion/wallet-card/utils.ts b/components/motion/wallet-card/utils.ts new file mode 100644 index 0000000..e8f24cb --- /dev/null +++ b/components/motion/wallet-card/utils.ts @@ -0,0 +1,9 @@ +export function diceBearGlassUrl(seed: string) { + return `https://api.dicebear.com/9.x/glass/svg?seed=${encodeURIComponent(seed)}`; +} + +export function truncateAddress(address: string) { + return address.length > 12 + ? `${address.slice(0, 6)}…${address.slice(-4)}` + : address; +} diff --git a/components/previews/blocks/wallet-card.preview.tsx b/components/previews/blocks/wallet-card.preview.tsx new file mode 100644 index 0000000..df8e94f --- /dev/null +++ b/components/previews/blocks/wallet-card.preview.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { useState } from "react"; +import { WalletCard } from "@/components/motion/wallet-card"; +import { Button } from "@/components/motion/button"; + +const ACCOUNTS = [ + { id: "main", name: "Main Wallet", address: "0x8f3Cb1a29e4D7c6F1B2a3E9d0C4b5A6f7D8e9C0b" }, + { id: "trading", name: "Trading", address: "0x1a2B3c4D5e6F7a8B9c0D1e2F3a4B5c6D7e8F9a0B" }, + { id: "cold", name: "Cold Storage", address: "0x9F8e7D6c5B4a3E2d1C0b9A8f7E6d5C4b3A2e1F0d" }, +]; + +const RECENT_SEARCHES = ["vitalik.eth", "0xA0b8…6EB4", "Uniswap", "Send to Trading"]; + +export function WalletCardPreview() { + const [balance, setBalance] = useState(12480.32); + + return ( +
+ + +
+ ); +} diff --git a/components/previews/index.tsx b/components/previews/index.tsx index 0ef426e..40e5708 100644 --- a/components/previews/index.tsx +++ b/components/previews/index.tsx @@ -39,6 +39,9 @@ export const previews: Record = { (m) => m.PredictionMarketPreview, ), ), + "blocks/wallet-card": dynamic(() => + import("./blocks/wallet-card.preview").then((m) => m.WalletCardPreview), + ), "motion/table": dynamic(() => import("./motion/table.preview").then((m) => m.TablePreview), ), diff --git a/lib/registry.ts b/lib/registry.ts index 24cde35..896b34e 100644 --- a/lib/registry.ts +++ b/lib/registry.ts @@ -520,6 +520,20 @@ export const registry: CategoryEntry[] = [ description: "Prediction market trade ticket with buy/sell modes, outcome prices, rolling amount entry, quick add chips and trade states.", file: "components/motion/prediction-market.tsx", }, + { + slug: "wallet-card", + name: "Wallet Card", + description: "Wallet overview card with an account switcher and search that morph open from their triggers, a cascading balance with a live change pill and privacy toggle, copy-address, and Send / Deposit / Swap / Buy actions.", + file: "components/motion/wallet-card/index.tsx", + badge: "new", + keywords: [ + "wallet card react", + "web3 wallet component", + "crypto balance component", + "account switcher react", + "chain switcher react", + ], + }, { slug: "otp-input", name: "OTP Input",