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
+
+ );
+}
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. */}
+
+ );
+}
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 ? (
+
+