Wallet Card
NewWallet 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.
Balance
+$124.50
TSXcomponents/previews/blocks/wallet-card.preview.tsx
"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 (
<div className="flex w-full flex-col items-center gap-4 p-6">
<WalletCard
accounts={ACCOUNTS}
balance={balance}
defaultChange={124.5}
searchRecent={RECENT_SEARCHES}
hasNotifications
/>
<Button
variant="ghost"
size="sm"
onClick={() => setBalance((b) => b + (Math.random() > 0.5 ? 1 : -1) * (50 + Math.random() * 400))}
>
Simulate balance change
</Button>
</div>
);
}
TSXcomponents/motion/wallet-card/index.tsx
"use client";
// beui.dev/components/blocks/wallet-card
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 (
<div
className={cn(
"relative w-full max-w-xs overflow-hidden rounded-4xl border border-border p-6",
className,
)}
>
{/* relative anchor so the switcher + search panels span the whole row */}
<div className="relative flex items-center justify-between gap-2">
<AccountSwitcher
accounts={accounts}
activeAccount={activeAccount}
onSelect={handleAccountChange}
/>
<div className="flex shrink-0 items-center gap-1">
<SearchBar
placeholder={searchPlaceholder}
recent={searchRecent}
onChange={onSearchChange}
onSubmit={onSearchSubmit}
/>
<Button
variant="ghost"
size="icon"
onClick={onNotifications}
aria-label="Notifications"
className="relative"
>
<Bell className="h-4 w-4" />
{hasNotifications ? (
<span className="absolute top-1.5 right-1.5 flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-60" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-primary" />
</span>
) : null}
</Button>
</div>
</div>
<div className="mt-8 flex flex-col items-center text-center">
<div className="flex items-center gap-1.5">
<p className="text-xs text-muted-foreground">Balance</p>
<button
type="button"
onClick={() => setBalanceHidden((h) => !h)}
aria-label={balanceHidden ? "Show balance" : "Hide balance"}
aria-pressed={balanceHidden}
className="text-muted-foreground outline-none transition-colors hover:text-foreground"
>
{balanceHidden ? (
<EyeOff className="h-3.5 w-3.5" />
) : (
<Eye className="h-3.5 w-3.5" />
)}
</button>
</div>
{/* One ActionSwapText swaps the number and the asterisk mask with a
per-letter cascade — same baseline, no overlap or layout shift. */}
<ActionSwapText
value={balanceHidden ? "hidden" : shownBalance}
animation="cascade"
className="text-3xl font-semibold text-foreground"
>
{balanceHidden ? maskedBalance : shownBalance}
</ActionSwapText>
{balanceHidden ? (
<div className="mt-2 flex h-7 items-center justify-center">
<span className="translate-y-[3px] text-sm font-semibold text-muted-foreground leading-none tracking-[0.3em]">
*****
</span>
</div>
) : (
<BalanceDelta balance={balance} initialChange={defaultChange} />
)}
</div>
<div className="mt-8">
<WalletActions
onSend={onSend}
onDeposit={onDeposit}
onSwap={onSwap}
onBuy={onBuy}
/>
</div>
</div>
);
}
Install
Add it with the shadcn CLI, or copy the source manually.
$ bunx --bun shadcn add @beui/wallet-card
Needs the theme tokens once. Already ran
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
TSXlib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
TSXlib/ease.ts
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
TSXlib/hooks/use-hover-capable.ts
"use client";
import { useEffect, useState } from "react";
/**
* Returns true only on devices that have a true hover (mouse / trackpad).
* Touch devices fire phantom `:hover` on tap that sticks until tap-elsewhere
* — gate hover-only effects (scale lifts, magnetic pulls) behind this.
*/
export function useHoverCapable() {
const [canHover, setCanHover] = useState(false);
useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const mq = window.matchMedia("(hover: hover) and (pointer: fine)");
const update = () => setCanHover(mq.matches);
update();
mq.addEventListener?.("change", update);
return () => mq.removeEventListener?.("change", update);
}, []);
return canHover;
}
Copy the source code
TSXcomponents/motion/wallet-card/index.tsx
"use client";
// beui.dev/components/blocks/wallet-card
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 (
<div
className={cn(
"relative w-full max-w-xs overflow-hidden rounded-4xl border border-border p-6",
className,
)}
>
{/* relative anchor so the switcher + search panels span the whole row */}
<div className="relative flex items-center justify-between gap-2">
<AccountSwitcher
accounts={accounts}
activeAccount={activeAccount}
onSelect={handleAccountChange}
/>
<div className="flex shrink-0 items-center gap-1">
<SearchBar
placeholder={searchPlaceholder}
recent={searchRecent}
onChange={onSearchChange}
onSubmit={onSearchSubmit}
/>
<Button
variant="ghost"
size="icon"
onClick={onNotifications}
aria-label="Notifications"
className="relative"
>
<Bell className="h-4 w-4" />
{hasNotifications ? (
<span className="absolute top-1.5 right-1.5 flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-60" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-primary" />
</span>
) : null}
</Button>
</div>
</div>
<div className="mt-8 flex flex-col items-center text-center">
<div className="flex items-center gap-1.5">
<p className="text-xs text-muted-foreground">Balance</p>
<button
type="button"
onClick={() => setBalanceHidden((h) => !h)}
aria-label={balanceHidden ? "Show balance" : "Hide balance"}
aria-pressed={balanceHidden}
className="text-muted-foreground outline-none transition-colors hover:text-foreground"
>
{balanceHidden ? (
<EyeOff className="h-3.5 w-3.5" />
) : (
<Eye className="h-3.5 w-3.5" />
)}
</button>
</div>
{/* One ActionSwapText swaps the number and the asterisk mask with a
per-letter cascade — same baseline, no overlap or layout shift. */}
<ActionSwapText
value={balanceHidden ? "hidden" : shownBalance}
animation="cascade"
className="text-3xl font-semibold text-foreground"
>
{balanceHidden ? maskedBalance : shownBalance}
</ActionSwapText>
{balanceHidden ? (
<div className="mt-2 flex h-7 items-center justify-center">
<span className="translate-y-[3px] text-sm font-semibold text-muted-foreground leading-none tracking-[0.3em]">
*****
</span>
</div>
) : (
<BalanceDelta balance={balance} initialChange={defaultChange} />
)}
</div>
<div className="mt-8">
<WalletActions
onSend={onSend}
onDeposit={onDeposit}
onSwap={onSwap}
onBuy={onBuy}
/>
</div>
</div>
);
}
TSXcomponents/motion/wallet-card/account-switcher.tsx
"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<HTMLDivElement>(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.
<div ref={rootRef} className="min-w-0">
{/* 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. */}
<div aria-hidden className={cn(HEAD, "pointer-events-none opacity-0")}>
<AccountAvatar account={activeAccount ?? accounts[0]} />
<span className="grid">
{accounts.map((account) => (
<span
key={account.id}
className="col-start-1 row-start-1 whitespace-nowrap text-sm font-medium"
>
{account.name}
</span>
))}
</span>
<ChevronDown className="h-4 w-4" />
</div>
<AnimatePresence initial={false} mode="popLayout">
{open ? null : (
<motion.button
key="trigger"
layoutId={layoutId}
type="button"
aria-haspopup="listbox"
aria-expanded={false}
onClick={() => 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 ? (
<>
<AccountAvatar account={activeAccount} />
<motion.span
layout="position"
className="truncate text-sm font-medium text-foreground"
>
{activeAccount.name}
</motion.span>
<motion.span layout="position" className="text-muted-foreground">
<ChevronDown className="h-4 w-4" />
</motion.span>
</>
) : null}
</motion.button>
)}
</AnimatePresence>
<AnimatePresence initial={false} mode="popLayout">
{open ? (
<motion.div
key="panel"
layoutId={layoutId}
role="listbox"
transition={morph}
style={{ borderRadius: 16 }}
className="absolute top-0 -right-2 -left-2 z-30 overflow-hidden border border-border/30 bg-background backdrop-blur-md"
>
<button
type="button"
onClick={() => setOpen(false)}
className={cn(HEAD, "w-full outline-none")}
>
{activeAccount ? <AccountAvatar account={activeAccount} /> : null}
<motion.span
layout="position"
className="min-w-0 flex-1 truncate text-sm font-medium text-foreground"
>
{activeAccount?.name ?? "Select account"}
</motion.span>
<motion.span
layout="position"
animate={{ rotate: 180 }}
transition={morph}
className="text-muted-foreground"
>
<ChevronDown className="h-4 w-4" />
</motion.span>
</button>
<motion.ul
initial="hidden"
animate="show"
variants={reduce ? undefined : LIST}
className={cn(
"max-h-64 overflow-y-auto p-1.5",
armed ? "" : "pointer-events-none",
)}
>
{accounts.map((account) => {
const selected = account.id === activeAccount?.id;
return (
<motion.li
key={account.id}
variants={reduce ? undefined : ITEM}
className={cn(
"flex items-center rounded-xl pr-1 text-sm transition-colors",
selected
? "bg-muted text-foreground"
: cn(
"text-muted-foreground",
armed && "hover:bg-muted hover:text-foreground",
),
)}
>
<button
type="button"
role="option"
aria-selected={selected}
onClick={() => {
onSelect(account.id);
setOpen(false);
}}
className="flex min-w-0 flex-1 items-center gap-2.5 rounded-xl px-2 py-2 text-left outline-none"
>
<AccountAvatar account={account} />
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate font-medium">
{account.name}
</span>
<span className="truncate text-xs text-muted-foreground">
{truncateAddress(account.address)}
</span>
</span>
{selected ? (
<Check className="h-4 w-4 shrink-0 text-foreground" />
) : null}
</button>
<CopyButton value={account.address} />
</motion.li>
);
})}
</motion.ul>
</motion.div>
) : null}
</AnimatePresence>
</div>
);
}
TSXcomponents/motion/wallet-card/actions.tsx
"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 (
<div className="flex items-start justify-between gap-2">
{actions.map(({ key, label, icon: Icon, onClick }) => (
<motion.button
key={key}
type="button"
onClick={onClick}
whileTap={reduce ? undefined : { scale: 0.94 }}
transition={SPRING_PRESS}
className="flex flex-1 flex-col items-center gap-2 outline-none"
>
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-muted text-foreground">
<Icon className="h-5 w-5" />
</span>
<span className="text-xs font-medium text-muted-foreground">
{label}
</span>
</motion.button>
))}
</div>
);
}
TSXcomponents/motion/wallet-card/balance-delta.tsx
"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 (
<div className="mt-2 flex h-7 items-center justify-center">
<AnimatePresence mode="wait">
{delta ? (
<motion.span
key={delta.id}
initial={{ opacity: 0, y: reduce ? 0 : 6, scale: reduce ? 1 : 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: reduce ? 0 : -6, scale: reduce ? 1 : 0.9 }}
transition={{ duration: 0.2, ease: EASE_OUT }}
className={cn(
"inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-semibold tabular-nums",
up
? "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400"
: "bg-red-500/15 text-red-600 dark:text-red-400",
)}
>
{up ? (
<TrendingUp className="h-3.5 w-3.5" />
) : (
<TrendingDown className="h-3.5 w-3.5" />
)}
{up ? "+" : "-"}$
{Math.abs(delta.amount).toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</motion.span>
) : null}
</AnimatePresence>
</div>
);
}
TSXcomponents/motion/wallet-card/search-bar.tsx
"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<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(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.
<div ref={rootRef} className="shrink-0">
{/* reserve the icon's width while open (the icon has left the flow) */}
{open ? <div aria-hidden className="h-8 w-8" /> : null}
<AnimatePresence initial={false} mode="popLayout">
{open ? null : (
<motion.button
key="icon"
layoutId={layoutId}
type="button"
aria-label="Search"
onClick={() => 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"
>
<Search className="h-4 w-4" />
</motion.button>
)}
</AnimatePresence>
{/* 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. */}
<AnimatePresence initial={false} mode="popLayout">
{open ? (
<motion.div
key="panel"
layoutId={layoutId}
transition={morph}
style={{ borderRadius: 16 }}
className="absolute top-0 -right-2 -left-2 z-40 overflow-hidden border border-border/30 bg-background backdrop-blur-md"
>
<div className="flex h-9 items-center gap-2 px-3">
<motion.span
layout="position"
transition={glide}
className="shrink-0"
>
<Search className="h-4 w-4 text-muted-foreground" />
</motion.span>
<motion.input
ref={inputRef}
layout="position"
initial={reduce ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
transition={{
opacity: { duration: 0.15, delay: 0.12, ease: EASE_OUT },
layout: glide,
}}
value={value}
onChange={(e) => {
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"
/>
</div>
<div className="h-px bg-border/40" />
{filtered.length > 0 ? (
<motion.ul
initial="hidden"
animate="show"
variants={reduce ? undefined : LIST}
className={cn(
"max-h-56 overflow-y-auto p-1.5",
armed ? "" : "pointer-events-none",
)}
>
{filtered.map((term) => (
<motion.li
key={term}
layout="position"
variants={reduce ? undefined : ITEM}
>
<button
type="button"
onClick={() => {
setValue(term);
onChange?.(term);
submit(term);
}}
className={cn(
"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",
armed && "hover:bg-muted hover:text-foreground",
)}
>
<History className="h-4 w-4 shrink-0" />
<span className="min-w-0 flex-1 truncate">{term}</span>
</button>
</motion.li>
))}
</motion.ul>
) : (
<motion.div
layout="position"
className="flex flex-col items-center gap-1 px-4 py-8 text-center"
>
<Search className="h-5 w-5 text-muted-foreground/60" />
<p className="text-sm text-muted-foreground">
{query ? "No matches" : "No recent searches"}
</p>
</motion.div>
)}
</motion.div>
) : null}
</AnimatePresence>
</div>
);
}
TSXcomponents/motion/wallet-card/types.ts
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;
}
TSXcomponents/motion/action-swap.tsx
"use client";
import { AnimatePresence, motion, useReducedMotion, type HTMLMotionProps, type Variants } from "motion/react";
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { EASE_OUT, EASE_OUT_CSS, SPRING_PRESS, SPRING_SWAP } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type ActionSwapItem = {
id: string;
label: ReactNode;
icon?: ReactNode;
ariaLabel?: string;
};
export type ActionSwapButtonVariant = "primary" | "secondary" | "outline" | "ghost";
export type ActionSwapButtonSize = "sm" | "md" | "lg" | "icon";
export type ActionSwapAnimation = "blur" | "roll" | "cascade";
/** Animations with a single-element variant set (cascade animates per letter). */
type CoreAnimation = "blur" | "roll";
export interface ActionSwapButtonProps extends Omit<
HTMLMotionProps<"button">,
"children" | "onChange"
> {
items: ActionSwapItem[];
value?: string;
defaultValue?: string;
onValueChange?: (value: string, item: ActionSwapItem) => void;
variant?: ActionSwapButtonVariant;
size?: ActionSwapButtonSize;
animation?: ActionSwapAnimation;
iconOnly?: boolean;
cycle?: boolean;
}
export interface ActionSwapTextProps {
value: string;
children: ReactNode;
animation?: ActionSwapAnimation;
className?: string;
}
export interface ActionSwapIconProps {
value: string;
children: ReactNode;
animation?: ActionSwapAnimation;
className?: string;
}
const BLUR_TRANSITION = { duration: 0.2, ease: "easeInOut" } as const;
const ROLL_TRANSITION = { duration: 0.24, ease: EASE_OUT } as const;
const SWAP_BLUR = "blur(8px)";
const ROLL_BLUR = "blur(6px)";
// Cascade rolls the label one letter at a time, left to right. The leaving
// and landing strings overlap as independent layers (no shared cells), so
// proportional glyph widths never jitter. Exits cascade at half the enter
// stagger so the tail of the old label lingers briefly.
const CASCADE_STAGGER = 0.025;
const CASCADE_LETTER_VARIANTS: Variants = {
initial: { opacity: 0, y: "105%", filter: ROLL_BLUR },
animate: (delay: number = 0) => ({
opacity: 1,
y: "0%",
filter: "blur(0px)",
transition: { ...SPRING_SWAP, delay },
}),
exit: (delay: number = 0) => ({
opacity: 0,
y: "-105%",
filter: ROLL_BLUR,
transition: { duration: 0.16, ease: EASE_OUT, delay: delay * 0.5 },
}),
};
const TEXT_VARIANTS: Record<CoreAnimation, Variants> = {
blur: {
initial: { opacity: 0, scale: 0.94, filter: SWAP_BLUR },
animate: {
opacity: 1,
scale: 1,
filter: "blur(0px)",
transition: BLUR_TRANSITION,
},
exit: {
opacity: 0,
scale: 0.94,
filter: SWAP_BLUR,
transition: BLUR_TRANSITION,
},
},
roll: {
initial: { opacity: 0, y: "115%", filter: ROLL_BLUR },
animate: {
opacity: 1,
y: "0%",
filter: "blur(0px)",
transition: ROLL_TRANSITION,
},
exit: {
opacity: 0,
y: "-115%",
filter: ROLL_BLUR,
transition: { duration: 0.18, ease: "easeInOut" },
},
},
};
const ICON_VARIANTS: Record<CoreAnimation, Variants> = {
blur: {
initial: { opacity: 0, scale: 0.25, filter: SWAP_BLUR },
animate: {
opacity: 1,
scale: 1,
filter: "blur(0px)",
transition: BLUR_TRANSITION,
},
exit: {
opacity: 0,
scale: 0.25,
filter: SWAP_BLUR,
transition: BLUR_TRANSITION,
},
},
roll: {
initial: { opacity: 0, y: 16, filter: ROLL_BLUR },
animate: {
opacity: 1,
y: 0,
filter: "blur(0px)",
transition: ROLL_TRANSITION,
},
exit: {
opacity: 0,
y: -16,
filter: ROLL_BLUR,
transition: { duration: 0.18, ease: "easeInOut" },
},
},
};
const VARIANT_CLASS: Record<ActionSwapButtonVariant, string> = {
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
secondary: "border border-border bg-card text-foreground hover:border-border",
outline: "border border-border bg-transparent text-foreground hover:bg-primary/5",
ghost: "text-muted-foreground hover:bg-primary/5 hover:text-foreground",
};
const SIZE_CLASS: Record<ActionSwapButtonSize, string> = {
sm: "h-8 gap-1.5 rounded-full px-3 text-xs",
md: "h-10 gap-2 rounded-full px-4 text-sm",
lg: "h-12 gap-2.5 rounded-full px-5 text-base",
icon: "h-10 w-10 rounded-full",
};
export function ActionSwapText({
value,
children,
animation = "blur",
className,
}: ActionSwapTextProps) {
const reduce = useReducedMotion();
const measureRef = useRef<HTMLSpanElement>(null);
const [width, setWidth] = useState<number>();
useLayoutEffect(() => {
const nextWidth = measureRef.current?.offsetWidth;
if (!nextWidth) return;
setWidth((currentWidth) => (currentWidth === nextWidth ? currentWidth : nextWidth));
});
// Cascade needs a plain string to split into letters; non-string content
// and reduced motion fall back to the closest single-element animation.
const label = typeof children === "string" ? children : null;
const cascade = animation === "cascade" && label !== null && !reduce;
const coreAnimation: CoreAnimation =
animation === "cascade" ? "roll" : animation;
return (
<span
className={cn("relative inline-block overflow-hidden whitespace-nowrap align-bottom", className)}
style={{
width,
transition: reduce ? undefined : `width 220ms ${EASE_OUT_CSS}`,
}}
>
<span
ref={measureRef}
aria-hidden
className="invisible inline-block whitespace-nowrap"
>
{children}
</span>
{cascade ? (
<>
{/* Letters are decorative fragments; readers get the whole label. */}
<span className="sr-only">{label}</span>
<AnimatePresence initial={false}>
<motion.span
key={`cascade-${value}`}
aria-hidden
initial="initial"
animate="animate"
exit="exit"
className="absolute left-0 top-0 inline-block whitespace-pre"
>
{label.split("").map((char, i) => (
<motion.span
// biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity — the letter at a position is exactly what rolls.
key={i}
custom={i * CASCADE_STAGGER}
variants={CASCADE_LETTER_VARIANTS}
className="inline-block whitespace-pre will-change-[opacity,filter,transform]"
>
{char}
</motion.span>
))}
</motion.span>
</AnimatePresence>
</>
) : (
<AnimatePresence initial={false}>
<motion.span
key={`${animation}-${value}`}
variants={TEXT_VARIANTS[coreAnimation]}
initial={reduce ? false : "initial"}
animate={reduce ? { opacity: 1, filter: "blur(0px)", scale: 1, y: 0 } : "animate"}
exit={reduce ? undefined : "exit"}
className="absolute left-0 top-0 inline-block will-change-[opacity,filter,transform]"
>
{children}
</motion.span>
</AnimatePresence>
)}
</span>
);
}
export function ActionSwapIcon({
value,
children,
animation = "blur",
className,
}: ActionSwapIconProps) {
const reduce = useReducedMotion();
// Icons are single elements — cascade maps to its closest motion, roll.
const coreAnimation: CoreAnimation =
animation === "cascade" ? "roll" : animation;
return (
<span className={cn("relative inline-grid shrink-0 place-items-center overflow-hidden", className)}>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={`${animation}-${value}`}
aria-hidden
variants={ICON_VARIANTS[coreAnimation]}
initial={reduce ? false : "initial"}
animate={reduce ? { opacity: 1, filter: "blur(0px)", scale: 1, y: 0 } : "animate"}
exit={reduce ? undefined : "exit"}
className="col-start-1 row-start-1 inline-flex items-center justify-center will-change-[opacity,filter,transform]"
>
{children}
</motion.span>
</AnimatePresence>
</span>
);
}
export function ActionSwapButton({
items,
value,
defaultValue,
onValueChange,
variant = "secondary",
size = "md",
animation = "blur",
iconOnly = size === "icon",
cycle = true,
className,
disabled,
onClick,
...rest
}: ActionSwapButtonProps) {
const reduce = useReducedMotion();
const [internalValue, setInternalValue] = useState(defaultValue ?? items[0]?.id);
const currentValue = value ?? internalValue;
const activeIndex = Math.max(0, items.findIndex((item) => item.id === currentValue));
const activeItem = items[activeIndex] ?? items[0];
const hasIcon = items.some((item) => item.icon);
const nextItem = cycle && items.length > 0 ? items[(activeIndex + 1) % items.length] : undefined;
if (!activeItem) return null;
const accessibleLabel = activeItem.ariaLabel ?? (iconOnly && typeof activeItem.label === "string" ? activeItem.label : undefined);
return (
<motion.button
type="button"
disabled={disabled}
whileTap={reduce || disabled ? undefined : { scale: 0.97 }}
transition={SPRING_PRESS}
className={cn(
"inline-flex items-center justify-center overflow-hidden font-medium transition-colors",
"disabled:pointer-events-none disabled:opacity-50",
VARIANT_CLASS[variant],
SIZE_CLASS[size],
className,
)}
aria-label={accessibleLabel}
onClick={(event) => {
onClick?.(event);
if (event.defaultPrevented || disabled || !cycle || !nextItem) return;
if (value === undefined) setInternalValue(nextItem.id);
onValueChange?.(nextItem.id, nextItem);
}}
{...rest}
>
{hasIcon ? (
<ActionSwapIcon value={activeItem.id} animation={animation} className="h-4 w-4">
{activeItem.icon ?? null}
</ActionSwapIcon>
) : null}
{!iconOnly ? (
<ActionSwapText value={activeItem.id} animation={animation}>
{activeItem.label}
</ActionSwapText>
) : null}
</motion.button>
);
}
TSXcomponents/motion/button/index.tsx
export { Button } from "./base";
export type { ButtonProps, ButtonVariant, ButtonSize } from "./base";
export { StatefulButton } from "./stateful";
export type { StatefulButtonProps, ButtonState } from "./stateful";
export { MagneticButton } from "./magnetic";
export type { MagneticButtonProps } from "./magnetic";
TSXcomponents/motion/wallet-card/account-avatar.tsx
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
<img
src={diceBearGlassUrl(account.id || account.address)}
alt={account.name}
className={cn("h-7 w-7 shrink-0 rounded-full bg-muted", className)}
/>
);
}
TSXcomponents/motion/wallet-card/constants.ts
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";
TSXcomponents/motion/wallet-card/copy-button.tsx
"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 (
<button
type="button"
aria-label={copied ? "Copied" : "Copy address"}
onClick={(e) => {
e.stopPropagation();
copy();
}}
className={cn(
"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",
className,
)}
>
<ActionSwapIcon
value={copied ? "check" : "copy"}
animation="cascade"
className="h-3.5 w-3.5"
>
{copied ? (
<Check className="h-3.5 w-3.5 text-emerald-500" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</ActionSwapIcon>
</button>
);
}
TSXcomponents/motion/wallet-card/use-dismiss.ts
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<HTMLElement | null>,
) {
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]);
}
TSXcomponents/motion/wallet-card/utils.ts
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;
}
TSXcomponents/motion/button/base.tsx
"use client";
import {
AnimatePresence,
motion,
useReducedMotion,
type HTMLMotionProps,
} from "motion/react";
import {
forwardRef,
type PointerEvent,
type ReactNode,
useCallback,
useRef,
useState,
} from "react";
import { EASE_OUT, SPRING_PRESS } from "@/lib/ease";
import { cn } from "@/lib/utils";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
export type ButtonVariant = "primary" | "secondary" | "ghost" | "outline";
export type ButtonSize = "sm" | "md" | "lg" | "icon";
export interface ButtonProps extends Omit<
HTMLMotionProps<"button">,
"children"
> {
variant?: ButtonVariant;
size?: ButtonSize;
pressScale?: number;
/** Spawn a Material-style ripple from the press point. Off by default. */
ripple?: boolean;
children?: ReactNode;
}
type Ripple = { id: number; x: number; y: number; size: number };
const VARIANT_CLASS: Record<ButtonVariant, string> = {
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
secondary: "border border-border bg-card text-foreground hover:border-border",
ghost: "text-muted-foreground hover:text-foreground hover:bg-primary/5",
outline:
"border border-border bg-transparent text-foreground hover:bg-primary/5",
};
const SIZE_CLASS: Record<ButtonSize, string> = {
sm: "h-8 px-3 text-xs gap-1.5 rounded-full",
md: "h-10 px-5 text-sm gap-2 rounded-full",
lg: "h-12 px-6 text-base gap-2 rounded-full",
icon: "h-8 w-8 rounded-lg",
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
function Button(
{
variant = "primary",
size = "md",
pressScale = 0.93,
ripple = false,
className,
children,
onPointerDown,
...rest
},
ref,
) {
const reduce = useReducedMotion();
const canHover = useHoverCapable();
const [ripples, setRipples] = useState<Ripple[]>([]);
const nextId = useRef(0);
const handlePointerDown = useCallback(
(event: PointerEvent<HTMLButtonElement>) => {
if (ripple && !reduce) {
const rect = event.currentTarget.getBoundingClientRect();
const size = Math.max(rect.width, rect.height) * 2;
setRipples((prev) => [
...prev,
{
id: nextId.current++,
x: event.clientX - rect.left,
y: event.clientY - rect.top,
size,
},
]);
}
onPointerDown?.(event);
},
[ripple, reduce, onPointerDown],
);
return (
<motion.button
ref={ref}
type="button"
whileTap={reduce ? undefined : { scale: pressScale }}
whileHover={reduce || !canHover ? undefined : { scale: 1.02 }}
transition={SPRING_PRESS}
onPointerDown={handlePointerDown}
className={cn(
"inline-flex items-center justify-center font-medium select-none",
"transition-colors",
"disabled:pointer-events-none disabled:opacity-50",
ripple && "relative overflow-hidden",
VARIANT_CLASS[variant],
SIZE_CLASS[size],
className,
)}
{...rest}
>
{ripple && !reduce ? (
<span className="pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]">
<AnimatePresence>
{ripples.map((r) => (
<motion.span
key={r.id}
className="absolute rounded-full bg-current"
style={{
left: r.x,
top: r.y,
width: r.size,
height: r.size,
x: "-50%",
y: "-50%",
}}
initial={{ scale: 0, opacity: 0.3 }}
animate={{ scale: 1, opacity: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 1.6, ease: EASE_OUT }}
onAnimationComplete={() =>
setRipples((prev) => prev.filter((x) => x.id !== r.id))
}
/>
))}
</AnimatePresence>
</span>
) : null}
{children}
</motion.button>
);
},
);
TSXcomponents/motion/button/magnetic.tsx
"use client";
import { forwardRef } from "react";
import { Magnetic } from "../magnetic";
import { Button, type ButtonProps } from "./base";
export interface MagneticButtonProps extends ButtonProps {
/** Magnetic pull strength. Default 0.25. */
strength?: number;
/** Class applied to the magnetic wrapper. */
magneticClassName?: string;
}
export const MagneticButton = forwardRef<HTMLButtonElement, MagneticButtonProps>(function MagneticButton(
{ strength = 0.25, magneticClassName, children, ...rest },
ref,
) {
return (
<Magnetic strength={strength} className={magneticClassName}>
<Button ref={ref} {...rest}>
{children}
</Button>
</Magnetic>
);
});
TSXcomponents/motion/button/stateful.tsx
"use client";
import {
AnimatePresence,
motion,
useReducedMotion,
type Variants,
} from "motion/react";
import { Check, Loader2, X } from "lucide-react";
import {
forwardRef,
useLayoutEffect,
useRef,
useState,
type ReactNode,
} from "react";
import { EASE_OUT, SPRING_SWAP } from "@/lib/ease";
import { Button, type ButtonProps } from "./base";
export type ButtonState = "idle" | "loading" | "success" | "error";
export interface StatefulButtonProps extends Omit<ButtonProps, "children"> {
state?: ButtonState;
children: ReactNode;
loadingText?: ReactNode;
successText?: ReactNode;
errorText?: ReactNode;
icon?: ReactNode;
}
const CASCADE_STAGGER = 0.025;
const ROLL_BLUR = "blur(6px)";
const CASCADE_LETTER_VARIANTS: Variants = {
initial: { opacity: 0, y: "105%", filter: ROLL_BLUR },
animate: (delay: number = 0) => ({
opacity: 1,
y: "0%",
filter: "blur(0px)",
transition: { ...SPRING_SWAP, delay },
}),
exit: (delay: number = 0) => ({
opacity: 0,
y: "-105%",
filter: ROLL_BLUR,
transition: { duration: 0.16, ease: EASE_OUT, delay: delay * 0.5 },
}),
};
const ICON_VARIANTS: Variants = {
// Width collapses too, so the icon adds/removes its own space smoothly
// instead of popping the row width in a single frame.
initial: { opacity: 0, width: 0, scale: 0.7, filter: ROLL_BLUR },
animate: {
opacity: 1,
width: "1.5rem",
scale: 1,
filter: "blur(0px)",
transition: SPRING_SWAP,
},
exit: {
opacity: 0,
width: 0,
scale: 0.7,
filter: ROLL_BLUR,
transition: { duration: 0.16, ease: EASE_OUT },
},
};
function IconSlot({ keyId, children }: { keyId: string; children: ReactNode }) {
const reduce = useReducedMotion();
return (
<motion.span
key={keyId}
variants={ICON_VARIANTS}
initial={reduce ? { opacity: 0 } : "initial"}
animate={reduce ? { opacity: 1 } : "animate"}
exit={reduce ? { opacity: 0 } : "exit"}
transition={reduce ? { duration: 0.15 } : undefined}
className="inline-grid shrink-0 place-items-center overflow-hidden"
>
{children}
</motion.span>
);
}
function TextSlot({
value,
children,
}: {
value: string;
children: ReactNode;
}) {
const reduce = useReducedMotion();
const measureRef = useRef<HTMLSpanElement>(null);
const [width, setWidth] = useState<number>();
const label = typeof children === "string" ? children : null;
const cascade = label !== null && !reduce;
// Width is set instantly from the measurer; the parent's single `layout`
// animation smooths the resize (text + icons together) so nothing competes.
useLayoutEffect(() => {
const nextWidth = measureRef.current?.offsetWidth;
if (!nextWidth) return;
setWidth((current) => (current === nextWidth ? current : nextWidth));
});
return (
<motion.span
initial={false}
animate={{ width }}
transition={reduce ? { duration: 0 } : SPRING_SWAP}
className="relative inline-block overflow-hidden whitespace-nowrap align-bottom"
>
<span
ref={measureRef}
aria-hidden
className="invisible inline-block whitespace-nowrap"
>
{children}
</span>
{cascade ? (
<>
<span className="sr-only">{label}</span>
<AnimatePresence initial={false}>
<motion.span
key={`cascade-${value}`}
aria-hidden
initial="initial"
animate="animate"
exit="exit"
className="absolute left-0 top-0 inline-block whitespace-pre"
>
{label.split("").map((char, index) => (
<motion.span
// biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity.
key={index}
custom={index * CASCADE_STAGGER}
variants={CASCADE_LETTER_VARIANTS}
className="inline-block whitespace-pre will-change-[opacity,filter,transform]"
>
{char}
</motion.span>
))}
</motion.span>
</AnimatePresence>
</>
) : (
<AnimatePresence initial={false}>
<motion.span
key={`text-${value}`}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 14, filter: ROLL_BLUR }}
animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0, filter: "blur(0px)" }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -14, filter: ROLL_BLUR }}
transition={reduce ? { duration: 0.15 } : SPRING_SWAP}
className="absolute left-0 top-0 inline-block will-change-[opacity,filter,transform]"
>
{children}
</motion.span>
</AnimatePresence>
)}
</motion.span>
);
}
export const StatefulButton = forwardRef<HTMLButtonElement, StatefulButtonProps>(function StatefulButton(
{
state = "idle",
children,
loadingText = "Loading",
successText = "Done",
errorText = "Try again",
icon,
disabled,
...rest
},
ref,
) {
const isBusy = state === "loading";
const stateText =
state === "loading"
? loadingText
: state === "success"
? successText
: state === "error"
? errorText
: children;
const textKey =
typeof stateText === "string" ? `${state}-${stateText}` : state;
return (
<Button ref={ref} disabled={disabled || isBusy} aria-busy={isBusy} whileHover={undefined} {...rest}>
<span
aria-live="polite"
className="relative inline-flex items-center justify-center overflow-hidden"
>
<AnimatePresence initial={false}>
{state === "loading" ? (
<IconSlot keyId="loading-icon">
<Loader2 className="h-4 w-4 animate-spin" />
</IconSlot>
) : null}
{state === "success" ? (
<IconSlot keyId="success-icon">
<Check className="h-4 w-4" />
</IconSlot>
) : null}
{state === "error" ? (
<IconSlot keyId="error-icon">
<X className="h-4 w-4" />
</IconSlot>
) : null}
</AnimatePresence>
<TextSlot value={textKey}>{stateText}</TextSlot>
<AnimatePresence initial={false}>
{state === "idle" && icon ? (
<IconSlot keyId="idle-icon">{icon}</IconSlot>
) : null}
</AnimatePresence>
</span>
</Button>
);
});
TSXcomponents/motion/magnetic.tsx
"use client";
import { motion, useMotionValue, useReducedMotion, useSpring } from "motion/react";
import { useRef, type ReactNode } from "react";
import { SPRING_MOUSE } from "@/lib/ease";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { cn } from "@/lib/utils";
export interface MagneticProps {
children: ReactNode;
strength?: number;
className?: string;
}
export function Magnetic({ children, strength = 0.35, className }: MagneticProps) {
const ref = useRef<HTMLDivElement>(null);
const reduce = useReducedMotion();
const canHover = useHoverCapable();
// Decorative cursor-follow: skip on touch (phantom hover) and reduced motion.
const enabled = !reduce && canHover;
const x = useMotionValue(0);
const y = useMotionValue(0);
const sx = useSpring(x, SPRING_MOUSE);
const sy = useSpring(y, SPRING_MOUSE);
const onMove = (e: React.MouseEvent<HTMLDivElement>) => {
const el = ref.current;
if (!el || !enabled) return;
const rect = el.getBoundingClientRect();
x.set((e.clientX - rect.left - rect.width / 2) * strength);
y.set((e.clientY - rect.top - rect.height / 2) * strength);
};
const onLeave = () => {
x.set(0);
y.set(0);
};
return (
<motion.div
ref={ref}
onMouseMove={onMove}
onMouseLeave={onLeave}
style={{ x: sx, y: sy }}
className={cn("inline-block", className)}
>
{children}
</motion.div>
);
}
Related components
Multi-chain Swap
Cross-chain swap widget with chain + token selectors, morphing views, animated flip and quote.
Dynamic Island
iOS-style island pill that morphs between live activity views with bouncy shell resize and blur crossfades.
Command Palette
⌘K palette with fuzzy filter, spring-animated active row and glass surface.