` with `animate-pulse rounded-md bg-muted`. Pass any `className` to control size and shape.
+
+---
+
+## Customization
+
+Components support three levels of customization, from lightest touch to full control.
+
+### CSS token overrides
+
+Override CSS custom properties on any ancestor element to re-theme an entire subtree. See the [Theming](#theming) section above for the full token table.
+
+```css
+.my-brand {
+ --primary: oklch(0.65 0.25 160);
+ --card: oklch(0.18 0.02 160);
+}
+```
+
+```tsx
+
+
+
+```
+
+### className overrides
+
+Every component accepts a `className` prop. Classes are merged with [tailwind-merge](https://github.com/dcastil/tailwind-merge), so your overrides always win over defaults — no `!important` needed.
+
+```tsx
+{/* Sharp corners */}
+
+
+{/* Extra rounded with shadow */}
+
+
+{/* Pill-shaped wallet button */}
+
+ Connect
+
+
+{/* Custom skeleton color */}
+
+```
+
+### Sub-component composition
+
+Each composite component exports its building blocks so you can assemble custom layouts without the parent wrapper.
+
+#### BalanceCard exports
+
+| Export | Description |
+|---|---|
+| `BalanceCard` | Full card with header, balance, token list |
+| `BalanceAmount` | Formatted balance display (bigint → human-readable) |
+| `TokenList` | Expandable token list with icons and fiat values |
+| `BalanceCardSkeleton` | Loading skeleton |
+| `ErrorState` | Error with retry button |
+
+#### ConnectWalletButton exports
+
+| Export | Description |
+|---|---|
+| `ConnectWalletButton` | Full button with dropdown |
+| `WalletButton` | Styled button (no dropdown logic) |
+| `ButtonIcon` | Wallet icon renderer |
+| `ButtonContent` | Label text wrapper |
+| `ButtonSpinner` | Loading spinner |
+| `WalletDropdown` | Connected-state dropdown |
+
+#### NetworkSwitcher exports
+
+| Export | Description |
+|---|---|
+| `NetworkSwitcher` | Full dropdown switcher |
+| `NetworkTrigger` | Trigger button with status dot |
+| `StatusIndicator` | Connection status dot (green/spinner/red) |
+| `NetworkDropdown` | Dropdown panel |
+| `NetworkOption` | Single network row |
+
+#### SwapInput exports
+
+| Export | Description |
+|---|---|
+| `SwapInput` | Two-card swap widget |
+| `TokenInput` | Single token input card (usable standalone for send/stake flows) |
+| `SwapInputSkeleton` | Loading skeleton |
+
+#### Composition example
+
+Build a custom portfolio card using sub-components directly:
+
+```tsx
+import { BalanceAmount, TokenList } from './kit-components/ui/balance-card';
+import { WalletButton, ButtonIcon } from './kit-components/ui/connect-wallet-button';
+import { NetworkTrigger, StatusIndicator } from './kit-components/ui/network-switcher';
+import { TokenInput } from './kit-components/ui/swap-input';
+
+{/* Custom portfolio card */}
+
+
+
+
+
+{/* Standalone wallet trigger */}
+
+ Launch Wallet
+
+
+{/* Standalone send input */}
+
+```
+
+---
+
+## Solana types reference
+
+These types come from the Solana packages and appear throughout the component APIs:
+
+| Type | Package | Description |
+|---|---|---|
+| `Address` | `@solana/kit` | Base58-encoded Solana address (branded string) |
+| `Lamports` | `@solana/kit` | SOL balance in lamports (branded bigint, 1 SOL = 1e9) |
+| `ClusterMoniker` | `@solana/client` | `'mainnet-beta' \| 'testnet' \| 'devnet' \| 'localnet'` |
+| `WalletConnectorMetadata` | `@solana/client` | `{ id, name, icon?, ready }` — wallet adapter info |
+| `WalletStatus` | `@solana/client` | `{ status: 'connected' \| 'connecting' \| 'error' }` |
+| `ClassifiedTransaction` | `tx-indexer` | Parsed and classified transaction with legs, amounts, and counterparty |
+
+---
+
+## Development
+
+```bash
+pnpm dev # Vite dev server
+pnpm storybook # Storybook on :6006
+pnpm build # TypeScript check + Vite build
+pnpm lint # Biome linter
+pnpm format # Biome formatter
+```
+
+Tests run with Vitest:
+
+```bash
+npx vitest run # All tests
+npx vitest run --reporter=verbose # Verbose output
+```
+
+Visual integration tests live in `tests/e2e-visual/` and exercise all components in a real DashboardShell layout with mock and live chain data.
diff --git a/packages/components/components.json b/packages/components/components.json
new file mode 100644
index 0000000..9102640
--- /dev/null
+++ b/packages/components/components.json
@@ -0,0 +1,22 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "new-york",
+ "rsc": false,
+ "tsx": true,
+ "tailwind": {
+ "config": "",
+ "css": "src/index.css",
+ "baseColor": "zinc",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "iconLibrary": "lucide",
+ "aliases": {
+ "components": "@/kit-components",
+ "utils": "@/kit-components/lib/utils",
+ "ui": "@/kit-components/ui",
+ "lib": "@/kit-components/lib",
+ "hooks": "@/hooks"
+ },
+ "registries": {}
+}
diff --git a/packages/components/index.html b/packages/components/index.html
new file mode 100644
index 0000000..f513ff5
--- /dev/null
+++ b/packages/components/index.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
components
+
+
+
+
+
+
diff --git a/packages/components/package.json b/packages/components/package.json
new file mode 100644
index 0000000..1a3fdd6
--- /dev/null
+++ b/packages/components/package.json
@@ -0,0 +1,59 @@
+{
+ "name": "components",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc -b && vite build",
+ "preview": "vite preview",
+ "storybook": "storybook dev -p 6006",
+ "build-storybook": "storybook build",
+ "build:shadcn": "pnpm shadcn build",
+ "format": "biome check --write src",
+ "lint": "biome check src"
+ },
+ "dependencies": {
+ "@radix-ui/react-toast": "^1.2.15",
+ "@solana/client": "^1.7.0",
+ "@solana/kit": "catalog:solana",
+ "@solana/react-hooks": "^1.4.1",
+ "@tailwindcss/vite": "^4.1.18",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "lucide-react": "^0.562.0",
+ "react": "^19.2.0",
+ "react-dom": "^19.2.0",
+ "tailwind-merge": "^3.4.0",
+ "tailwindcss": "^4.1.18",
+ "tx-indexer": "^1.5.0"
+ },
+ "devDependencies": {
+ "@chromatic-com/storybook": "^4.1.3",
+ "@eslint/js": "^9.39.1",
+ "@storybook/addon-a11y": "^10.1.11",
+ "@storybook/addon-docs": "^10.1.11",
+ "@storybook/addon-onboarding": "^10.1.11",
+ "@storybook/addon-vitest": "^10.1.11",
+ "@storybook/react": "^10.1.11",
+ "@storybook/react-vite": "^10.1.11",
+ "@types/node": "^24.10.1",
+ "@types/react": "^19.2.5",
+ "@types/react-dom": "^19.2.3",
+ "@types/storybook__react": "^5.2.1",
+ "@vitejs/plugin-react": "^5.1.1",
+ "@vitest/browser-playwright": "^4.0.7",
+ "@vitest/coverage-v8": "^4.0.7",
+ "eslint": "^9.39.1",
+ "eslint-plugin-react-hooks": "^7.0.1",
+ "eslint-plugin-react-refresh": "^0.4.24",
+ "eslint-plugin-storybook": "^10.1.11",
+ "globals": "^16.5.0",
+ "playwright": "^1.57.0",
+ "storybook": "^10.1.11",
+ "tw-animate-css": "^1.4.0",
+ "typescript": "~5.9.3",
+ "typescript-eslint": "^8.46.4",
+ "vite": "^7.2.4"
+ }
+}
diff --git a/packages/components/registry.json b/packages/components/registry.json
new file mode 100644
index 0000000..fe51488
--- /dev/null
+++ b/packages/components/registry.json
@@ -0,0 +1 @@
+[]
diff --git a/packages/components/src/App.tsx b/packages/components/src/App.tsx
new file mode 100644
index 0000000..e69de29
diff --git a/packages/components/src/index.css b/packages/components/src/index.css
new file mode 100644
index 0000000..ebfcb01
--- /dev/null
+++ b/packages/components/src/index.css
@@ -0,0 +1,135 @@
+@import "tailwindcss";
+@import "tw-animate-css";
+
+@custom-variant dark (&:is(.dark *));
+
+@theme inline {
+ --radius-sm: calc(var(--radius) - 4px);
+ --radius-md: calc(var(--radius) - 2px);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) + 4px);
+ --radius-2xl: calc(var(--radius) + 8px);
+ --radius-3xl: calc(var(--radius) + 12px);
+ --radius-4xl: calc(var(--radius) + 16px);
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
+ --color-popover: var(--popover);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-secondary: var(--secondary);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-accent: var(--accent);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-destructive: var(--destructive);
+ --color-success: var(--success);
+ --color-success-foreground: var(--success-foreground);
+ --color-warning: var(--warning);
+ --color-warning-foreground: var(--warning-foreground);
+ --color-border: var(--border);
+ --color-input: var(--input);
+ --color-ring: var(--ring);
+ --color-chart-1: var(--chart-1);
+ --color-chart-2: var(--chart-2);
+ --color-chart-3: var(--chart-3);
+ --color-chart-4: var(--chart-4);
+ --color-chart-5: var(--chart-5);
+ --color-sidebar: var(--sidebar);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-ring: var(--sidebar-ring);
+}
+
+:root {
+ --radius: 0.625rem;
+ --background: oklch(1 0 0);
+ --foreground: oklch(0.141 0.005 285.823);
+ --card: oklch(1 0 0);
+ --card-foreground: oklch(0.141 0.005 285.823);
+ --popover: oklch(1 0 0);
+ --popover-foreground: oklch(0.141 0.005 285.823);
+ --primary: oklch(0.21 0.006 285.885);
+ --primary-foreground: oklch(0.985 0 0);
+ --secondary: oklch(0.967 0.001 286.375);
+ --secondary-foreground: oklch(0.21 0.006 285.885);
+ --muted: oklch(0.967 0.001 286.375);
+ --muted-foreground: oklch(0.552 0.016 285.938);
+ --accent: oklch(0.967 0.001 286.375);
+ --accent-foreground: oklch(0.21 0.006 285.885);
+ --destructive: oklch(0.577 0.245 27.325);
+ --success: oklch(0.59 0.2 145.023);
+ --success-foreground: oklch(0.985 0 0);
+ --warning: oklch(0.768 0.189 70.08);
+ --warning-foreground: oklch(0.21 0.006 285.885);
+ --border: oklch(0.92 0.004 286.32);
+ --input: oklch(0.92 0.004 286.32);
+ --ring: oklch(0.705 0.015 286.067);
+ --chart-1: oklch(0.646 0.222 41.116);
+ --chart-2: oklch(0.6 0.118 184.704);
+ --chart-3: oklch(0.398 0.07 227.392);
+ --chart-4: oklch(0.828 0.189 84.429);
+ --chart-5: oklch(0.769 0.188 70.08);
+ --sidebar: oklch(0.985 0 0);
+ --sidebar-foreground: oklch(0.141 0.005 285.823);
+ --sidebar-primary: oklch(0.21 0.006 285.885);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.967 0.001 286.375);
+ --sidebar-accent-foreground: oklch(0.21 0.006 285.885);
+ --sidebar-border: oklch(0.92 0.004 286.32);
+ --sidebar-ring: oklch(0.705 0.015 286.067);
+}
+
+.dark {
+ --background: oklch(0.141 0.005 285.823);
+ --foreground: oklch(0.985 0 0);
+ --card: oklch(0.21 0.006 285.885);
+ --card-foreground: oklch(0.985 0 0);
+ --popover: oklch(0.21 0.006 285.885);
+ --popover-foreground: oklch(0.985 0 0);
+ --primary: oklch(0.92 0.004 286.32);
+ --primary-foreground: oklch(0.21 0.006 285.885);
+ --secondary: oklch(0.274 0.006 286.033);
+ --secondary-foreground: oklch(0.985 0 0);
+ --muted: oklch(0.274 0.006 286.033);
+ --muted-foreground: oklch(0.705 0.015 286.067);
+ --accent: oklch(0.274 0.006 286.033);
+ --accent-foreground: oklch(0.985 0 0);
+ --destructive: oklch(0.704 0.191 22.216);
+ --success: oklch(0.696 0.17 162.48);
+ --success-foreground: oklch(0.985 0 0);
+ --warning: oklch(0.828 0.189 84.429);
+ --warning-foreground: oklch(0.985 0 0);
+ --border: oklch(1 0 0 / 10%);
+ --input: oklch(1 0 0 / 15%);
+ --ring: oklch(0.552 0.016 285.938);
+ --chart-1: oklch(0.488 0.243 264.376);
+ --chart-2: oklch(0.696 0.17 162.48);
+ --chart-3: oklch(0.769 0.188 70.08);
+ --chart-4: oklch(0.627 0.265 303.9);
+ --chart-5: oklch(0.645 0.246 16.439);
+ --sidebar: oklch(0.21 0.006 285.885);
+ --sidebar-foreground: oklch(0.985 0 0);
+ --sidebar-primary: oklch(0.488 0.243 264.376);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.274 0.006 286.033);
+ --sidebar-accent-foreground: oklch(0.985 0 0);
+ --sidebar-border: oklch(1 0 0 / 10%);
+ --sidebar-ring: oklch(0.552 0.016 285.938);
+}
+
+@layer base {
+ * {
+ @apply border-border outline-ring/50;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+}
diff --git a/packages/components/src/kit-components/ui/address-display/AddressDisplay.test.tsx b/packages/components/src/kit-components/ui/address-display/AddressDisplay.test.tsx
new file mode 100644
index 0000000..bb04f4b
--- /dev/null
+++ b/packages/components/src/kit-components/ui/address-display/AddressDisplay.test.tsx
@@ -0,0 +1,157 @@
+// @vitest-environment jsdom
+
+import { address } from '@solana/kit';
+import { cleanup, fireEvent, render, screen } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import '@testing-library/jest-dom/vitest';
+
+afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+});
+
+import { AddressDisplay, getExplorerUrl, truncateAddress } from './AddressDisplay';
+
+/**
+ * Tests for truncateAddress utility function
+ */
+describe('truncateAddress', () => {
+ it('truncates a normal Solana address', () => {
+ const address = 'Hb6dzd4pYxmFYKkJDWuhzBEUkkaE93sFcvXYtriTkmw9';
+ expect(truncateAddress(address)).toBe('Hb6d...kmw9');
+ });
+
+ it('returns original string if 8 characters or less', () => {
+ expect(truncateAddress('12345678')).toBe('12345678');
+ expect(truncateAddress('abc')).toBe('abc');
+ });
+
+ it('handles empty string', () => {
+ expect(truncateAddress('')).toBe('');
+ });
+
+ it('truncates string with exactly 9 characters', () => {
+ expect(truncateAddress('123456789')).toBe('1234...6789');
+ });
+});
+
+/**
+ * Tests for getExplorerUrl utility function
+ */
+describe('getExplorerUrl', () => {
+ const testAddress = 'Hb6dzd4pYxmFYKkJDWuhzBEUkkaE93sFcvXYtriTkmw9';
+
+ it('builds mainnet URL without cluster param', () => {
+ const url = getExplorerUrl(testAddress, 'mainnet-beta');
+ expect(url).toBe(`https://explorer.solana.com/address/${testAddress}`);
+ expect(url).not.toContain('cluster');
+ });
+
+ it('builds devnet URL with cluster param', () => {
+ const url = getExplorerUrl(testAddress, 'devnet');
+ expect(url).toBe(`https://explorer.solana.com/address/${testAddress}?cluster=devnet`);
+ });
+
+ it('builds testnet URL with cluster param', () => {
+ const url = getExplorerUrl(testAddress, 'testnet');
+ expect(url).toBe(`https://explorer.solana.com/address/${testAddress}?cluster=testnet`);
+ });
+});
+
+/**
+ * Tests for AddressDisplay component
+ */
+describe('AddressDisplay', () => {
+ const testAddressString = 'Hb6dzd4pYxmFYKkJDWuhzBEUkkaE93sFcvXYtriTkmw9';
+ const testAddress = address(testAddressString);
+
+ it('renders truncated address', () => {
+ render(
);
+ expect(screen.getByText('Hb6d...kmw9')).toBeInTheDocument();
+ });
+
+ it('renders full address in tooltip by default', () => {
+ render(
);
+ expect(screen.getByText(testAddressString)).toBeInTheDocument();
+ });
+
+ it('hides tooltip when showTooltip is false', () => {
+ render(
);
+ // The full address tooltip text should not be in the DOM
+ expect(screen.queryByText(testAddressString)).not.toBeInTheDocument();
+ });
+
+ it('renders copy button with accessible label', () => {
+ render(
);
+ expect(screen.getByRole('button', { name: /copy address/i })).toBeInTheDocument();
+ });
+
+ it('renders explorer link when showExplorerLink is true (default)', () => {
+ render(
);
+ const link = screen.getByRole('link', { name: /view on solana explorer/i });
+ expect(link).toBeInTheDocument();
+ expect(link).toHaveAttribute('href', `https://explorer.solana.com/address/${testAddressString}`);
+ });
+
+ it('hides explorer link when showExplorerLink is false', () => {
+ render(
);
+ expect(screen.queryByRole('link', { name: /view on solana explorer/i })).not.toBeInTheDocument();
+ });
+
+ it('uses correct explorer URL for devnet', () => {
+ render(
);
+ const link = screen.getByRole('link', { name: /view on solana explorer/i });
+ expect(link).toHaveAttribute('href', `https://explorer.solana.com/address/${testAddressString}?cluster=devnet`);
+ });
+
+ it('applies custom className', () => {
+ const { container } = render(
);
+ expect(container.firstChild).toHaveClass('custom-class');
+ });
+
+ describe('semantic token styles', () => {
+ it('applies bg-card semantic token on the chip', () => {
+ const { container } = render(
);
+ const chip = container.querySelector('span > span');
+ expect(chip).toHaveClass('bg-card');
+ });
+ });
+
+ describe('copy functionality', () => {
+ it('calls onCopy callback when copy button is clicked', async () => {
+ // Mock clipboard API
+ const mockWriteText = vi.fn().mockResolvedValue(undefined);
+ vi.stubGlobal('navigator', {
+ ...navigator,
+ clipboard: { writeText: mockWriteText },
+ });
+
+ const onCopy = vi.fn();
+ render(
);
+
+ const copyButton = screen.getByRole('button', { name: /copy address/i });
+ fireEvent.click(copyButton);
+
+ await vi.waitFor(() => {
+ expect(onCopy).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ it('copies address to clipboard when copy button is clicked', async () => {
+ const mockWriteText = vi.fn().mockResolvedValue(undefined);
+ vi.stubGlobal('navigator', {
+ ...navigator,
+ clipboard: { writeText: mockWriteText },
+ });
+
+ render(
);
+
+ const copyButton = screen.getByRole('button', { name: /copy address/i });
+ fireEvent.click(copyButton);
+
+ await vi.waitFor(() => {
+ expect(mockWriteText).toHaveBeenCalledWith(testAddressString);
+ });
+ });
+ });
+});
diff --git a/packages/components/src/kit-components/ui/address-display/AddressDisplay.tsx b/packages/components/src/kit-components/ui/address-display/AddressDisplay.tsx
new file mode 100644
index 0000000..6ef3382
--- /dev/null
+++ b/packages/components/src/kit-components/ui/address-display/AddressDisplay.tsx
@@ -0,0 +1,106 @@
+import type { ClusterMoniker } from '@solana/client';
+import type { Address } from '@solana/kit';
+import { Check, Copy, ExternalLink } from 'lucide-react';
+import type React from 'react';
+import { useState } from 'react';
+import { cn } from '@/lib/utils';
+
+export interface AddressDisplayProps extends Omit
, 'onCopy'> {
+ /** Solana public key in base58 format */
+ address: Address;
+ /** Callback fired after address is copied to clipboard */
+ onCopy?: () => void;
+ /** Show link to Solana Explorer (default: true) */
+ showExplorerLink?: boolean;
+ /** Show full address tooltip on hover (default: true) */
+ showTooltip?: boolean;
+ /** Solana network for Explorer URL (default: "mainnet-beta") */
+ network?: ClusterMoniker;
+ /** Additional CSS classes to apply to the container */
+ className?: string;
+}
+
+/** Truncates a Solana address to format: "6DMh...1DkK" */
+export function truncateAddress(address: string): string {
+ if (address.length <= 8) return address;
+ return `${address.slice(0, 4)}...${address.slice(-4)}`;
+}
+
+/** Builds Solana Explorer URL for the given address and network */
+export function getExplorerUrl(address: string, network: string): string {
+ const base = 'https://explorer.solana.com';
+ const isMainnet = network === 'mainnet-beta' || network === 'mainnet';
+ const cluster = isMainnet ? '' : `?cluster=${network}`;
+ return `${base}/address/${address}${cluster}`;
+}
+
+export const AddressDisplay: React.FC = ({
+ address,
+ onCopy,
+ showExplorerLink = true,
+ showTooltip = true,
+ network = 'mainnet-beta',
+ className,
+ ...props
+}) => {
+ const [copied, setCopied] = useState(false);
+ const truncated = truncateAddress(address);
+
+ const handleCopy = async () => {
+ try {
+ await navigator.clipboard.writeText(address);
+ setCopied(true);
+ onCopy?.();
+ setTimeout(() => setCopied(false), 2000);
+ } catch (err) {
+ console.error('Failed to copy address:', err);
+ }
+ };
+
+ return (
+
+ {/* Chip */}
+
+ {/* Address text with tooltip - hover only on address */}
+
+ {truncated}
+ {/* Tooltip */}
+ {showTooltip && (
+
+ {address}
+
+ )}
+
+
+ {showExplorerLink && (
+
+
+
+ )}
+
+
+ );
+};
diff --git a/packages/components/src/kit-components/ui/address-display/index.ts b/packages/components/src/kit-components/ui/address-display/index.ts
new file mode 100644
index 0000000..a8d6429
--- /dev/null
+++ b/packages/components/src/kit-components/ui/address-display/index.ts
@@ -0,0 +1,2 @@
+export type { AddressDisplayProps } from './AddressDisplay';
+export { AddressDisplay } from './AddressDisplay';
diff --git a/packages/components/src/kit-components/ui/balance-card/BalanceAmount.tsx b/packages/components/src/kit-components/ui/balance-card/BalanceAmount.tsx
new file mode 100644
index 0000000..6506ba4
--- /dev/null
+++ b/packages/components/src/kit-components/ui/balance-card/BalanceAmount.tsx
@@ -0,0 +1,40 @@
+import type React from 'react';
+import { cn } from '@/lib/utils';
+import type { BalanceAmountProps } from './types';
+import { formatBalance, formatFiatValue } from './utils';
+
+/**
+ * Displays a formatted balance amount with optional fiat formatting
+ */
+export const BalanceAmount: React.FC = ({
+ balance,
+ tokenDecimals = 9,
+ isFiat = false,
+ currency = 'USD',
+ displayDecimals = 2,
+ locale = 'en-US',
+ isPrivate = false,
+ size = 'md',
+ className = '',
+ tokenSymbol,
+}) => {
+ const sizeStyles = {
+ sm: 'text-xl font-semibold',
+ md: 'text-2xl font-bold',
+ lg: 'text-4xl font-bold',
+ }[size];
+
+ const formattedBalance = isPrivate
+ ? '••••••'
+ : isFiat
+ ? formatFiatValue(balance, currency, locale, tokenDecimals)
+ : tokenSymbol
+ ? `${formatBalance(balance, { tokenDecimals, displayDecimals, locale })} ${tokenSymbol}`
+ : formatBalance(balance, { tokenDecimals, displayDecimals, locale });
+
+ return (
+
+ {formattedBalance}
+
+ );
+};
diff --git a/packages/components/src/kit-components/ui/balance-card/BalanceCard.test.tsx b/packages/components/src/kit-components/ui/balance-card/BalanceCard.test.tsx
new file mode 100644
index 0000000..0b37738
--- /dev/null
+++ b/packages/components/src/kit-components/ui/balance-card/BalanceCard.test.tsx
@@ -0,0 +1,299 @@
+// @vitest-environment jsdom
+
+import { address, lamports } from '@solana/kit';
+import { cleanup, fireEvent, render, screen } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import '@testing-library/jest-dom/vitest';
+
+import { BalanceCard } from './BalanceCard';
+
+afterEach(() => {
+ cleanup();
+});
+
+const testAddress = address('6DMh7fYHrKdCJwCFUQfMfNAdLADi9xqsRKNzmZA31DkK');
+const testBalance = lamports(34_810_000_000n); // ~34.81 when formatted
+
+const sampleTokens = [
+ { symbol: 'USDC', balance: 15.5, fiatValue: 15.5 },
+ { symbol: 'USDT', balance: 10.18, fiatValue: 10.18 },
+];
+
+describe('BalanceCard', () => {
+ describe('rendering', () => {
+ it('renders without crashing with required props', () => {
+ render();
+ expect(screen.getByText('Total balance')).toBeInTheDocument();
+ });
+
+ it('renders as a section element with aria-label', () => {
+ render();
+ expect(screen.getByRole('region')).toHaveAttribute('aria-label', expect.stringContaining('Wallet balance'));
+ });
+
+ it('displays wallet address in truncated format', () => {
+ render();
+ expect(screen.getByText('6DMh...1DkK')).toBeInTheDocument();
+ });
+
+ it('displays total balance label', () => {
+ render();
+ expect(screen.getByText('Total balance')).toBeInTheDocument();
+ });
+ });
+
+ describe('balance formatting', () => {
+ it('shows fiat format with currency symbol when isFiatBalance is true', () => {
+ render();
+ // Balance should include $ symbol
+ expect(screen.getByText(/\$/)).toBeInTheDocument();
+ });
+
+ it('shows crypto format without currency symbol when isFiatBalance is false', () => {
+ render();
+ // Should not have currency symbol
+ expect(screen.queryByText(/\$/)).not.toBeInTheDocument();
+ });
+
+ it('shows token symbol after balance when tokenSymbol is provided', () => {
+ render();
+ expect(screen.getByText(/SOL/)).toBeInTheDocument();
+ expect(screen.queryByText(/\$/)).not.toBeInTheDocument();
+ });
+
+ it('does not show token symbol when isFiatBalance is true even if tokenSymbol is set', () => {
+ render();
+ expect(screen.getByText(/\$/)).toBeInTheDocument();
+ expect(screen.queryByText(/SOL/)).not.toBeInTheDocument();
+ });
+ });
+
+ describe('loading state', () => {
+ it('renders skeleton when isLoading is true', () => {
+ const { container } = render();
+ // Skeleton should be present, not the balance label
+ expect(screen.queryByText('Total balance')).not.toBeInTheDocument();
+ // Check for skeleton elements (animated divs)
+ expect(container.querySelector('.animate-pulse')).toBeInTheDocument();
+ });
+ });
+
+ describe('error state', () => {
+ it('displays error message when error prop is a string', () => {
+ render();
+ expect(screen.getByText('Failed to load balance')).toBeInTheDocument();
+ });
+
+ it('displays error message when error prop is an Error object', () => {
+ render();
+ expect(screen.getByText('Network error')).toBeInTheDocument();
+ });
+
+ it('calls onRetry when retry button is clicked', () => {
+ const onRetry = vi.fn();
+ render();
+
+ const retryButton = screen.getByRole('button', { name: /try again/i });
+ fireEvent.click(retryButton);
+
+ expect(onRetry).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('token list', () => {
+ it('renders token list section', () => {
+ render();
+ expect(screen.getByText('View all tokens')).toBeInTheDocument();
+ });
+
+ it('token list is collapsed by default', () => {
+ render();
+ // Token symbols should not be visible when collapsed
+ const button = screen.getByRole('button', { name: /view all tokens/i });
+ expect(button).toHaveAttribute('aria-expanded', 'false');
+ });
+
+ it('token list is expanded when defaultExpanded is true', () => {
+ render();
+ const button = screen.getByRole('button', { name: /view all tokens/i });
+ expect(button).toHaveAttribute('aria-expanded', 'true');
+ });
+
+ it('expands token list when toggle is clicked', () => {
+ render();
+
+ const button = screen.getByRole('button', { name: /view all tokens/i });
+ expect(button).toHaveAttribute('aria-expanded', 'false');
+
+ fireEvent.click(button);
+
+ expect(button).toHaveAttribute('aria-expanded', 'true');
+ });
+
+ it('shows "No tokens yet" when tokens array is empty and expanded', () => {
+ render();
+ expect(screen.getByText('No tokens yet')).toBeInTheDocument();
+ });
+
+ it('renders all tokens when expanded', () => {
+ render();
+ expect(screen.getByText('USDC')).toBeInTheDocument();
+ expect(screen.getByText('USDT')).toBeInTheDocument();
+ });
+
+ it('calls onExpandedChange when token list is toggled', () => {
+ const onExpandedChange = vi.fn();
+ render(
+ ,
+ );
+
+ const button = screen.getByRole('button', { name: /view all tokens/i });
+ fireEvent.click(button);
+
+ expect(onExpandedChange).toHaveBeenCalledWith(true);
+ });
+ });
+
+ describe('semantic tokens', () => {
+ it('uses semantic background token', () => {
+ const { container } = render();
+ expect(container.firstChild).toHaveClass('bg-card');
+ });
+
+ it('uses semantic foreground token', () => {
+ const { container } = render();
+ expect(container.firstChild).toHaveClass('text-card-foreground');
+ });
+
+ it('uses semantic border token', () => {
+ const { container } = render();
+ expect(container.firstChild).toHaveClass('border-border');
+ });
+ });
+
+ describe('size variants', () => {
+ it('applies small padding for sm size', () => {
+ const { container } = render();
+ expect(container.firstChild).toHaveClass('p-3');
+ });
+
+ it('applies medium padding for md size (default)', () => {
+ const { container } = render();
+ expect(container.firstChild).toHaveClass('p-4');
+ });
+
+ it('applies large padding for lg size', () => {
+ const { container } = render();
+ expect(container.firstChild).toHaveClass('p-6');
+ });
+ });
+
+ describe('custom className', () => {
+ it('applies additional className', () => {
+ const { container } = render();
+ expect(container.firstChild).toHaveClass('custom-class');
+ });
+ });
+
+ describe('wallet address interactions', () => {
+ it('calls onCopyAddress when copy button is clicked', async () => {
+ // Mock clipboard API
+ const mockWriteText = vi.fn().mockResolvedValue(undefined);
+ vi.stubGlobal('navigator', {
+ ...navigator,
+ clipboard: { writeText: mockWriteText },
+ });
+
+ const onCopyAddress = vi.fn();
+ render(
+ ,
+ );
+
+ const copyButton = screen.getByRole('button', { name: /copy/i });
+ fireEvent.click(copyButton);
+
+ // Wait for async clipboard operation
+ await vi.waitFor(() => {
+ expect(onCopyAddress).toHaveBeenCalledWith(testAddress);
+ });
+ });
+ });
+
+ describe('controlled expansion', () => {
+ it('respects controlled isExpanded prop', () => {
+ render();
+ const button = screen.getByRole('button', { name: /view all tokens/i });
+ expect(button).toHaveAttribute('aria-expanded', 'true');
+ });
+
+ it('does not change expansion when controlled', () => {
+ const onExpandedChange = vi.fn();
+ render(
+ ,
+ );
+
+ const button = screen.getByRole('button', { name: /view all tokens/i });
+ fireEvent.click(button);
+
+ // Callback should be called, but controlled prop should still control the state
+ expect(onExpandedChange).toHaveBeenCalledWith(true);
+ });
+ });
+
+ describe('edge cases', () => {
+ it('handles zero balance correctly', () => {
+ render();
+ expect(screen.getByText('$0.00')).toBeInTheDocument();
+ });
+
+ it('renders without walletAddress prop', () => {
+ render();
+ // Should render without crashing, no address section
+ expect(screen.getByText('Total balance')).toBeInTheDocument();
+ });
+
+ it('handles missing onRetry gracefully in error state', () => {
+ render();
+ // Should render error but retry button may or may not be present
+ expect(screen.getByText('Error occurred')).toBeInTheDocument();
+ });
+
+ it('handles missing onExpandedChange gracefully', () => {
+ render();
+ const button = screen.getByRole('button', { name: /view all tokens/i });
+ // Should not crash when clicking without handler
+ expect(() => fireEvent.click(button)).not.toThrow();
+ });
+ });
+
+ describe('accessibility', () => {
+ it('has accessible section with aria-label including wallet address', () => {
+ render();
+ const section = screen.getByRole('region');
+ expect(section).toHaveAttribute('aria-label', expect.stringContaining(testAddress));
+ });
+
+ it('has accessible section without wallet address', () => {
+ render();
+ const section = screen.getByRole('region');
+ expect(section).toHaveAttribute('aria-label', 'Wallet balance');
+ });
+
+ it('error state has alert role for screen readers', () => {
+ render();
+ expect(screen.getByRole('alert')).toBeInTheDocument();
+ });
+
+ it('token list toggle has aria-expanded and aria-controls', () => {
+ render();
+ const button = screen.getByRole('button', { name: /view all tokens/i });
+ expect(button).toHaveAttribute('aria-expanded');
+ expect(button).toHaveAttribute('aria-controls');
+ });
+ });
+});
diff --git a/packages/components/src/kit-components/ui/balance-card/BalanceCard.tsx b/packages/components/src/kit-components/ui/balance-card/BalanceCard.tsx
new file mode 100644
index 0000000..5e0755f
--- /dev/null
+++ b/packages/components/src/kit-components/ui/balance-card/BalanceCard.tsx
@@ -0,0 +1,119 @@
+import type React from 'react';
+import { cn } from '@/lib/utils';
+import { AddressDisplay } from '../address-display/AddressDisplay';
+import walletIcon from './assets/wallet-icon-dark.png';
+import { BalanceAmount } from './BalanceAmount';
+import { BalanceCardSkeleton } from './BalanceCardSkeleton';
+import { ErrorState } from './ErrorState';
+import { TokenList } from './TokenList';
+import type { BalanceCardProps } from './types';
+
+const EMPTY_TOKENS: BalanceCardProps['tokens'] = [];
+
+/**
+ * A comprehensive balance card component for displaying wallet balances
+ * with support for token lists, loading states, and error handling.
+ *
+ * @example
+ * ```tsx
+ * // Basic usage
+ *
+ *
+ * // With token list
+ *
+ * ```
+ */
+export const BalanceCard: React.FC = ({
+ walletAddress,
+ totalBalance,
+ tokenDecimals = 9,
+ isFiatBalance = false,
+ tokenSymbol,
+ currency = 'USD',
+ displayDecimals = 2,
+ tokens = EMPTY_TOKENS,
+ isLoading = false,
+ error,
+ onRetry,
+ onCopyAddress,
+ defaultExpanded = false,
+ isExpanded: controlledExpanded,
+ onExpandedChange,
+ size = 'md',
+ className = '',
+ locale = 'en-US',
+}) => {
+ // Show skeleton during loading
+ if (isLoading) {
+ return ;
+ }
+
+ const paddingStyles = {
+ sm: 'p-3',
+ md: 'p-4',
+ lg: 'p-6',
+ }[size];
+
+ const errorMessage = error ? (typeof error === 'string' ? error : error.message || 'An error occurred') : null;
+
+ return (
+
+ {/* Wallet address */}
+ {walletAddress && (
+
+

+
onCopyAddress(walletAddress) : undefined}
+ showExplorerLink={false}
+ className="[&>span]:bg-transparent! [&>span]:p-0! [&>span]:rounded-none!"
+ />
+
+ )}
+
+ {/* Balance label */}
+ Total balance
+
+ {/* Balance amount */}
+
+
+ {/* Error state */}
+ {errorMessage && }
+
+ {/* Token list */}
+
+
+ );
+};
diff --git a/packages/components/src/kit-components/ui/balance-card/BalanceCardSkeleton.tsx b/packages/components/src/kit-components/ui/balance-card/BalanceCardSkeleton.tsx
new file mode 100644
index 0000000..7ef8561
--- /dev/null
+++ b/packages/components/src/kit-components/ui/balance-card/BalanceCardSkeleton.tsx
@@ -0,0 +1,40 @@
+import type React from 'react';
+import { cn } from '@/lib/utils';
+import type { BalanceCardSkeletonProps } from './types';
+
+/**
+ * Skeleton loading state for the BalanceCard component
+ */
+export const BalanceCardSkeleton: React.FC = ({ size = 'md', className = '' }) => {
+ const paddingStyles = {
+ sm: 'p-3',
+ md: 'p-4',
+ lg: 'p-6',
+ }[size];
+
+ return (
+
+ );
+};
diff --git a/packages/components/src/kit-components/ui/balance-card/ErrorState.tsx b/packages/components/src/kit-components/ui/balance-card/ErrorState.tsx
new file mode 100644
index 0000000..01b76ed
--- /dev/null
+++ b/packages/components/src/kit-components/ui/balance-card/ErrorState.tsx
@@ -0,0 +1,25 @@
+import { TriangleAlert } from 'lucide-react';
+import type React from 'react';
+import { cn } from '@/lib/utils';
+import type { ErrorStateProps } from './types';
+
+/**
+ * Error state component for displaying error messages with optional retry
+ */
+export const ErrorState: React.FC = ({ message, onRetry, className = '' }) => {
+ return (
+
+
+ {message}
+ {onRetry && (
+
+ )}
+
+ );
+};
diff --git a/packages/components/src/kit-components/ui/balance-card/TokenList.tsx b/packages/components/src/kit-components/ui/balance-card/TokenList.tsx
new file mode 100644
index 0000000..66271bc
--- /dev/null
+++ b/packages/components/src/kit-components/ui/balance-card/TokenList.tsx
@@ -0,0 +1,122 @@
+import { ChevronUp } from 'lucide-react';
+import type React from 'react';
+import { useId, useState } from 'react';
+import { cn } from '@/lib/utils';
+import type { TokenInfo, TokenListProps } from './types';
+
+/**
+ * Formats a number as currency
+ */
+function formatCurrency(value: number | string, currency: string, locale: string): string {
+ const num = typeof value === 'string' ? Number.parseFloat(value) : value;
+ if (Number.isNaN(num)) return String(value);
+
+ return new Intl.NumberFormat(locale, {
+ style: 'currency',
+ currency,
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ }).format(num);
+}
+
+/**
+ * Token row component for displaying individual token info
+ */
+const TokenRow: React.FC<{
+ token: TokenInfo;
+ locale?: string;
+ currency?: string;
+}> = ({ token, locale = 'en-US', currency = 'USD' }) => {
+ const displayBalance = token.fiatValue
+ ? formatCurrency(token.fiatValue, currency, locale)
+ : typeof token.balance === 'number'
+ ? token.balance.toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
+ : token.balance;
+
+ return (
+
+ {token.symbol}
+ {displayBalance}
+
+ );
+};
+
+/**
+ * Expandable token list component showing all tokens in a wallet
+ */
+export const TokenList: React.FC = ({
+ tokens,
+ isExpanded: controlledExpanded,
+ defaultExpanded = false,
+ onExpandedChange,
+ className = '',
+ locale = 'en-US',
+ currency = 'USD',
+}) => {
+ const [internalExpanded, setInternalExpanded] = useState(defaultExpanded);
+ const contentId = useId();
+
+ const isControlled = controlledExpanded !== undefined;
+ const isExpanded = isControlled ? controlledExpanded : internalExpanded;
+
+ const handleToggle = () => {
+ const newExpanded = !isExpanded;
+ if (!isControlled) {
+ setInternalExpanded(newExpanded);
+ }
+ onExpandedChange?.(newExpanded);
+ };
+
+ return (
+
+ {/* Toggle header */}
+
+
+ {/* Expandable content */}
+
+ {/* Table header */}
+
+ Token
+ Balance
+
+
+ {/* Token rows */}
+ {tokens.length === 0 ? (
+
No tokens yet
+ ) : (
+
+ {tokens.map((token) => (
+
+ ))}
+
+ )}
+
+
+ );
+};
diff --git a/packages/components/src/kit-components/ui/balance-card/assets/wallet-icon-dark.png b/packages/components/src/kit-components/ui/balance-card/assets/wallet-icon-dark.png
new file mode 100644
index 0000000..0a58d32
Binary files /dev/null and b/packages/components/src/kit-components/ui/balance-card/assets/wallet-icon-dark.png differ
diff --git a/packages/components/src/kit-components/ui/balance-card/assets/wallet-icon-light.png b/packages/components/src/kit-components/ui/balance-card/assets/wallet-icon-light.png
new file mode 100644
index 0000000..3693e83
Binary files /dev/null and b/packages/components/src/kit-components/ui/balance-card/assets/wallet-icon-light.png differ
diff --git a/packages/components/src/kit-components/ui/balance-card/index.ts b/packages/components/src/kit-components/ui/balance-card/index.ts
new file mode 100644
index 0000000..a5898e6
--- /dev/null
+++ b/packages/components/src/kit-components/ui/balance-card/index.ts
@@ -0,0 +1,29 @@
+// Main component
+
+// Sub-components
+export { BalanceAmount } from './BalanceAmount';
+export { BalanceCard } from './BalanceCard';
+export { BalanceCardSkeleton } from './BalanceCardSkeleton';
+export { ErrorState } from './ErrorState';
+export { TokenList } from './TokenList';
+
+// Types
+export type {
+ BalanceAmountProps,
+ BalanceCardProps,
+ BalanceCardSkeletonProps,
+ ErrorStateProps,
+ TokenInfo,
+ TokenListProps,
+} from './types';
+export type { FormatBalanceOptions } from './utils';
+
+// Utilities
+export {
+ copyToClipboard,
+ formatBalance,
+ formatFiatValue,
+ formatPercentageChange,
+ stringToColor,
+ truncateAddress,
+} from './utils';
diff --git a/packages/components/src/kit-components/ui/balance-card/types.ts b/packages/components/src/kit-components/ui/balance-card/types.ts
new file mode 100644
index 0000000..1299819
--- /dev/null
+++ b/packages/components/src/kit-components/ui/balance-card/types.ts
@@ -0,0 +1,130 @@
+import type { Address, Lamports } from '@solana/kit';
+import type React from 'react';
+
+/**
+ * Token information for display in the token list
+ */
+export interface TokenInfo {
+ /** Token symbol (e.g., "USDC", "SOL") */
+ symbol: string;
+ /** Token name (e.g., "USD Coin", "Solana") */
+ name?: string;
+ /** Token balance */
+ balance: number | string;
+ /** Token icon URL or React node */
+ icon?: string | React.ReactNode;
+ /** Fiat value of the token balance */
+ fiatValue?: number | string;
+ /** Token mint address */
+ mintAddress?: Address;
+}
+
+/**
+ * Props for the main BalanceCard component
+ */
+export interface BalanceCardProps {
+ /** Wallet address to display */
+ walletAddress?: Address;
+ /** Total balance in Lamports */
+ totalBalance: Lamports;
+ /** Number of decimals for the token (default: 9 for SOL) */
+ tokenDecimals?: number;
+ /** Whether the balance is displayed as fiat (with currency symbol) */
+ isFiatBalance?: boolean;
+ /** Token symbol to display after balance (e.g. "SOL"). Only used when isFiatBalance is false. */
+ tokenSymbol?: string;
+ /** Currency code for fiat display (default: "USD") */
+ currency?: string;
+ /** Number of decimal places to display (default: 2) */
+ displayDecimals?: number;
+ /** List of tokens to display in expandable section */
+ tokens?: TokenInfo[];
+ /** Whether the component is in loading state */
+ isLoading?: boolean;
+ /** Error message or Error object */
+ error?: string | Error;
+ /** Callback when retry is clicked in error state */
+ onRetry?: () => void | Promise;
+ /** Callback when address copy button is clicked */
+ onCopyAddress?: (address: Address) => void;
+ /** Whether the token list is initially expanded (default: false) */
+ defaultExpanded?: boolean;
+ /** Controlled expanded state */
+ isExpanded?: boolean;
+ /** Callback when expanded state changes */
+ onExpandedChange?: (expanded: boolean) => void;
+ /** Size variant */
+ size?: 'sm' | 'md' | 'lg';
+ /** Additional CSS classes */
+ className?: string;
+ /** Locale for number formatting (default: "en-US") */
+ locale?: string;
+}
+
+/**
+ * Props for the BalanceCardSkeleton component
+ */
+export interface BalanceCardSkeletonProps {
+ /** Size variant */
+ size?: 'sm' | 'md' | 'lg';
+ /** Additional CSS classes */
+ className?: string;
+}
+
+/**
+ * Props for the BalanceAmount component
+ */
+export interface BalanceAmountProps {
+ /** Balance value in base units (bigint) */
+ balance: bigint;
+ /** Number of decimals for the token (e.g., 9 for SOL, 6 for USDC) */
+ tokenDecimals?: number;
+ /** Whether to display as fiat with currency symbol */
+ isFiat?: boolean;
+ /** Token symbol to display after balance (e.g. "SOL"). Only used when isFiat is false. */
+ tokenSymbol?: string;
+ /** Currency code for fiat display */
+ currency?: string;
+ /** Number of decimal places to display */
+ displayDecimals?: number;
+ /** Locale for formatting */
+ locale?: string;
+ /** Whether to show privacy mask */
+ isPrivate?: boolean;
+ /** Size variant */
+ size?: 'sm' | 'md' | 'lg';
+ /** Additional CSS classes */
+ className?: string;
+}
+
+/**
+ * Props for the TokenList component
+ */
+export interface TokenListProps {
+ /** List of tokens to display */
+ tokens: TokenInfo[];
+ /** Whether the list is expanded (controlled) */
+ isExpanded?: boolean;
+ /** Initial expanded state for uncontrolled mode (default: false) */
+ defaultExpanded?: boolean;
+ /** Callback when expansion state changes */
+ onExpandedChange?: (expanded: boolean) => void;
+ /** Additional CSS classes */
+ className?: string;
+ /** Locale for number formatting */
+ locale?: string;
+ /** Currency code for fiat display (default: "USD") */
+ currency?: string;
+}
+
+/**
+ * Props for the ErrorState component
+ */
+export interface ErrorStateProps {
+ /** Error message */
+ message: string;
+ /** Callback when retry is clicked */
+ onRetry?: () => void | Promise;
+ /** Additional CSS classes */
+ className?: string;
+}
diff --git a/packages/components/src/kit-components/ui/balance-card/utils.ts b/packages/components/src/kit-components/ui/balance-card/utils.ts
new file mode 100644
index 0000000..6092304
--- /dev/null
+++ b/packages/components/src/kit-components/ui/balance-card/utils.ts
@@ -0,0 +1,194 @@
+/**
+ * Utility functions for balance formatting and display
+ */
+
+export interface FormatBalanceOptions {
+ /** Number of decimals in the token (e.g., 9 for SOL, 6 for USDC) */
+ tokenDecimals?: number;
+ /** Number of decimal places to display */
+ displayDecimals?: number;
+ locale?: string;
+ abbreviate?: boolean;
+ showLessThan?: boolean;
+}
+
+/**
+ * Converts a bigint balance to a decimal number
+ * @param balance - The balance in base units (bigint)
+ * @param tokenDecimals - Number of decimals for the token
+ * @returns The balance as a number
+ */
+function bigintToNumber(balance: bigint, tokenDecimals: number): number {
+ const divisor = 10 ** tokenDecimals;
+ // Convert to number, handling precision for large values
+ return Number(balance) / divisor;
+}
+
+/**
+ * Formats a bigint balance with proper locale formatting
+ * @param balance - The balance in base units (bigint)
+ * @param options - Formatting options
+ * @returns Formatted balance string
+ */
+export function formatBalance(balance: bigint | null | undefined, options: FormatBalanceOptions = {}): string {
+ const {
+ tokenDecimals = 9,
+ displayDecimals = 2,
+ locale = 'en-US',
+ abbreviate = false,
+ showLessThan = true,
+ } = options;
+
+ if (balance === null || balance === undefined) {
+ return '—';
+ }
+
+ const num = bigintToNumber(balance, tokenDecimals);
+
+ if (Number.isNaN(num)) {
+ return '—';
+ }
+
+ if (!Number.isFinite(num)) {
+ return '—';
+ }
+
+ // Handle very small numbers
+ if (num > 0 && num < 0.01 && showLessThan) {
+ return '< 0.01';
+ }
+
+ // Handle abbreviation for large numbers
+ if (abbreviate && Math.abs(num) >= 1_000_000_000) {
+ return `${(num / 1_000_000_000).toFixed(2)}B`;
+ }
+ if (abbreviate && Math.abs(num) >= 1_000_000) {
+ return `${(num / 1_000_000).toFixed(2)}M`;
+ }
+ if (abbreviate && Math.abs(num) >= 1_000) {
+ return `${(num / 1_000).toFixed(2)}K`;
+ }
+
+ return new Intl.NumberFormat(locale, {
+ minimumFractionDigits: displayDecimals,
+ maximumFractionDigits: displayDecimals,
+ }).format(num);
+}
+
+/**
+ * Formats a bigint value as currency
+ * @param value - The value in base units (bigint)
+ * @param currency - Currency code (default: USD)
+ * @param locale - Locale for formatting (default: en-US)
+ * @param tokenDecimals - Number of decimals for the token (default: 9 for SOL)
+ * @returns Formatted currency string
+ */
+export function formatFiatValue(
+ value: bigint | null | undefined,
+ currency = 'USD',
+ locale = 'en-US',
+ tokenDecimals = 9,
+): string {
+ if (value === null || value === undefined) {
+ return '';
+ }
+
+ const num = bigintToNumber(value, tokenDecimals);
+
+ if (Number.isNaN(num) || !Number.isFinite(num)) {
+ return '';
+ }
+
+ try {
+ return new Intl.NumberFormat(locale, {
+ style: 'currency',
+ currency,
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ }).format(num);
+ } catch {
+ // Fallback to USD if invalid currency code is provided
+ return new Intl.NumberFormat(locale, {
+ style: 'currency',
+ currency: 'USD',
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ }).format(num);
+ }
+}
+
+/**
+ * Truncates a wallet address for display
+ * @param address - Full wallet address
+ * @param startChars - Number of characters to show at start (default: 4)
+ * @param endChars - Number of characters to show at end (default: 4)
+ * @returns Truncated address string
+ */
+export function truncateAddress(address: string | null | undefined, startChars = 4, endChars = 4): string {
+ if (!address) {
+ return '';
+ }
+
+ if (address.length <= startChars + endChars + 3) {
+ return address;
+ }
+
+ return `${address.slice(0, startChars)}...${address.slice(-endChars)}`;
+}
+
+/**
+ * Generates a deterministic color based on a string (for fallback token icons)
+ * @param str - Input string (typically token symbol)
+ * @returns HSL color string
+ */
+export function stringToColor(str: string): string {
+ let hash = 0;
+ for (let i = 0; i < str.length; i++) {
+ hash = str.charCodeAt(i) + ((hash << 5) - hash);
+ }
+ const hue = Math.abs(hash % 360);
+ return `hsl(${hue}, 65%, 50%)`;
+}
+
+/**
+ * Formats percentage change with sign
+ * @param change - Percentage change value
+ * @param decimals - Decimal places (default: 2)
+ * @returns Formatted percentage string with sign
+ */
+export function formatPercentageChange(change: number | null | undefined, decimals = 2): string {
+ if (change === null || change === undefined || Number.isNaN(change)) {
+ return '0.00%';
+ }
+
+ const sign = change > 0 ? '+' : '';
+ return `${sign}${change.toFixed(decimals)}%`;
+}
+
+/**
+ * Copies text to clipboard
+ * @param text - Text to copy
+ * @returns Promise that resolves when copy is complete
+ */
+export async function copyToClipboard(text: string): Promise {
+ try {
+ await navigator.clipboard.writeText(text);
+ return true;
+ } catch {
+ // Fallback for older browsers
+ const textArea = document.createElement('textarea');
+ textArea.value = text;
+ textArea.style.position = 'fixed';
+ textArea.style.left = '-999999px';
+ document.body.appendChild(textArea);
+ textArea.select();
+ try {
+ document.execCommand('copy');
+ document.body.removeChild(textArea);
+ return true;
+ } catch {
+ document.body.removeChild(textArea);
+ return false;
+ }
+ }
+}
diff --git a/packages/components/src/kit-components/ui/connect-wallet-button/ButtonContent.tsx b/packages/components/src/kit-components/ui/connect-wallet-button/ButtonContent.tsx
new file mode 100644
index 0000000..279b613
--- /dev/null
+++ b/packages/components/src/kit-components/ui/connect-wallet-button/ButtonContent.tsx
@@ -0,0 +1,17 @@
+'use client';
+
+import { cn } from '@/lib/utils';
+import type { ButtonContentProps } from './types';
+
+/**
+ * ButtonContent - Text content wrapper for wallet button.
+ * Provides consistent typography styling.
+ *
+ * @example
+ * ```tsx
+ * Connect Wallet
+ * ```
+ */
+export function ButtonContent({ children, className }: ButtonContentProps): React.ReactElement {
+ return {children};
+}
diff --git a/packages/components/src/kit-components/ui/connect-wallet-button/ButtonIcon.tsx b/packages/components/src/kit-components/ui/connect-wallet-button/ButtonIcon.tsx
new file mode 100644
index 0000000..fc9db0b
--- /dev/null
+++ b/packages/components/src/kit-components/ui/connect-wallet-button/ButtonIcon.tsx
@@ -0,0 +1,34 @@
+'use client';
+
+import { Wallet } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import type { ButtonIconProps } from './types';
+
+/**
+ * ButtonIcon - Displays the wallet icon in the button.
+ * Shows the connected wallet's logo/icon.
+ * Falls back to Wallet icon from lucide-react if no src provided.
+ *
+ * @example
+ * ```tsx
+ *
+ * ```
+ */
+export function ButtonIcon({ src, alt = 'Wallet icon', size = 24, className }: ButtonIconProps): React.ReactElement {
+ // Fallback to Wallet icon from lucide-react if no src provided
+ if (!src) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/packages/components/src/kit-components/ui/connect-wallet-button/ButtonSpinner.tsx b/packages/components/src/kit-components/ui/connect-wallet-button/ButtonSpinner.tsx
new file mode 100644
index 0000000..54d6749
--- /dev/null
+++ b/packages/components/src/kit-components/ui/connect-wallet-button/ButtonSpinner.tsx
@@ -0,0 +1,19 @@
+'use client';
+
+import { Loader2 } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import type { ButtonSpinnerProps } from './types';
+
+/**
+ * ButtonSpinner - Animated loading spinner for wallet button.
+ * Shows during wallet connection attempts.
+ * Uses Loader2 from lucide-react for consistent iconography.
+ *
+ * @example
+ * ```tsx
+ *
+ * ```
+ */
+export function ButtonSpinner({ size = 20, className }: ButtonSpinnerProps): React.ReactElement {
+ return ;
+}
diff --git a/packages/components/src/kit-components/ui/connect-wallet-button/ChevronIcon.tsx b/packages/components/src/kit-components/ui/connect-wallet-button/ChevronIcon.tsx
new file mode 100644
index 0000000..2b86135
--- /dev/null
+++ b/packages/components/src/kit-components/ui/connect-wallet-button/ChevronIcon.tsx
@@ -0,0 +1,28 @@
+'use client';
+
+import { ChevronDown } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import type { ChevronIconProps } from './types';
+
+/**
+ * ChevronIcon - Chevron indicator for dropdown state.
+ * Points down when collapsed, up when expanded.
+ * Uses ChevronDown from lucide-react for consistent iconography.
+ *
+ * @example
+ * ```tsx
+ *
+ * ```
+ */
+export function ChevronIcon({ direction, className }: ChevronIconProps): React.ReactElement {
+ const rotation = direction === 'up' ? 180 : 0;
+
+ return (
+
+
+
+ );
+}
diff --git a/packages/components/src/kit-components/ui/connect-wallet-button/ConnectWalletButton.test.tsx b/packages/components/src/kit-components/ui/connect-wallet-button/ConnectWalletButton.test.tsx
new file mode 100644
index 0000000..7c314cb
--- /dev/null
+++ b/packages/components/src/kit-components/ui/connect-wallet-button/ConnectWalletButton.test.tsx
@@ -0,0 +1,422 @@
+// @vitest-environment jsdom
+
+import { lamports } from '@solana/client';
+import { address } from '@solana/kit';
+import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import '@testing-library/jest-dom/vitest';
+
+import { ConnectWalletButton } from './ConnectWalletButton';
+
+afterEach(() => {
+ cleanup();
+});
+
+const mockAddress = address('6DMh7gJwvuTq3Bpf8rPVGPjzqnz1DkK3H1mVh9kP1DkK');
+const mockBalance = lamports(1120000000n); // 1.12 SOL
+
+const mockWallet = {
+ address: mockAddress,
+};
+
+const mockConnector = {
+ id: 'phantom',
+ name: 'Phantom',
+ icon: 'data:image/svg+xml,',
+};
+
+describe('ConnectWalletButton', () => {
+ describe('disconnected state', () => {
+ it('renders "Connect Wallet" text when disconnected', () => {
+ render( {}} onDisconnect={() => {}} />);
+ expect(screen.getByText('Connect Wallet')).toBeInTheDocument();
+ });
+
+ it('renders custom connect label when provided', () => {
+ render(
+ {}}
+ onDisconnect={() => {}}
+ />,
+ );
+ expect(screen.getByText('Link Wallet')).toBeInTheDocument();
+ });
+
+ it('calls onConnect when button is clicked', () => {
+ const onConnect = vi.fn();
+ render( {}} />);
+
+ fireEvent.click(screen.getByRole('button'));
+
+ expect(onConnect).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not show dropdown menu when disconnected', () => {
+ render( {}} onDisconnect={() => {}} />);
+
+ fireEvent.click(screen.getByRole('button'));
+
+ expect(screen.queryByText('Disconnect')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('connecting state', () => {
+ it('renders spinner when connecting', () => {
+ const { container } = render(
+ {}} onDisconnect={() => {}} />,
+ );
+ const spinner = container.querySelector('.animate-spin');
+ expect(spinner).toBeInTheDocument();
+ });
+
+ it('does not call onConnect when clicked while connecting', () => {
+ const onConnect = vi.fn();
+ render( {}} />);
+
+ fireEvent.click(screen.getByRole('button'));
+
+ expect(onConnect).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('connected state', () => {
+ it('shows wallet icon when connected', () => {
+ const { container } = render(
+ {}}
+ onDisconnect={() => {}}
+ />,
+ );
+ const img = container.querySelector('img');
+ expect(img).toBeInTheDocument();
+ });
+
+ it('opens dropdown when connected button is clicked', async () => {
+ render(
+ {}}
+ onDisconnect={() => {}}
+ />,
+ );
+
+ fireEvent.click(screen.getByRole('button'));
+
+ await waitFor(() => {
+ expect(screen.getByText('Disconnect')).toBeInTheDocument();
+ });
+ });
+
+ it('displays wallet address in dropdown', async () => {
+ render(
+ {}}
+ onDisconnect={() => {}}
+ />,
+ );
+
+ fireEvent.click(screen.getByRole('button'));
+
+ await waitFor(() => {
+ expect(screen.getByText(/6DMh.*DkK/)).toBeInTheDocument();
+ });
+ });
+
+ it('calls onDisconnect when disconnect is clicked', async () => {
+ const onDisconnect = vi.fn();
+ render(
+ {}}
+ onDisconnect={onDisconnect}
+ />,
+ );
+
+ fireEvent.click(screen.getByRole('button'));
+
+ await waitFor(() => {
+ expect(screen.getByText('Disconnect')).toBeInTheDocument();
+ });
+
+ fireEvent.click(screen.getByText('Disconnect'));
+ expect(onDisconnect).toHaveBeenCalledTimes(1);
+ });
+
+ it('has aria-haspopup when connected', () => {
+ render(
+ {}}
+ onDisconnect={() => {}}
+ />,
+ );
+
+ expect(screen.getByRole('button')).toHaveAttribute('aria-haspopup', 'menu');
+ });
+
+ it('has aria-expanded that reflects dropdown state', async () => {
+ render(
+ {}}
+ onDisconnect={() => {}}
+ />,
+ );
+
+ const button = screen.getByRole('button');
+ expect(button).toHaveAttribute('aria-expanded', 'false');
+
+ fireEvent.click(button);
+
+ await waitFor(() => {
+ expect(button).toHaveAttribute('aria-expanded', 'true');
+ });
+ });
+ });
+
+ describe('keyboard interactions', () => {
+ it('closes dropdown on Escape key', async () => {
+ render(
+ {}}
+ onDisconnect={() => {}}
+ />,
+ );
+
+ fireEvent.click(screen.getByRole('button'));
+
+ await waitFor(() => {
+ expect(screen.getByText('Disconnect')).toBeInTheDocument();
+ });
+
+ fireEvent.keyDown(document, { key: 'Escape' });
+
+ await waitFor(() => {
+ expect(screen.queryByText('Disconnect')).not.toBeInTheDocument();
+ });
+ });
+ });
+
+ describe('click outside', () => {
+ it('closes dropdown when clicking outside', async () => {
+ render(
+
+
Outside
+
{}}
+ onDisconnect={() => {}}
+ />
+ ,
+ );
+
+ fireEvent.click(screen.getByRole('button'));
+
+ await waitFor(() => {
+ expect(screen.getByText('Disconnect')).toBeInTheDocument();
+ });
+
+ fireEvent.mouseDown(screen.getByTestId('outside'));
+
+ await waitFor(() => {
+ expect(screen.queryByText('Disconnect')).not.toBeInTheDocument();
+ });
+ });
+ });
+
+ describe('network switcher integration', () => {
+ it('passes network props to dropdown', async () => {
+ const onNetworkChange = vi.fn();
+
+ render(
+ {}}
+ onDisconnect={() => {}}
+ selectedNetwork="devnet"
+ networkStatus="connected"
+ onNetworkChange={onNetworkChange}
+ />,
+ );
+
+ fireEvent.click(screen.getByRole('button'));
+
+ await waitFor(() => {
+ expect(screen.getByText('Disconnect')).toBeInTheDocument();
+ });
+
+ expect(screen.getByText('Network')).toBeInTheDocument();
+ });
+
+ it('renders without network props (optional)', async () => {
+ render(
+ {}}
+ onDisconnect={() => {}}
+ />,
+ );
+
+ fireEvent.click(screen.getByRole('button'));
+
+ await waitFor(() => {
+ expect(screen.getByText('Disconnect')).toBeInTheDocument();
+ });
+
+ expect(screen.getByText('Network')).toBeInTheDocument();
+ });
+ });
+
+ describe('balance display', () => {
+ it('shows loading state when balanceLoading is true', async () => {
+ render(
+ {}}
+ onDisconnect={() => {}}
+ />,
+ );
+
+ fireEvent.click(screen.getByRole('button'));
+
+ await waitFor(() => {
+ expect(screen.getByText('Disconnect')).toBeInTheDocument();
+ });
+
+ expect(screen.getByText('Loading...')).toBeInTheDocument();
+ });
+
+ it('handles undefined balance gracefully', async () => {
+ render(
+ {}}
+ onDisconnect={() => {}}
+ />,
+ );
+
+ fireEvent.click(screen.getByRole('button'));
+
+ await waitFor(() => {
+ expect(screen.getByText('Disconnect')).toBeInTheDocument();
+ });
+ });
+ });
+
+ describe('SSR / hydration', () => {
+ it('shows disconnected state when isReady is false', () => {
+ render(
+ {}}
+ onDisconnect={() => {}}
+ />,
+ );
+
+ expect(screen.getByText('Connect Wallet')).toBeInTheDocument();
+ });
+
+ it('disables button when isReady is false', () => {
+ render(
+ {}}
+ onDisconnect={() => {}}
+ />,
+ );
+ expect(screen.getByRole('button')).toBeDisabled();
+ });
+ });
+
+ describe('custom className', () => {
+ it('applies additional className to container', () => {
+ const { container } = render(
+ {}}
+ onDisconnect={() => {}}
+ />,
+ );
+ expect(container.firstChild).toHaveClass('custom-class');
+ });
+ });
+
+ describe('error status', () => {
+ it('shows disconnected state when status is error', () => {
+ render( {}} onDisconnect={() => {}} />);
+ expect(screen.getByText('Connect Wallet')).toBeInTheDocument();
+ });
+
+ it('allows reconnection when in error state', () => {
+ const onConnect = vi.fn();
+ render( {}} />);
+
+ fireEvent.click(screen.getByRole('button'));
+
+ expect(onConnect).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('custom labels', () => {
+ it('renders custom disconnect label', async () => {
+ render(
+ {}}
+ onDisconnect={() => {}}
+ />,
+ );
+
+ fireEvent.click(screen.getByRole('button'));
+
+ await waitFor(() => {
+ expect(screen.getByText('Log Out')).toBeInTheDocument();
+ });
+ });
+ });
+});
diff --git a/packages/components/src/kit-components/ui/connect-wallet-button/ConnectWalletButton.tsx b/packages/components/src/kit-components/ui/connect-wallet-button/ConnectWalletButton.tsx
new file mode 100644
index 0000000..df00dd2
--- /dev/null
+++ b/packages/components/src/kit-components/ui/connect-wallet-button/ConnectWalletButton.tsx
@@ -0,0 +1,201 @@
+'use client';
+
+import type { ClusterMoniker, WalletStatus } from '@solana/client';
+import type { Address, Lamports } from '@solana/kit';
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { cn } from '@/lib/utils';
+import { WalletButton } from './WalletButton';
+import { WalletDropdown, WalletDropdownWrapper } from './WalletDropdown';
+
+/**
+ * Props for the ConnectWalletButton component.
+ */
+export interface ConnectWalletButtonProps {
+ /** Additional className for the container */
+ className?: string;
+ /** Custom labels */
+ labels?: {
+ connect?: string;
+ connecting?: string;
+ disconnect?: string;
+ };
+ // === Wallet Connection Props (from useWalletConnection) ===
+ /** Current connection status */
+ status: 'disconnected' | 'connecting' | 'connected' | 'error';
+ /** Whether the hook has hydrated (for SSR) */
+ isReady?: boolean;
+ /** Connected wallet session */
+ wallet?: {
+ address: Address;
+ publicKey?: { toBase58(): string };
+ };
+ /** Current wallet connector info */
+ currentConnector?: {
+ id: string;
+ name: string;
+ icon?: string;
+ };
+ /** Wallet balance in lamports */
+ balance?: Lamports;
+ /** Whether balance is still loading */
+ balanceLoading?: boolean;
+ /** Callback when connect button is clicked (opens wallet modal) */
+ onConnect?: () => void;
+ /** Callback to disconnect wallet */
+ onDisconnect?: () => Promise | void;
+ // === Network Props ===
+ /** Currently selected network */
+ selectedNetwork?: ClusterMoniker;
+ /** Network connection status */
+ networkStatus?: WalletStatus['status'];
+ /** Callback when network is changed */
+ onNetworkChange?: (network: ClusterMoniker) => void;
+}
+
+/**
+ * ConnectWalletButton - Fully functional wallet connection button.
+ *
+ * This component handles all wallet connection states and integrates with
+ * the framework-kit hooks. Use this as the main entry point for wallet UIs.
+ *
+ * @example
+ * ```tsx
+ * // With useWalletConnection hook
+ * const { status, wallet, currentConnector, disconnect, isReady } = useWalletConnection();
+ * const { lamports } = useBalance(wallet?.address);
+ * const modal = useWalletModalState();
+ *
+ *
+ * ```
+ */
+export function ConnectWalletButton({
+ className,
+ labels,
+ status,
+ isReady = true,
+ wallet,
+ currentConnector,
+ balance,
+ balanceLoading = false,
+ onConnect,
+ onDisconnect,
+ selectedNetwork,
+ networkStatus,
+ onNetworkChange,
+}: ConnectWalletButtonProps) {
+ const [isDropdownOpen, setIsDropdownOpen] = useState(false);
+ const [balanceVisible, setBalanceVisible] = useState(true);
+ const containerRef = useRef(null);
+
+ // Map external status to internal connection state
+ const connectionState = (() => {
+ if (!isReady) return 'disconnected'; // Show disconnected during SSR
+ if (status === 'connecting') return 'connecting';
+ if (status === 'connected' && wallet) return 'connected';
+ return 'disconnected';
+ })();
+
+ // Format wallet address for display
+ const walletAddress = wallet?.address ?? (wallet?.publicKey?.toBase58() as Address | undefined);
+
+ // Handle button click based on state
+ const handleButtonClick = useCallback(() => {
+ if (connectionState === 'connected') {
+ setIsDropdownOpen((prev) => !prev);
+ } else if (connectionState === 'disconnected') {
+ onConnect?.();
+ }
+ }, [connectionState, onConnect]);
+
+ // Handle disconnect
+ const handleDisconnect = useCallback(async () => {
+ setIsDropdownOpen(false);
+ await onDisconnect?.();
+ }, [onDisconnect]);
+
+ // Handle balance visibility toggle
+ const handleToggleBalance = useCallback(() => {
+ setBalanceVisible((prev) => !prev);
+ }, []);
+
+ // Close dropdown when clicking outside
+ useEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
+ setIsDropdownOpen(false);
+ }
+ };
+
+ if (isDropdownOpen) {
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }
+ }, [isDropdownOpen]);
+
+ // Close dropdown on escape key
+ useEffect(() => {
+ const handleEscape = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') {
+ setIsDropdownOpen(false);
+ }
+ };
+
+ if (isDropdownOpen) {
+ document.addEventListener('keydown', handleEscape);
+ return () => document.removeEventListener('keydown', handleEscape);
+ }
+ }, [isDropdownOpen]);
+
+ // Build wallet info for UI components
+ const walletInfo = currentConnector
+ ? {
+ id: currentConnector.id,
+ name: currentConnector.name,
+ icon: currentConnector.icon,
+ }
+ : undefined;
+
+ return (
+
+
+ {connectionState === 'disconnected' && (labels?.connect ?? 'Connect Wallet')}
+
+
+ {connectionState === 'connected' && walletInfo && walletAddress && (
+
+
+
+ )}
+
+ );
+}
+
+export default ConnectWalletButton;
diff --git a/packages/components/src/kit-components/ui/connect-wallet-button/WalletButton.tsx b/packages/components/src/kit-components/ui/connect-wallet-button/WalletButton.tsx
new file mode 100644
index 0000000..7f2270e
--- /dev/null
+++ b/packages/components/src/kit-components/ui/connect-wallet-button/WalletButton.tsx
@@ -0,0 +1,161 @@
+'use client';
+
+import { cva, type VariantProps } from 'class-variance-authority';
+import { forwardRef } from 'react';
+import { cn } from '@/lib/utils';
+import { ButtonContent } from './ButtonContent';
+import { ButtonIcon } from './ButtonIcon';
+import { ButtonSpinner } from './ButtonSpinner';
+import { ChevronIcon } from './ChevronIcon';
+import type { WalletButtonProps } from './types';
+
+/**
+ * Button variants using class-variance-authority.
+ * Uses semantic CSS variable tokens for theming.
+ */
+const walletButtonVariants = cva(
+ [
+ 'inline-flex items-center justify-center',
+ 'font-medium text-sm leading-4',
+ 'transition-all duration-200 ease-in-out',
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-ring',
+ 'disabled:pointer-events-none disabled:opacity-50',
+ 'cursor-pointer',
+ ],
+ {
+ variants: {
+ variant: {
+ /** Filled button - Disconnected state */
+ filled: [
+ 'border border-border',
+ 'bg-primary hover:bg-primary/90',
+ 'text-primary-foreground',
+ 'gap-2 rounded-2xl',
+ ],
+ /** Loading state */
+ loading: ['border border-transparent', 'bg-secondary', 'text-card-foreground', 'rounded-2xl'],
+ /** Connected state */
+ connected: [
+ 'border border-border',
+ 'bg-card hover:bg-secondary',
+ 'text-card-foreground',
+ 'gap-2.5 rounded-lg',
+ ],
+ },
+ size: {
+ /** Default size */
+ default: 'min-h-9 px-4 py-2.5',
+ sm: 'h-8 px-3 py-1.5 text-xs',
+ lg: 'h-12 px-6 py-3 text-base',
+ /** Loading size (wider horizontal padding) */
+ loading: 'min-h-9 px-5 py-2.5',
+ /** Connected size */
+ connected: 'min-h-9 px-4 py-2.5',
+ },
+ },
+ defaultVariants: {
+ variant: 'filled',
+ size: 'default',
+ },
+ },
+);
+
+export type WalletButtonVariantProps = VariantProps;
+
+export interface WalletButtonFullProps extends WalletButtonProps, WalletButtonVariantProps {
+ /** Button variant style */
+ variant?: 'filled' | 'loading' | 'connected';
+ /** Button size */
+ size?: 'default' | 'sm' | 'lg' | 'loading' | 'connected';
+}
+
+/**
+ * WalletButton - Main button component for wallet connection.
+ * Handles disconnected, connecting, and connected states with appropriate UI.
+ *
+ * @example
+ * ```tsx
+ * // Disconnected state
+ *
+ *
+ * // Connecting state
+ *
+ *
+ * // Connected state
+ *
+ * ```
+ */
+export const WalletButton = forwardRef(
+ ({ connectionState, wallet, isExpanded = false, variant, size, className, disabled, children, ...props }, ref) => {
+ // Determine variant based on connection state
+ const resolvedVariant = (() => {
+ if (variant) return variant;
+ if (connectionState === 'connected') return 'connected';
+ if (connectionState === 'connecting') return 'loading';
+ return 'filled';
+ })();
+
+ // Determine size based on connection state
+ const resolvedSize = (() => {
+ if (size) return size;
+ if (connectionState === 'connected') return 'connected';
+ if (connectionState === 'connecting') return 'loading';
+ return 'default';
+ })();
+
+ // Render content based on connection state
+ const renderContent = () => {
+ switch (connectionState) {
+ case 'connecting':
+ return ;
+
+ case 'connected':
+ return (
+ <>
+
+
+ >
+ );
+ default:
+ return {children ?? 'Connect Wallet'};
+ }
+ };
+
+ const buttonClasses = cn(walletButtonVariants({ variant: resolvedVariant, size: resolvedSize }), className);
+
+ return (
+
+ );
+ },
+);
+
+WalletButton.displayName = 'WalletButton';
+
+export { walletButtonVariants };
diff --git a/packages/components/src/kit-components/ui/connect-wallet-button/WalletDropdown.tsx b/packages/components/src/kit-components/ui/connect-wallet-button/WalletDropdown.tsx
new file mode 100644
index 0000000..00e2fed
--- /dev/null
+++ b/packages/components/src/kit-components/ui/connect-wallet-button/WalletDropdown.tsx
@@ -0,0 +1,182 @@
+'use client';
+
+import { LogOut } from 'lucide-react';
+import { useCallback, useState } from 'react';
+import { cn, formatSolBalance } from '@/lib/utils';
+import { AddressDisplay } from '../address-display/AddressDisplay';
+import { NetworkDropdown } from '../network-switcher/NetworkDropdown';
+import { NetworkHeader } from '../network-switcher/NetworkHeader';
+import { NetworkTrigger } from '../network-switcher/NetworkTrigger';
+import { DEFAULT_NETWORKS } from '../network-switcher/types';
+import { ButtonIcon } from './ButtonIcon';
+import type { WalletDropdownProps } from './types';
+
+export function WalletDropdown({
+ wallet,
+ address,
+ balance,
+ balanceVisible: controlledBalanceVisible,
+ balanceLoading = false,
+ onToggleBalance,
+ onDisconnect,
+ onCopyAddress,
+ selectedNetwork = 'mainnet-beta',
+ networkStatus = 'connected',
+ onNetworkChange,
+ className,
+ disconnectLabel = 'Disconnect',
+}: WalletDropdownProps): React.ReactElement {
+ // View state: 'wallet' (default) or 'network' (swaps in-place per Figma)
+ const [view, setView] = useState<'wallet' | 'network'>('wallet');
+ const [internalBalanceVisible, setInternalBalanceVisible] = useState(true);
+
+ const balanceVisible = controlledBalanceVisible ?? internalBalanceVisible;
+ const networks = DEFAULT_NETWORKS;
+
+ // ── Handlers ──────────────────────────────────────────────
+ const handleToggleBalance = useCallback(() => {
+ if (onToggleBalance) {
+ onToggleBalance();
+ } else {
+ setInternalBalanceVisible((prev) => !prev);
+ }
+ }, [onToggleBalance]);
+
+ const handleNetworkSelect = useCallback(
+ (network: Parameters>[0]) => {
+ onNetworkChange?.(network);
+ setView('wallet');
+ },
+ [onNetworkChange],
+ );
+
+ // ── Derived ───────────────────────────────────────────────
+ const formattedBalance = balance !== undefined ? `SOL ${formatSolBalance(balance)}` : null;
+ const balanceText = (() => {
+ if (balanceLoading) return 'Loading...';
+ if (!balanceVisible) return '******';
+ return formattedBalance;
+ })();
+
+ const rowPx = 'px-4 py-2.5';
+ const containerCn = cn('min-w-full w-max max-w-sm rounded-lg overflow-hidden', 'bg-card', 'shadow-lg', className);
+
+ // ═══════════════════════════════════════════════════════════
+ // VIEW 2: Network selection (replaces wallet dropdown in-place)
+ // Figma node 210:711 / 210:851
+ // Composes NetworkHeader + NetworkDropdown sub-components
+ // (exported building blocks — the consumer API for custom layouts)
+ // ═══════════════════════════════════════════════════════════
+ if (view === 'network') {
+ return (
+
+
+ {/* Header: "Network" + chevron-up → click goes back to wallet view */}
+ setView('wallet')} />
+
+ {/* Network options — className strips the default container styles */}
+
+
+
+ );
+ }
+
+ // ═══════════════════════════════════════════════════════════
+ // VIEW 1: Wallet info (default)
+ // Figma node 210:630 / 210:775
+ // ═══════════════════════════════════════════════════════════
+ return (
+
+ {/* ── Row 1: Address + Balance ── */}
+
+
+
+
+
+
+
+ {(formattedBalance || balanceLoading) && (
+
+ )}
+
+
+
+
+ {/* ── Row 2: Network trigger → swaps to network view ── */}
+
+ setView('network')}
+ className={cn(
+ 'w-full! min-w-0! bg-transparent! border-0! rounded-none!',
+ 'text-card-foreground',
+ 'hover:bg-accent',
+ 'transition-colors duration-200',
+ )}
+ />
+
+
+ {/* ── Row 3: Disconnect (only when onDisconnect is provided) ── */}
+ {onDisconnect && (
+
+ )}
+
+ );
+}
+
+/**
+ * WalletDropdownWrapper - Wrapper component for positioning dropdown relative to button.
+ */
+export function WalletDropdownWrapper({
+ isOpen,
+ children,
+ className,
+}: {
+ isOpen: boolean;
+ children: React.ReactNode;
+ className?: string;
+}): React.ReactElement | null {
+ if (!isOpen) return null;
+
+ return {children}
;
+}
diff --git a/packages/components/src/kit-components/ui/connect-wallet-button/assets/backpack.png b/packages/components/src/kit-components/ui/connect-wallet-button/assets/backpack.png
new file mode 100644
index 0000000..97c3971
Binary files /dev/null and b/packages/components/src/kit-components/ui/connect-wallet-button/assets/backpack.png differ
diff --git a/packages/components/src/kit-components/ui/connect-wallet-button/assets/solflare.png b/packages/components/src/kit-components/ui/connect-wallet-button/assets/solflare.png
new file mode 100644
index 0000000..070e1fc
Binary files /dev/null and b/packages/components/src/kit-components/ui/connect-wallet-button/assets/solflare.png differ
diff --git a/packages/components/src/kit-components/ui/connect-wallet-button/index.ts b/packages/components/src/kit-components/ui/connect-wallet-button/index.ts
new file mode 100644
index 0000000..cd0ffb1
--- /dev/null
+++ b/packages/components/src/kit-components/ui/connect-wallet-button/index.ts
@@ -0,0 +1,72 @@
+/**
+ * ConnectWalletButton - Solana wallet connection components
+ *
+ * @description
+ * A composable set of components for connecting to Solana wallets.
+ * All sub-components are exported for custom compositions.
+ *
+ * @example Integrated usage (recommended)
+ * ```tsx
+ * import { ConnectWalletButton } from '@framework-kit/components';
+ * import { useWalletConnection, useBalance } from '@solana/react-hooks';
+ *
+ * const { status, wallet, currentConnector, disconnect, isReady } = useWalletConnection();
+ * const { lamports } = useBalance(wallet?.address);
+ *
+ *
+ * ```
+ *
+ * @example Basic usage with raw components
+ * ```tsx
+ * import { WalletButton } from '@framework-kit/components';
+ *
+ *
+ * ```
+ *
+ * @example Custom composition
+ * ```tsx
+ * import {
+ * ButtonContent,
+ * ButtonIcon,
+ * ButtonSpinner,
+ * ChevronIcon,
+ * } from '@framework-kit/components';
+ *
+ *
+ * ```
+ */
+
+// Sub-components
+export { ButtonContent } from './ButtonContent';
+export { ButtonIcon } from './ButtonIcon';
+export { ButtonSpinner } from './ButtonSpinner';
+export { ChevronIcon } from './ChevronIcon';
+export type { ConnectWalletButtonProps } from './ConnectWalletButton';
+// Integrated component (recommended for most use cases)
+export { ConnectWalletButton } from './ConnectWalletButton';
+// Types
+export type {
+ ButtonContentProps,
+ ButtonIconProps,
+ ButtonSpinnerProps,
+ ChevronIconProps,
+ WalletButtonProps,
+ WalletDropdownProps,
+} from './types';
+export type { WalletButtonFullProps } from './WalletButton';
+// Main button component
+export { WalletButton, walletButtonVariants } from './WalletButton';
+// Dropdown component
+export { WalletDropdown, WalletDropdownWrapper } from './WalletDropdown';
diff --git a/packages/components/src/kit-components/ui/connect-wallet-button/types.ts b/packages/components/src/kit-components/ui/connect-wallet-button/types.ts
new file mode 100644
index 0000000..b820c66
--- /dev/null
+++ b/packages/components/src/kit-components/ui/connect-wallet-button/types.ts
@@ -0,0 +1,87 @@
+/**
+ * ConnectWalletButton Component Types
+ * @description Type definitions for the connect wallet button and its sub-components
+ */
+
+import type { ClusterMoniker, WalletConnectorMetadata, WalletStatus } from '@solana/client';
+import type { Address, Lamports } from '@solana/kit';
+import type React from 'react';
+
+/** Props for the main WalletButton component */
+export interface WalletButtonProps extends React.ButtonHTMLAttributes {
+ /** Current connection state */
+ connectionState: WalletStatus['status'];
+ /** Connected wallet info (required when connected) */
+ wallet?: WalletConnectorMetadata | null;
+ /** Whether the dropdown is expanded (connected state) */
+ isExpanded?: boolean;
+ /** Custom class name */
+ className?: string;
+}
+
+/** Props for the ButtonContent sub-component */
+export interface ButtonContentProps {
+ /** Text content to display */
+ children: React.ReactNode;
+ /** Custom class name */
+ className?: string;
+}
+
+/** Props for the ButtonSpinner sub-component */
+export interface ButtonSpinnerProps {
+ /** Spinner size in pixels */
+ size?: number;
+ /** Custom class name */
+ className?: string;
+}
+
+/** Props for the ButtonIcon sub-component */
+export interface ButtonIconProps {
+ /** Icon source URL or base64 data */
+ src?: string;
+ /** Alt text for accessibility */
+ alt?: string;
+ /** Icon size in pixels */
+ size?: number;
+ /** Custom class name */
+ className?: string;
+}
+
+/** Props for the ChevronIcon sub-component */
+export interface ChevronIconProps {
+ /** Direction of the chevron */
+ direction: 'up' | 'down';
+ /** Custom class name */
+ className?: string;
+}
+
+/** Props for the connected dropdown */
+export interface WalletDropdownProps {
+ /** Connected wallet info */
+ wallet: WalletConnectorMetadata;
+ /** Wallet address (typed as Address for proper Solana integration) */
+ address: Address;
+ /** Balance in lamports */
+ balance?: Lamports;
+ /** Whether balance is visible or hidden */
+ balanceVisible?: boolean;
+ /** Whether balance is still loading */
+ balanceLoading?: boolean;
+ /** Callback when balance visibility toggles */
+ onToggleBalance?: () => void;
+ /** Callback when disconnect is clicked */
+ onDisconnect?: () => void;
+ /** Callback when address is copied */
+ onCopyAddress?: () => void;
+ // === Network Props ===
+ /** Currently selected network */
+ selectedNetwork?: ClusterMoniker;
+ /** Network connection status */
+ networkStatus?: WalletStatus['status'];
+ /** Callback when network is changed */
+ onNetworkChange?: (network: ClusterMoniker) => void;
+ /** Custom class name */
+ className?: string;
+ /** Custom label for the disconnect button */
+ disconnectLabel?: string;
+}
diff --git a/packages/components/src/kit-components/ui/dashboard-shell/DashboardShell.test.tsx b/packages/components/src/kit-components/ui/dashboard-shell/DashboardShell.test.tsx
new file mode 100644
index 0000000..c5a1869
--- /dev/null
+++ b/packages/components/src/kit-components/ui/dashboard-shell/DashboardShell.test.tsx
@@ -0,0 +1,187 @@
+// @vitest-environment jsdom
+import { cleanup, render, screen } from '@testing-library/react';
+import { afterEach, describe, expect, it } from 'vitest';
+import '@testing-library/jest-dom/vitest';
+
+import { DashboardShell } from './DashboardShell';
+
+afterEach(() => {
+ cleanup();
+});
+describe('DashboardShell', () => {
+ //basic rendering test
+ it('renders without crashing', () => {
+ render(
+
+ Content
+ ,
+ );
+ expect(screen.getByTestId('shell')).toBeInTheDocument();
+ });
+ //test to see that children are rendered
+ it('renders children content', () => {
+ render(
+
+ Hello World
+ ,
+ );
+ expect(screen.getByTestId('child')).toBeInTheDocument();
+ expect(screen.getByText('Hello World')).toBeInTheDocument();
+ });
+ // test to see that header slot works
+ it('renders header when provided', () => {
+ render(
+ Navigation}>
+ Content
+ ,
+ );
+ expect(screen.getByTestId('header')).toBeInTheDocument();
+ expect(screen.getByText('Navigation')).toBeInTheDocument();
+ });
+ // test that header is optional
+ it('does not render header element when not provided', () => {
+ render(
+
+ Content
+ ,
+ );
+ expect(screen.queryByRole('banner')).not.toBeInTheDocument();
+ });
+ // test that semantic bg-background token is applied
+ it('applies bg-background semantic token', () => {
+ render(
+
+ Content
+ ,
+ );
+ expect(screen.getByTestId('shell')).toHaveClass('bg-background');
+ });
+ // test that custom classes are applied
+ it('applies custom className', () => {
+ render(
+
+ Content
+ ,
+ );
+ expect(screen.getByTestId('shell')).toHaveClass('custom-class');
+ });
+ //test that props are passed through
+ it('passes through additional props', () => {
+ render(
+
+ Content
+ ,
+ );
+ const shell = screen.getByTestId('shell');
+ expect(shell).toHaveAttribute('id', 'my-shell');
+ expect(shell).toHaveAttribute('aria-label', 'Dashboard');
+ });
+ // test that dot grid shows by default
+ it('renders dot grid background pattern by default', () => {
+ render(
+
+ Content
+ ,
+ );
+ const shell = screen.getByTestId('shell');
+ const dotGrid = shell.querySelector('[aria-hidden="true"]');
+ expect(dotGrid).toBeInTheDocument();
+ });
+ // test that dot grid can be hidden
+ it('hides dot grid when showDotGrid is false', () => {
+ render(
+
+ Content
+ ,
+ );
+ const shell = screen.getByTestId('shell');
+ const dotGrid = shell.querySelector('[aria-hidden="true"]');
+ expect(dotGrid).not.toBeInTheDocument();
+ });
+ //test for semantic HTML structure
+ it('uses semantic HTML elements', () => {
+ render(
+ Nav}>
+ Content
+ ,
+ );
+ // header slot renders inside element
+ expect(screen.getByRole('banner')).toBeInTheDocument();
+ // children render inside element
+ expect(screen.getByRole('main')).toBeInTheDocument();
+ });
+ // test that rounded corners are applied by default
+ it('applies rounded-3xl by default', () => {
+ render(
+
+ Content
+ ,
+ );
+ expect(screen.getByTestId('shell')).toHaveClass('rounded-3xl');
+ });
+ // test that rounded corners can be disabled
+ it('does not apply rounded-3xl when rounded is false', () => {
+ render(
+
+ Content
+ ,
+ );
+ expect(screen.getByTestId('shell')).not.toHaveClass('rounded-3xl');
+ });
+ // test that headerClassName is applied
+ it('applies headerClassName to the header element', () => {
+ render(
+ Nav} headerClassName="custom-header">
+ Content
+ ,
+ );
+ expect(screen.getByRole('banner')).toHaveClass('custom-header');
+ });
+ // test that contentClassName is applied
+ it('applies contentClassName to the main element', () => {
+ render(
+
+ Content
+ ,
+ );
+ expect(screen.getByRole('main')).toHaveClass('custom-content');
+ });
+ // test responsive padding on header
+ it('applies responsive padding to header', () => {
+ render(
+ Nav}>
+ Content
+ ,
+ );
+ const header = screen.getByRole('banner');
+ expect(header).toHaveClass('p-4');
+ expect(header).toHaveClass('md:p-6');
+ expect(header).toHaveClass('lg:p-8');
+ });
+ // test responsive padding on main
+ it('applies responsive padding to main', () => {
+ render(
+
+ Content
+ ,
+ );
+ const main = screen.getByRole('main');
+ expect(main).toHaveClass('p-4');
+ expect(main).toHaveClass('md:p-6');
+ expect(main).toHaveClass('lg:p-8');
+ });
+ // header and main use relative positioning without z-index so dropdowns can escape
+ it('does not trap dropdowns with z-index on header or main', () => {
+ render(
+ Nav}>
+ Content
+ ,
+ );
+ const header = screen.getByRole('banner');
+ const main = screen.getByRole('main');
+ expect(header).toHaveClass('relative');
+ expect(main).toHaveClass('relative');
+ expect(header).not.toHaveClass('z-10');
+ expect(main).not.toHaveClass('z-10');
+ });
+});
diff --git a/packages/components/src/kit-components/ui/dashboard-shell/DashboardShell.tsx b/packages/components/src/kit-components/ui/dashboard-shell/DashboardShell.tsx
new file mode 100644
index 0000000..1849f97
--- /dev/null
+++ b/packages/components/src/kit-components/ui/dashboard-shell/DashboardShell.tsx
@@ -0,0 +1,61 @@
+import type React from 'react';
+import { cn } from '@/lib/utils';
+
+export interface DashboardShellProps extends React.HTMLAttributes {
+ // slot for optional header content like nav, wallet buttons, etc
+ header?: React.ReactNode;
+ // main content slot
+ children?: React.ReactNode;
+ // slot for showing the dot grid background pattern
+ showDotGrid?: boolean;
+ // additional class name for the header element
+ headerClassName?: string;
+ // additional class name for the main content element
+ contentClassName?: string;
+ // whether to apply rounded corners (default true)
+ rounded?: boolean;
+}
+
+export const DashboardShell: React.FC = ({
+ header,
+ children,
+ showDotGrid = true,
+ headerClassName,
+ contentClassName,
+ rounded = true,
+ className,
+ ...props
+}) => {
+ return (
+
+ {/* dot grid background pattern if enabled */}
+ {showDotGrid && (
+
+ )}
+ {/* header slot */}
+ {header && (
+
+ )}
+ {/* main content slot */}
+
{children}
+
+ );
+};
diff --git a/packages/components/src/kit-components/ui/dashboard-shell/index.ts b/packages/components/src/kit-components/ui/dashboard-shell/index.ts
new file mode 100644
index 0000000..ed06c5d
--- /dev/null
+++ b/packages/components/src/kit-components/ui/dashboard-shell/index.ts
@@ -0,0 +1,2 @@
+export type { DashboardShellProps } from './DashboardShell';
+export { DashboardShell } from './DashboardShell';
diff --git a/packages/components/src/kit-components/ui/index.ts b/packages/components/src/kit-components/ui/index.ts
new file mode 100644
index 0000000..c9150fa
--- /dev/null
+++ b/packages/components/src/kit-components/ui/index.ts
@@ -0,0 +1,10 @@
+export * from './address-display';
+export * from './balance-card';
+export * from './connect-wallet-button';
+export * from './dashboard-shell';
+export * from './network-switcher';
+export * from './skeleton';
+export * from './swap-input';
+export * from './transaction-table';
+export * from './transaction-toast';
+export * from './wallet-modal';
diff --git a/packages/components/src/kit-components/ui/network-switcher/NetworkDropdown.tsx b/packages/components/src/kit-components/ui/network-switcher/NetworkDropdown.tsx
new file mode 100644
index 0000000..7ef0839
--- /dev/null
+++ b/packages/components/src/kit-components/ui/network-switcher/NetworkDropdown.tsx
@@ -0,0 +1,34 @@
+'use client';
+
+import { cn } from '@/lib/utils';
+import { NetworkOption } from './NetworkOption';
+import type { NetworkDropdownProps } from './types';
+
+/**
+ * NetworkDropdown - Expanded dropdown showing all network options.
+ */
+export function NetworkDropdown({
+ selectedNetwork,
+ status = 'connected',
+ networks,
+ onSelect,
+ className,
+}: NetworkDropdownProps) {
+ return (
+
+ {networks.map((network) => (
+ onSelect?.(network.id)}
+ />
+ ))}
+
+ );
+}
diff --git a/packages/components/src/kit-components/ui/network-switcher/NetworkHeader.tsx b/packages/components/src/kit-components/ui/network-switcher/NetworkHeader.tsx
new file mode 100644
index 0000000..0605b0d
--- /dev/null
+++ b/packages/components/src/kit-components/ui/network-switcher/NetworkHeader.tsx
@@ -0,0 +1,29 @@
+'use client';
+
+import { ChevronUp } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import type { NetworkHeaderProps } from './types';
+
+/**
+ * NetworkHeader - Header row inside dropdown with "Network" label and chevron.
+ */
+export function NetworkHeader({ isOpen = true, onClick, className }: NetworkHeaderProps) {
+ return (
+
+ );
+}
diff --git a/packages/components/src/kit-components/ui/network-switcher/NetworkOption.tsx b/packages/components/src/kit-components/ui/network-switcher/NetworkOption.tsx
new file mode 100644
index 0000000..e39b170
--- /dev/null
+++ b/packages/components/src/kit-components/ui/network-switcher/NetworkOption.tsx
@@ -0,0 +1,30 @@
+'use client';
+
+import { cn } from '@/lib/utils';
+import { StatusIndicator } from './StatusIndicator';
+import type { NetworkOptionProps } from './types';
+
+/**
+ * NetworkOption - A single network option in the dropdown.
+ */
+export function NetworkOption({ network, isSelected = false, status, onClick, className }: NetworkOptionProps) {
+ return (
+
+ );
+}
diff --git a/packages/components/src/kit-components/ui/network-switcher/NetworkSwitcher.test.tsx b/packages/components/src/kit-components/ui/network-switcher/NetworkSwitcher.test.tsx
new file mode 100644
index 0000000..f709f3b
--- /dev/null
+++ b/packages/components/src/kit-components/ui/network-switcher/NetworkSwitcher.test.tsx
@@ -0,0 +1,303 @@
+// @vitest-environment jsdom
+
+import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import '@testing-library/jest-dom/vitest';
+
+import { NetworkSwitcher } from './NetworkSwitcher';
+import { DEFAULT_NETWORKS } from './types';
+
+afterEach(() => {
+ cleanup();
+});
+
+describe('NetworkSwitcher', () => {
+ describe('rendering', () => {
+ it('renders without crashing', () => {
+ render();
+ expect(screen.getByRole('button')).toBeInTheDocument();
+ });
+
+ it('renders trigger button', () => {
+ render();
+ expect(screen.getByRole('button')).toBeInTheDocument();
+ });
+
+ it('dropdown is closed by default', () => {
+ render();
+ // Network options should not be visible when dropdown is closed
+ expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
+ });
+
+ it('shows selected network label in trigger', () => {
+ render();
+ expect(screen.getByRole('button')).toHaveTextContent('Devnet');
+ });
+
+ it('shows status indicator in trigger when connected', () => {
+ render();
+ expect(screen.getByLabelText('Connected')).toBeInTheDocument();
+ });
+
+ it('updates trigger label when selectedNetwork changes', () => {
+ const { rerender } = render();
+ expect(screen.getByRole('button')).toHaveTextContent('Mainnet');
+
+ rerender();
+ expect(screen.getByRole('button')).toHaveTextContent('Devnet');
+ });
+ });
+
+ describe('dropdown behavior', () => {
+ it('opens dropdown when trigger is clicked', () => {
+ render();
+
+ fireEvent.click(screen.getByRole('button'));
+
+ // Network options should now be visible
+ expect(screen.getByText('Devnet')).toBeInTheDocument();
+ expect(screen.getByText('Testnet')).toBeInTheDocument();
+ });
+
+ it('displays all default network options', () => {
+ render();
+
+ fireEvent.click(screen.getByRole('button'));
+
+ const listbox = screen.getByRole('listbox');
+ for (const network of DEFAULT_NETWORKS) {
+ expect(within(listbox).getByText(network.label)).toBeInTheDocument();
+ }
+ });
+
+ it('closes dropdown after network selection', () => {
+ const onNetworkChange = vi.fn();
+ render();
+
+ // Open dropdown
+ fireEvent.click(screen.getByRole('button'));
+ expect(screen.getByText('Devnet')).toBeInTheDocument();
+
+ // Select a network
+ fireEvent.click(screen.getByText('Devnet'));
+
+ // Dropdown should close
+ expect(screen.queryByText('Testnet')).not.toBeInTheDocument();
+ });
+
+ it('toggles dropdown open and closed', () => {
+ render();
+
+ const trigger = screen.getByRole('button');
+
+ // Open
+ fireEvent.click(trigger);
+ expect(screen.getByText('Devnet')).toBeInTheDocument();
+
+ // Close
+ fireEvent.click(trigger);
+ expect(screen.queryByText('Devnet')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('network selection', () => {
+ it('calls onNetworkChange when network is selected', () => {
+ const onNetworkChange = vi.fn();
+ render();
+
+ fireEvent.click(screen.getByRole('button'));
+ fireEvent.click(screen.getByText('Devnet'));
+
+ expect(onNetworkChange).toHaveBeenCalledWith('devnet');
+ });
+
+ it('calls onNetworkChange with correct network id', () => {
+ const onNetworkChange = vi.fn();
+ render();
+
+ fireEvent.click(screen.getByRole('button'));
+ fireEvent.click(screen.getByText('Testnet'));
+
+ expect(onNetworkChange).toHaveBeenCalledWith('testnet');
+ });
+ });
+
+ describe('keyboard interactions', () => {
+ it('closes dropdown on Escape key', () => {
+ render();
+
+ // Open dropdown
+ fireEvent.click(screen.getByRole('button'));
+ expect(screen.getByText('Devnet')).toBeInTheDocument();
+
+ // Press Escape
+ fireEvent.keyDown(document, { key: 'Escape' });
+
+ expect(screen.queryByText('Devnet')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('click outside', () => {
+ it('closes dropdown when clicking outside', () => {
+ render(
+ ,
+ );
+
+ // Open dropdown
+ fireEvent.click(screen.getByRole('button'));
+ expect(screen.getByText('Devnet')).toBeInTheDocument();
+
+ // Click outside
+ fireEvent.mouseDown(screen.getByTestId('outside'));
+
+ expect(screen.queryByText('Devnet')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('controlled mode', () => {
+ it('respects controlled open prop', () => {
+ render();
+
+ // Dropdown should be open without clicking
+ expect(screen.getByText('Devnet')).toBeInTheDocument();
+ });
+
+ it('calls onOpenChange when toggle is clicked', () => {
+ const onOpenChange = vi.fn();
+ render();
+
+ fireEvent.click(screen.getByRole('button'));
+
+ expect(onOpenChange).toHaveBeenCalledWith(true);
+ });
+
+ it('calls onOpenChange with false when closing', () => {
+ const onOpenChange = vi.fn();
+ render();
+
+ fireEvent.keyDown(document, { key: 'Escape' });
+
+ expect(onOpenChange).toHaveBeenCalledWith(false);
+ });
+ });
+
+ describe('disabled state', () => {
+ it('does not open dropdown when disabled', () => {
+ render();
+
+ fireEvent.click(screen.getByRole('button'));
+
+ // Dropdown should not open
+ expect(screen.queryByText('Devnet')).not.toBeInTheDocument();
+ });
+
+ it('trigger button is disabled', () => {
+ render();
+ expect(screen.getByRole('button')).toBeDisabled();
+ });
+ });
+
+ describe('custom networks', () => {
+ it('displays custom networks when provided', () => {
+ const customNetworks = [
+ { id: 'mainnet-beta' as const, label: 'Main Network' },
+ { id: 'devnet' as const, label: 'Development' },
+ ];
+
+ render();
+
+ fireEvent.click(screen.getByRole('button'));
+
+ const listbox = screen.getByRole('listbox');
+ expect(within(listbox).getByText('Main Network')).toBeInTheDocument();
+ expect(within(listbox).getByText('Development')).toBeInTheDocument();
+ // Default labels should not be present in dropdown
+ expect(within(listbox).queryByText('Mainnet')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('semantic token styles', () => {
+ it('applies semantic token classes to trigger', () => {
+ render();
+ const button = screen.getByRole('button');
+ expect(button).toHaveClass('bg-secondary');
+ expect(button).toHaveClass('text-card-foreground');
+ expect(button).toHaveClass('border-border');
+ });
+
+ it('applies semantic token classes to dropdown', () => {
+ render();
+
+ fireEvent.click(screen.getByRole('button'));
+
+ const listbox = screen.getByRole('listbox');
+ expect(listbox).toHaveClass('bg-secondary');
+ });
+ });
+
+ describe('status indicator', () => {
+ it('passes status to dropdown', () => {
+ render();
+
+ fireEvent.click(screen.getByRole('button'));
+
+ // Dropdown should be visible - status is used for styling
+ const listbox = screen.getByRole('listbox');
+ expect(within(listbox).getByText('Mainnet')).toBeInTheDocument();
+ });
+ });
+
+ describe('custom className', () => {
+ it('applies additional className to container', () => {
+ const { container } = render();
+ expect(container.firstChild).toHaveClass('custom-class');
+ });
+ });
+
+ describe('accessibility', () => {
+ it('trigger has button role', () => {
+ render();
+ expect(screen.getByRole('button')).toBeInTheDocument();
+ });
+
+ it('trigger has aria-haspopup attribute', () => {
+ render();
+ expect(screen.getByRole('button')).toHaveAttribute('aria-haspopup');
+ });
+
+ it('trigger has aria-expanded that reflects dropdown state', () => {
+ render();
+
+ const trigger = screen.getByRole('button');
+
+ expect(trigger).toHaveAttribute('aria-expanded', 'false');
+
+ fireEvent.click(trigger);
+
+ expect(trigger).toHaveAttribute('aria-expanded', 'true');
+ });
+ });
+
+ describe('edge cases', () => {
+ it('handles empty networks array gracefully', () => {
+ render();
+
+ fireEvent.click(screen.getByRole('button'));
+
+ // Should open without crashing, even with no options
+ expect(screen.getByRole('button')).toBeInTheDocument();
+ });
+
+ it('handles network selection when onNetworkChange is not provided', () => {
+ render();
+
+ fireEvent.click(screen.getByRole('button'));
+
+ // Should not crash when clicking without handler
+ expect(() => fireEvent.click(screen.getByText('Devnet'))).not.toThrow();
+ });
+ });
+});
diff --git a/packages/components/src/kit-components/ui/network-switcher/NetworkSwitcher.tsx b/packages/components/src/kit-components/ui/network-switcher/NetworkSwitcher.tsx
new file mode 100644
index 0000000..79a7cdc
--- /dev/null
+++ b/packages/components/src/kit-components/ui/network-switcher/NetworkSwitcher.tsx
@@ -0,0 +1,123 @@
+'use client';
+
+import type { ClusterMoniker } from '@solana/client';
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { cn } from '@/lib/utils';
+import { NetworkDropdown } from './NetworkDropdown';
+import { NetworkTrigger } from './NetworkTrigger';
+import type { NetworkSwitcherProps } from './types';
+import { DEFAULT_NETWORKS } from './types';
+
+/**
+ * NetworkSwitcher - A dropdown component for switching between Solana networks.
+ *
+ * @example
+ * ```tsx
+ * // Standalone usage
+ * console.log('Switched to:', network)}
+ * />
+ *
+ * // Controlled open state
+ *
+ * ```
+ */
+export function NetworkSwitcher({
+ selectedNetwork,
+ status = 'connected',
+ onNetworkChange,
+ open: controlledOpen,
+ onOpenChange,
+ networks = DEFAULT_NETWORKS,
+ className,
+ disabled = false,
+}: NetworkSwitcherProps) {
+ // Internal state for uncontrolled mode
+ const [internalOpen, setInternalOpen] = useState(false);
+ const containerRef = useRef(null);
+
+ // Use controlled state if provided, otherwise internal
+ const isOpen = controlledOpen ?? internalOpen;
+
+ const handleOpenChange = useCallback(
+ (newOpen: boolean) => {
+ if (controlledOpen === undefined) {
+ setInternalOpen(newOpen);
+ }
+ onOpenChange?.(newOpen);
+ },
+ [controlledOpen, onOpenChange],
+ );
+
+ const handleToggle = useCallback(() => {
+ if (!disabled) {
+ handleOpenChange(!isOpen);
+ }
+ }, [disabled, isOpen, handleOpenChange]);
+
+ const handleSelect = useCallback(
+ (network: ClusterMoniker) => {
+ onNetworkChange?.(network);
+ handleOpenChange(false);
+ },
+ [onNetworkChange, handleOpenChange],
+ );
+
+ // Close on click outside
+ useEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
+ handleOpenChange(false);
+ }
+ };
+
+ if (isOpen) {
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }
+ }, [isOpen, handleOpenChange]);
+
+ // Close on Escape
+ useEffect(() => {
+ const handleEscape = (event: KeyboardEvent) => {
+ if (event.key === 'Escape' && isOpen) {
+ handleOpenChange(false);
+ }
+ };
+
+ document.addEventListener('keydown', handleEscape);
+ return () => document.removeEventListener('keydown', handleEscape);
+ }, [isOpen, handleOpenChange]);
+
+ return (
+
+ {/* Trigger is always visible */}
+
n.id === selectedNetwork)?.label}
+ status={status}
+ onClick={handleToggle}
+ disabled={disabled}
+ />
+
+ {/* Dropdown appears below trigger when open */}
+ {isOpen && (
+
+
+
+ )}
+
+ );
+}
diff --git a/packages/components/src/kit-components/ui/network-switcher/NetworkTrigger.tsx b/packages/components/src/kit-components/ui/network-switcher/NetworkTrigger.tsx
new file mode 100644
index 0000000..4bb9685
--- /dev/null
+++ b/packages/components/src/kit-components/ui/network-switcher/NetworkTrigger.tsx
@@ -0,0 +1,51 @@
+'use client';
+
+import { ChevronRight, Network } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import { StatusIndicator } from './StatusIndicator';
+import type { NetworkTriggerProps } from './types';
+
+/**
+ * NetworkTrigger - Trigger button showing the selected network with status indicator.
+ */
+export function NetworkTrigger({
+ isOpen = false,
+ selectedLabel,
+ status,
+ onClick,
+ className,
+ disabled = false,
+}: NetworkTriggerProps) {
+ return (
+
+ );
+}
diff --git a/packages/components/src/kit-components/ui/network-switcher/StatusIndicator.tsx b/packages/components/src/kit-components/ui/network-switcher/StatusIndicator.tsx
new file mode 100644
index 0000000..2f32721
--- /dev/null
+++ b/packages/components/src/kit-components/ui/network-switcher/StatusIndicator.tsx
@@ -0,0 +1,36 @@
+'use client';
+
+import { Loader2 } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import type { StatusIndicatorProps } from './types';
+
+/**
+ * StatusIndicator - Shows network connection status as colored dot or spinner.
+ *
+ * - Connected: green dot
+ * - Error: red dot
+ * - Connecting: spinning loader
+ */
+export function StatusIndicator({ status, className }: StatusIndicatorProps) {
+ if (status === 'connecting') {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/packages/components/src/kit-components/ui/network-switcher/index.ts b/packages/components/src/kit-components/ui/network-switcher/index.ts
new file mode 100644
index 0000000..a69576a
--- /dev/null
+++ b/packages/components/src/kit-components/ui/network-switcher/index.ts
@@ -0,0 +1,51 @@
+/**
+ * NetworkSwitcher - A dropdown component for switching between Solana networks.
+ *
+ * @packageDocumentation
+ *
+ * ## Quick Start
+ *
+ * ```tsx
+ * import { NetworkSwitcher } from '@framework-kit/components';
+ *
+ * console.log(network)}
+ * />
+ * ```
+ *
+ * ## Composable Sub-components
+ *
+ * ```tsx
+ * import { NetworkDropdown, NetworkTrigger, NetworkOption } from '@framework-kit/components';
+ *
+ * // Build custom layouts with sub-components
+ *
+ * ```
+ */
+
+// Sub-components
+export { NetworkDropdown } from './NetworkDropdown';
+export { NetworkHeader } from './NetworkHeader';
+export { NetworkOption } from './NetworkOption';
+// Main component
+export { NetworkSwitcher } from './NetworkSwitcher';
+export { NetworkTrigger } from './NetworkTrigger';
+export { StatusIndicator } from './StatusIndicator';
+export type {
+ Network,
+ NetworkDropdownProps,
+ NetworkHeaderProps,
+ NetworkOptionProps,
+ NetworkSwitcherProps,
+ NetworkTriggerProps,
+ StatusIndicatorProps,
+} from './types';
+// Types and constants
+export { DEFAULT_NETWORKS } from './types';
diff --git a/packages/components/src/kit-components/ui/network-switcher/types.ts b/packages/components/src/kit-components/ui/network-switcher/types.ts
new file mode 100644
index 0000000..188cdaa
--- /dev/null
+++ b/packages/components/src/kit-components/ui/network-switcher/types.ts
@@ -0,0 +1,101 @@
+import type { ClusterMoniker, WalletStatus } from '@solana/client';
+
+/** Network configuration */
+export interface Network {
+ /** Unique identifier */
+ id: ClusterMoniker;
+ /** Display name */
+ label: string;
+ /** RPC endpoint URL (optional for custom) */
+ endpoint?: string;
+}
+
+/** Props for NetworkSwitcher main component */
+export interface NetworkSwitcherProps {
+ /** Currently selected network */
+ selectedNetwork: ClusterMoniker;
+ /** Network connection status */
+ status?: WalletStatus['status'];
+ /** Callback when network is changed */
+ onNetworkChange?: (network: ClusterMoniker) => void;
+ /** Whether dropdown is open (controlled) */
+ open?: boolean;
+ /** Callback when open state changes */
+ onOpenChange?: (open: boolean) => void;
+ /** Custom networks to display */
+ networks?: Network[];
+ /** Additional CSS classes */
+ className?: string;
+ /** Disable the switcher */
+ disabled?: boolean;
+}
+
+/** Props for NetworkTrigger component */
+export interface NetworkTriggerProps {
+ /** Whether dropdown is open */
+ isOpen?: boolean;
+ /** Label of the selected network (e.g. "Devnet") */
+ selectedLabel?: string;
+ /** Network connection status */
+ status?: WalletStatus['status'];
+ /** Click handler */
+ onClick?: () => void;
+ /** Additional CSS classes */
+ className?: string;
+ /** Disable the trigger */
+ disabled?: boolean;
+}
+
+/** Props for NetworkDropdown component */
+export interface NetworkDropdownProps {
+ /** Currently selected network */
+ selectedNetwork: ClusterMoniker;
+ /** Network connection status */
+ status?: WalletStatus['status'];
+ /** Networks to display */
+ networks: Network[];
+ /** Callback when network is selected */
+ onSelect?: (network: ClusterMoniker) => void;
+ /** Additional CSS classes */
+ className?: string;
+}
+
+/** Props for NetworkOption component */
+export interface NetworkOptionProps {
+ /** Network data */
+ network: Network;
+ /** Whether this option is selected */
+ isSelected?: boolean;
+ /** Network status (only shown for selected) */
+ status?: WalletStatus['status'];
+ /** Click handler */
+ onClick?: () => void;
+ /** Additional CSS classes */
+ className?: string;
+}
+
+/** Props for NetworkHeader component */
+export interface NetworkHeaderProps {
+ /** Whether dropdown is open */
+ isOpen?: boolean;
+ /** Click handler to toggle */
+ onClick?: () => void;
+ /** Additional CSS classes */
+ className?: string;
+}
+
+/** Props for StatusIndicator component */
+export interface StatusIndicatorProps {
+ /** Connection status */
+ status: WalletStatus['status'];
+ /** Additional CSS classes */
+ className?: string;
+}
+
+/** Default networks */
+export const DEFAULT_NETWORKS: Network[] = [
+ { id: 'mainnet-beta', label: 'Mainnet', endpoint: 'https://api.mainnet-beta.solana.com' },
+ { id: 'testnet', label: 'Testnet', endpoint: 'https://api.testnet.solana.com' },
+ { id: 'localnet', label: 'Localnet', endpoint: 'http://localhost:8899' },
+ { id: 'devnet', label: 'Devnet', endpoint: 'https://api.devnet.solana.com' },
+];
diff --git a/packages/components/src/kit-components/ui/skeleton/Skeleton.test.tsx b/packages/components/src/kit-components/ui/skeleton/Skeleton.test.tsx
new file mode 100644
index 0000000..78918df
--- /dev/null
+++ b/packages/components/src/kit-components/ui/skeleton/Skeleton.test.tsx
@@ -0,0 +1,41 @@
+// @vitest-environment jsdom
+import { cleanup, render, screen } from '@testing-library/react';
+import { afterEach, describe, expect, it } from 'vitest';
+import '@testing-library/jest-dom/vitest';
+
+import { Skeleton } from './Skeleton';
+
+afterEach(() => {
+ cleanup();
+});
+
+// test for basic rendering
+describe('Skeleton', () => {
+ it('renders without crashing', () => {
+ render();
+ expect(screen.getByTestId('skeleton')).toBeInTheDocument();
+ });
+
+ // test for className merging
+ it('applies custom className', () => {
+ render();
+ const element = screen.getByTestId('skeleton');
+ expect(element).toHaveClass('h-4');
+ expect(element).toHaveClass('w-32');
+ });
+
+ //test for props passing through
+ it('passes through additional props', () => {
+ render();
+ const element = screen.getByTestId('skeleton');
+ expect(element).toHaveAttribute('id', 'my-skeleton');
+ expect(element).toHaveAttribute('aria-label', 'Loading content');
+ });
+
+ //test for semantic token styles
+ it('applies bg-muted semantic token', () => {
+ render();
+ const element = screen.getByTestId('skeleton');
+ expect(element).toHaveClass('bg-muted');
+ });
+});
diff --git a/packages/components/src/kit-components/ui/skeleton/Skeleton.tsx b/packages/components/src/kit-components/ui/skeleton/Skeleton.tsx
new file mode 100644
index 0000000..9a5e3bc
--- /dev/null
+++ b/packages/components/src/kit-components/ui/skeleton/Skeleton.tsx
@@ -0,0 +1,8 @@
+import type React from 'react';
+import { cn } from '@/lib/utils';
+
+export interface SkeletonProps extends React.HTMLAttributes {}
+
+export const Skeleton: React.FC = ({ className, ...props }) => {
+ return ;
+};
diff --git a/packages/components/src/kit-components/ui/skeleton/index.ts b/packages/components/src/kit-components/ui/skeleton/index.ts
new file mode 100644
index 0000000..8391a3c
--- /dev/null
+++ b/packages/components/src/kit-components/ui/skeleton/index.ts
@@ -0,0 +1,2 @@
+export type { SkeletonProps } from './Skeleton';
+export { Skeleton } from './Skeleton';
diff --git a/packages/components/src/kit-components/ui/swap-input/SwapInput.tsx b/packages/components/src/kit-components/ui/swap-input/SwapInput.tsx
new file mode 100644
index 0000000..1f36509
--- /dev/null
+++ b/packages/components/src/kit-components/ui/swap-input/SwapInput.tsx
@@ -0,0 +1,104 @@
+import { ArrowDownUp } from 'lucide-react';
+import type React from 'react';
+import { cn } from '@/lib/utils';
+import { SwapInputSkeleton } from './SwapInputSkeleton';
+import { TokenInput } from './TokenInput';
+import type { SwapInputProps } from './types';
+import { isInsufficientBalance } from './utils';
+
+/**
+ * A swap input widget for exchanging tokens on Solana.
+ * Composes two TokenInput cards (Pay and Receive) with a swap direction
+ * button between them. Handles "insufficient balance" validation automatically.
+ *
+ * @example
+ * ```tsx
+ *
+ * ```
+ */
+export const SwapInput: React.FC = ({
+ payAmount,
+ onPayAmountChange,
+ receiveAmount,
+ onReceiveAmountChange,
+ payToken,
+ payTokens,
+ onPayTokenChange,
+ receiveToken,
+ receiveTokens,
+ onReceiveTokenChange,
+ onSwapDirection,
+ payBalance,
+ receiveReadOnly = true,
+ isLoading = false,
+ isSwapping = false,
+ size = 'md',
+ className,
+ disabled = false,
+}) => {
+ if (isLoading) {
+ return ;
+ }
+
+ const insufficientBalance = isInsufficientBalance(payAmount, payBalance);
+ const payError = insufficientBalance ? 'Insufficient balance' : undefined;
+
+ return (
+
+
+ {/* Pay card */}
+
+
+ {/* Swap direction button */}
+
+
+ {/* Receive card */}
+
+
+
+ );
+};
diff --git a/packages/components/src/kit-components/ui/swap-input/SwapInputSkeleton.tsx b/packages/components/src/kit-components/ui/swap-input/SwapInputSkeleton.tsx
new file mode 100644
index 0000000..b350208
--- /dev/null
+++ b/packages/components/src/kit-components/ui/swap-input/SwapInputSkeleton.tsx
@@ -0,0 +1,35 @@
+import type React from 'react';
+import { cn } from '@/lib/utils';
+import { Skeleton } from '../skeleton/Skeleton';
+import type { SwapInputSkeletonProps } from './types';
+
+/**
+ * Loading skeleton for the SwapInput component.
+ * Renders two placeholder cards with a swap button between them.
+ */
+export const SwapInputSkeleton: React.FC = ({ size = 'md', className }) => {
+ const paddingStyles = { sm: 'p-3', md: 'p-4', lg: 'p-5' }[size];
+
+ const renderCard = () => (
+
+ );
+
+ return (
+
+ );
+};
diff --git a/packages/components/src/kit-components/ui/swap-input/TokenInput.tsx b/packages/components/src/kit-components/ui/swap-input/TokenInput.tsx
new file mode 100644
index 0000000..c495f4f
--- /dev/null
+++ b/packages/components/src/kit-components/ui/swap-input/TokenInput.tsx
@@ -0,0 +1,265 @@
+import { ChevronDown } from 'lucide-react';
+import type React from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { cn } from '@/lib/utils';
+import { Skeleton } from '../skeleton/Skeleton';
+import type { SwapTokenInfo, TokenInputProps } from './types';
+import { sanitizeAmountInput } from './utils';
+
+function toCssSize(size: NonNullable) {
+ switch (size) {
+ case 'sm':
+ return { padding: 'p-3', inputText: 'text-xl', tokenIcon: 'size-5' };
+ case 'lg':
+ return { padding: 'p-5', inputText: 'text-3xl', tokenIcon: 'size-7' };
+ default:
+ return { padding: 'p-4', inputText: 'text-2xl', tokenIcon: 'size-6' };
+ }
+}
+
+function TokenLogo({ token, className }: { token: SwapTokenInfo; className?: string }) {
+ return token.logoURI ? (
+
+ ) : (
+
+ );
+}
+
+/**
+ * A single token input card for either the "Pay" or "Receive" side of a swap,
+ * or as a standalone input for send/stake/deposit flows.
+ *
+ * When a `tokens` list is provided, clicking the token button opens an inline
+ * dropdown to pick a different token. Without `tokens` the button is display-only.
+ *
+ * @example
+ * ```tsx
+ *
+ * ```
+ */
+export const TokenInput: React.FC = ({
+ label,
+ amount,
+ onAmountChange,
+ token,
+ tokens,
+ onTokenChange,
+ balance,
+ readOnly = false,
+ isLoading = false,
+ error,
+ size = 'md',
+ className,
+ placeholder = '0.00',
+}) => {
+ const css = toCssSize(size);
+
+ const [isOpen, setIsOpen] = useState(false);
+ const dropdownRef = useRef(null);
+
+ const hasDropdown = tokens && tokens.length > 0 && onTokenChange;
+
+ // Close dropdown on click outside
+ useEffect(() => {
+ if (!isOpen) return;
+ function handleClickOutside(e: MouseEvent) {
+ if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
+ setIsOpen(false);
+ }
+ }
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }, [isOpen]);
+
+ const handleAmountChange = useCallback(
+ (e: React.ChangeEvent) => {
+ const sanitized = sanitizeAmountInput(e.target.value);
+ if (sanitized !== null && onAmountChange) {
+ onAmountChange(sanitized);
+ }
+ },
+ [onAmountChange],
+ );
+
+ const handleTokenSelect = useCallback(
+ (t: SwapTokenInfo) => {
+ onTokenChange?.(t);
+ setIsOpen(false);
+ },
+ [onTokenChange],
+ );
+
+ const cardStyles = cn('rounded-xl border', 'bg-card border-border', css.padding, className);
+
+ return (
+
+ {/* Label */}
+
{label}
+
+ {/* Input + token selector row */}
+
+ {/* Amount input or skeleton */}
+ {isLoading ? (
+
+ ) : (
+
+ )}
+
+ {/* Token selector button + dropdown */}
+ {isLoading ? (
+
+ ) : (
+
+ {token ? (
+
+ ) : (
+
+ )}
+
+ {/* Dropdown */}
+ {isOpen && hasDropdown && (
+
+ {tokens.map((t) => {
+ const isSelected =
+ token?.symbol === t.symbol && token?.mintAddress === t.mintAddress;
+ return (
+
+ );
+ })}
+
+ )}
+
+ )}
+
+
+ {/* Balance + error row */}
+ {(balance !== undefined || error) && (
+
+ {balance !== undefined ? (
+
+ Balance
+ {balance}
+
+ ) : (
+
+ )}
+
+ {error && (
+
+ {error}
+
+ )}
+
+ )}
+
+ );
+};
diff --git a/packages/components/src/kit-components/ui/swap-input/index.ts b/packages/components/src/kit-components/ui/swap-input/index.ts
new file mode 100644
index 0000000..6c3deea
--- /dev/null
+++ b/packages/components/src/kit-components/ui/swap-input/index.ts
@@ -0,0 +1,18 @@
+// Main component
+export { SwapInput } from './SwapInput';
+
+// Sub-components
+export { SwapInputSkeleton } from './SwapInputSkeleton';
+export { TokenInput } from './TokenInput';
+
+// Types
+export type {
+ SwapInputProps,
+ SwapInputSize,
+ SwapInputSkeletonProps,
+ SwapTokenInfo,
+ TokenInputProps,
+} from './types';
+
+// Utilities
+export { isInsufficientBalance, sanitizeAmountInput } from './utils';
diff --git a/packages/components/src/kit-components/ui/swap-input/types.ts b/packages/components/src/kit-components/ui/swap-input/types.ts
new file mode 100644
index 0000000..5e1e869
--- /dev/null
+++ b/packages/components/src/kit-components/ui/swap-input/types.ts
@@ -0,0 +1,107 @@
+import type { Address } from '@solana/kit';
+
+/**
+ * Size variant for the SwapInput component.
+ */
+export type SwapInputSize = 'sm' | 'md' | 'lg';
+
+/**
+ * Token information for display in the swap token selector
+ */
+export interface SwapTokenInfo {
+ /** Token symbol (e.g., "USDC", "SOL") */
+ symbol: string;
+ /** Token name (e.g., "USD Coin", "Solana") */
+ name?: string;
+ /** Token logo URL (e.g., from token registry or tx-indexer logoURI) */
+ logoURI?: string;
+ /** Token mint address */
+ mintAddress?: Address;
+ /** Number of decimals for the token (e.g., 9 for SOL, 6 for USDC) */
+ decimals?: number;
+}
+
+/**
+ * Props for the TokenInput sub-component (one input card — Pay or Receive).
+ * Can be used standalone (e.g. send / stake / deposit flows) or composed inside SwapInput.
+ */
+export interface TokenInputProps {
+ /** Label displayed above the input (e.g., "Pay", "Receive") */
+ label: string;
+ /** Current amount value (string to support controlled input) */
+ amount: string;
+ /** Callback when the amount changes */
+ onAmountChange?: (value: string) => void;
+ /** Currently selected token */
+ token?: SwapTokenInfo;
+ /** List of tokens available in the dropdown. If omitted or empty the selector is display-only. */
+ tokens?: SwapTokenInfo[];
+ /** Callback when a token is selected from the dropdown */
+ onTokenChange?: (token: SwapTokenInfo) => void;
+ /** User's balance for the selected token (display string, e.g. "4.32") */
+ balance?: string;
+ /** Whether the input is read-only (for the "Receive" side when computed) */
+ readOnly?: boolean;
+ /** Whether the component is in loading/skeleton state */
+ isLoading?: boolean;
+ /** Error message to display (e.g., "Insufficient balance") */
+ error?: string;
+ /** Size variant */
+ size?: SwapInputSize;
+ /** Additional CSS classes */
+ className?: string;
+ /** Placeholder text for the input (default: "0.00") */
+ placeholder?: string;
+}
+
+/**
+ * Props for the main SwapInput composite component
+ */
+export interface SwapInputProps {
+ /** Amount the user wants to pay (controlled) */
+ payAmount: string;
+ /** Callback when pay amount changes */
+ onPayAmountChange?: (value: string) => void;
+ /** Amount the user will receive (controlled, typically computed externally) */
+ receiveAmount: string;
+ /** Callback when receive amount changes (if editable) */
+ onReceiveAmountChange?: (value: string) => void;
+ /** Token selected for the "Pay" side */
+ payToken?: SwapTokenInfo;
+ /** Available tokens for the pay dropdown */
+ payTokens?: SwapTokenInfo[];
+ /** Callback when the pay token is changed via dropdown */
+ onPayTokenChange?: (token: SwapTokenInfo) => void;
+ /** Token selected for the "Receive" side */
+ receiveToken?: SwapTokenInfo;
+ /** Available tokens for the receive dropdown */
+ receiveTokens?: SwapTokenInfo[];
+ /** Callback when the receive token is changed via dropdown */
+ onReceiveTokenChange?: (token: SwapTokenInfo) => void;
+ /** Callback when the swap direction button is pressed (swaps pay/receive tokens) */
+ onSwapDirection?: () => void;
+ /** User's balance for the pay token (display string, e.g. "4.32") */
+ payBalance?: string;
+ /** Whether the receive input is read-only (default: true) */
+ receiveReadOnly?: boolean;
+ /** Whether the component is in a loading state */
+ isLoading?: boolean;
+ /** Whether the swap is currently executing */
+ isSwapping?: boolean;
+ /** Size variant */
+ size?: SwapInputSize;
+ /** Additional CSS classes for the outer wrapper */
+ className?: string;
+ /** Disable all interactions */
+ disabled?: boolean;
+}
+
+/**
+ * Props for the SwapInputSkeleton component
+ */
+export interface SwapInputSkeletonProps {
+ /** Size variant */
+ size?: SwapInputSize;
+ /** Additional CSS classes */
+ className?: string;
+}
diff --git a/packages/components/src/kit-components/ui/swap-input/utils.ts b/packages/components/src/kit-components/ui/swap-input/utils.ts
new file mode 100644
index 0000000..221b146
--- /dev/null
+++ b/packages/components/src/kit-components/ui/swap-input/utils.ts
@@ -0,0 +1,39 @@
+/**
+ * Validates and sanitizes a numeric input string for token amounts.
+ * Allows only digits and a single decimal point.
+ * Returns the sanitized string, or null if the input is completely invalid.
+ *
+ * @param value - Raw input string from the user
+ * @returns Sanitized numeric string, or null
+ */
+export function sanitizeAmountInput(value: string): string | null {
+ if (value === '') return '';
+
+ const pattern = /^\d*\.?\d*$/;
+ if (!pattern.test(value)) return null;
+
+ // Don't allow multiple leading zeros like "007" but allow "0.07"
+ if (value.length > 1 && value[0] === '0' && value[1] !== '.') {
+ return value.slice(1);
+ }
+
+ return value;
+}
+
+/**
+ * Checks whether the pay amount exceeds the available balance.
+ *
+ * @param payAmount - The amount the user wants to pay (as a string)
+ * @param balance - The user's token balance (as a string)
+ * @returns true if payAmount exceeds balance
+ */
+export function isInsufficientBalance(payAmount: string, balance: string | undefined): boolean {
+ if (!balance || !payAmount) return false;
+
+ const pay = Number.parseFloat(payAmount);
+ const bal = Number.parseFloat(balance);
+
+ if (Number.isNaN(pay) || Number.isNaN(bal)) return false;
+
+ return pay > bal;
+}
diff --git a/packages/components/src/kit-components/ui/transaction-table/FilterDropdown.tsx b/packages/components/src/kit-components/ui/transaction-table/FilterDropdown.tsx
new file mode 100644
index 0000000..9831978
--- /dev/null
+++ b/packages/components/src/kit-components/ui/transaction-table/FilterDropdown.tsx
@@ -0,0 +1,111 @@
+import { ChevronDown } from 'lucide-react';
+import { useEffect, useId, useMemo, useRef, useState } from 'react';
+import { cn } from '@/lib/utils';
+import type { FilterDropdownProps } from './types';
+
+/**
+ * A dropdown filter control for selecting a single option from a list.
+ * Used internally by TransactionTable for date and type filters,
+ * but can be used standalone for custom filter UIs.
+ *
+ * @example
+ * ```tsx
+ * }
+ * value={dateFilter}
+ * options={[
+ * { value: 'all', label: 'All time' },
+ * { value: '7d', label: 'Last 7 days' },
+ * ]}
+ * onChange={setDateFilter}
+ * />
+ * ```
+ */
+export function FilterDropdown({
+ icon,
+ value,
+ options,
+ onChange,
+ className,
+}: FilterDropdownProps) {
+ const [open, setOpen] = useState(false);
+ const id = useId();
+ const rootRef = useRef(null);
+
+ const selectedLabel = useMemo(() => options.find((o) => o.value === value)?.label ?? value, [options, value]);
+
+ useEffect(() => {
+ if (!open) return;
+ const handlePointerDown = (event: PointerEvent) => {
+ const el = rootRef.current;
+ if (!el) return;
+ if (event.target instanceof Node && !el.contains(event.target)) {
+ setOpen(false);
+ }
+ };
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') {
+ setOpen(false);
+ }
+ };
+ document.addEventListener('pointerdown', handlePointerDown);
+ document.addEventListener('keydown', handleKeyDown);
+ return () => {
+ document.removeEventListener('pointerdown', handlePointerDown);
+ document.removeEventListener('keydown', handleKeyDown);
+ };
+ }, [open]);
+
+ const triggerStyles = cn(
+ 'inline-flex items-center gap-1.5 rounded-md border border-border bg-muted text-foreground px-2 py-1.5 text-xs transition-colors',
+ 'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background',
+ );
+
+ const menuStyles = cn(
+ 'absolute right-0 top-full z-50 mt-2 min-w-36 rounded-md border border-border bg-card text-card-foreground p-1 shadow-lg',
+ );
+
+ return (
+
+
+
+ {open ? (
+
+ {options.map((option) => {
+ const selected = option.value === value;
+ return (
+
+ );
+ })}
+
+ ) : null}
+
+ );
+}
diff --git a/packages/components/src/kit-components/ui/transaction-table/TransactionRow.tsx b/packages/components/src/kit-components/ui/transaction-table/TransactionRow.tsx
new file mode 100644
index 0000000..8e8b958
--- /dev/null
+++ b/packages/components/src/kit-components/ui/transaction-table/TransactionRow.tsx
@@ -0,0 +1,182 @@
+import { Coins, ExternalLink, HelpCircle } from 'lucide-react';
+import type React from 'react';
+import { useState } from 'react';
+import { cn, truncateAddress } from '@/lib/utils';
+import receiveIcon from './assets/receive.png';
+import sentIcon from './assets/sent.png';
+import type { TransactionRowProps } from './types';
+import {
+ formatFiatAmount,
+ formatTokenAmount,
+ formatTxDate,
+ getBlockTimeSeconds,
+ getCounterpartyAddress,
+ getPrimaryAmount,
+ getTransactionDirection,
+} from './utils';
+
+function toCssSize(size: NonNullable) {
+ switch (size) {
+ case 'sm':
+ return {
+ cell: 'py-2 px-4',
+ text: 'text-xs',
+ muted: 'text-xs',
+ typeIcon: 'size-4',
+ tokenIcon: 'size-5',
+ };
+ case 'lg':
+ return {
+ cell: 'py-3.5 px-5',
+ text: 'text-sm',
+ muted: 'text-xs',
+ typeIcon: 'size-5',
+ tokenIcon: 'size-7',
+ };
+ default:
+ return {
+ cell: 'py-2.5 px-4',
+ text: 'text-sm',
+ muted: 'text-xs',
+ typeIcon: 'size-4',
+ tokenIcon: 'size-6',
+ };
+ }
+}
+
+function TokenIcon({ src, alt, className }: { src: string; alt: string; className: string }) {
+ const [failed, setFailed] = useState(false);
+ if (failed) {
+ return (
+
+
+
+ );
+ }
+ return
setFailed(true)} />;
+}
+
+/**
+ * A single row within the TransactionTable, displaying the transaction type,
+ * timestamp, counterparty address, token amount, and an optional action button.
+ *
+ * @example
+ * ```tsx
+ * openExplorer(tx.tx.signature)}
+ * />
+ * ```
+ */
+export const TransactionRow: React.FC = ({
+ tx,
+ walletAddress,
+ size = 'md',
+ locale = 'en-US',
+ onViewTransaction,
+ renderRowAction,
+ className,
+}) => {
+ const css = toCssSize(size);
+
+ const direction = getTransactionDirection(tx, walletAddress);
+ const typeLabel = direction === 'sent' ? 'Sent' : direction === 'received' ? 'Received' : 'Other';
+
+ const counterparty = getCounterpartyAddress(tx, walletAddress);
+ const displayAddress = counterparty ? truncateAddress(counterparty) : '—';
+
+ const primaryAmount = getPrimaryAmount(tx);
+ const tokenSymbol = primaryAmount?.token?.symbol ?? '';
+ const tokenLogoUrl = primaryAmount?.token?.logoURI ?? undefined;
+ const amountText = primaryAmount?.amountUi !== undefined ? formatTokenAmount(primaryAmount.amountUi, locale) : '—';
+
+ const fiatCurrency = primaryAmount?.fiat?.currency ?? 'USD';
+ const fiatText =
+ primaryAmount?.fiat?.amount !== undefined
+ ? formatFiatAmount(primaryAmount.fiat.amount, fiatCurrency, locale)
+ : null;
+
+ const timeText = formatTxDate(getBlockTimeSeconds(tx), locale);
+
+ const rowStyles = cn(
+ 'group grid grid-cols-4 items-center gap-6 border-b border-border transition-colors hover:bg-accent',
+ css.cell,
+ className,
+ );
+
+ return (
+
+ {/* Type */}
+
+ {direction === 'sent' || direction === 'received' ? (
+

+ ) : (
+
+ )}
+
{typeLabel}
+
+
+ {/* Time */}
+
+ {timeText}
+
+
+ {/* Address */}
+
+ {displayAddress}
+
+
+ {/* Amount */}
+
+
+ {tokenLogoUrl ? (
+
+ ) : (
+
+
+
+ )}
+
+
+ {amountText}
+ {tokenSymbol ? ` ${tokenSymbol}` : ''}
+
+ {fiatText ? (
+
{fiatText}
+ ) : null}
+
+
+ {renderRowAction ? (
+
+ {renderRowAction(tx)}
+
+ ) : onViewTransaction ? (
+
+ ) : null}
+
+
+ );
+};
diff --git a/packages/components/src/kit-components/ui/transaction-table/TransactionTable.tsx b/packages/components/src/kit-components/ui/transaction-table/TransactionTable.tsx
new file mode 100644
index 0000000..cc18c8d
--- /dev/null
+++ b/packages/components/src/kit-components/ui/transaction-table/TransactionTable.tsx
@@ -0,0 +1,174 @@
+import { Calendar, ListFilter } from 'lucide-react';
+import type React from 'react';
+import { useMemo, useState } from 'react';
+import { cn } from '@/lib/utils';
+import { FilterDropdown } from './FilterDropdown';
+import { TransactionRow } from './TransactionRow';
+import { TransactionTableSkeleton } from './TransactionTableSkeleton';
+import type {
+ FilterDropdownOption,
+ TransactionDateFilter,
+ TransactionTableProps,
+ TransactionTypeFilter,
+} from './types';
+import { getBlockTimeSeconds, getTransactionDirection } from './utils';
+
+const DATE_OPTIONS: ReadonlyArray> = [
+ { value: 'all', label: 'All time' },
+ { value: '7d', label: 'Last 7 days' },
+ { value: '30d', label: 'Last 30 days' },
+ { value: '90d', label: 'Last 90 days' },
+];
+
+const TYPE_OPTIONS: ReadonlyArray> = [
+ { value: 'all', label: 'All' },
+ { value: 'sent', label: 'Sent' },
+ { value: 'received', label: 'Received' },
+];
+
+function daysToSeconds(days: number): number {
+ return days * 24 * 60 * 60;
+}
+
+function dateFilterToRangeSeconds(filter: TransactionDateFilter): number | null {
+ switch (filter) {
+ case '7d':
+ return daysToSeconds(7);
+ case '30d':
+ return daysToSeconds(30);
+ case '90d':
+ return daysToSeconds(90);
+ default:
+ return null;
+ }
+}
+
+/**
+ * A transaction table component for displaying classified Solana transactions
+ * with built-in date and type filtering, loading states, and theme support.
+ *
+ * @example
+ * ```tsx
+ * // Basic usage
+ *
+ *
+ * // With controlled filters and view callback
+ * openExplorer(tx.tx.signature)}
+ * />
+ * ```
+ */
+export const TransactionTable: React.FC = ({
+ transactions,
+ walletAddress,
+ isLoading = false,
+ size = 'md',
+ dateFilter: dateFilterProp,
+ onDateFilterChange,
+ dateFilterOptions = DATE_OPTIONS,
+ typeFilter: typeFilterProp,
+ onTypeFilterChange,
+ typeFilterOptions = TYPE_OPTIONS,
+ emptyMessage = 'No transactions yet',
+ className,
+ locale = 'en-US',
+ onViewTransaction,
+ renderRowAction,
+}) => {
+ const [internalDate, setInternalDate] = useState('all');
+ const [internalType, setInternalType] = useState('all');
+
+ const dateFilter = dateFilterProp ?? internalDate;
+ const typeFilter = typeFilterProp ?? internalType;
+
+ const handleDateChange = (value: TransactionDateFilter) => {
+ if (dateFilterProp === undefined) setInternalDate(value);
+ onDateFilterChange?.(value);
+ };
+
+ const handleTypeChange = (value: TransactionTypeFilter) => {
+ if (typeFilterProp === undefined) setInternalType(value);
+ onTypeFilterChange?.(value);
+ };
+
+ const filteredTransactions = useMemo(() => {
+ let txs = [...transactions];
+
+ if (typeFilter !== 'all') {
+ txs = txs.filter((tx) => getTransactionDirection(tx, walletAddress) === typeFilter);
+ }
+
+ const rangeSeconds = dateFilterToRangeSeconds(dateFilter);
+ if (rangeSeconds !== null) {
+ const nowSeconds = Math.floor(Date.now() / 1000);
+ const minSeconds = nowSeconds - rangeSeconds;
+ txs = txs.filter((tx) => {
+ const blockTime = getBlockTimeSeconds(tx);
+ return blockTime ? blockTime >= minSeconds : false;
+ });
+ }
+
+ return txs;
+ }, [transactions, typeFilter, walletAddress, dateFilter]);
+
+ const containerStyles = cn('rounded-2xl border border-border bg-card', className);
+
+ const headerRowStyles = cn(
+ 'grid grid-cols-4 items-center gap-6 border-b border-border px-4 py-2 text-xs font-normal text-muted-foreground',
+ );
+
+ return (
+
+
+ }
+ value={dateFilter}
+ options={dateFilterOptions}
+ onChange={handleDateChange}
+ />
+ }
+ value={typeFilter}
+ options={typeFilterOptions}
+ onChange={handleTypeChange}
+ />
+
+
+
+
Type
+
Time
+
Address
+
Amount
+
+
+ {isLoading ? (
+
+ ) : filteredTransactions.length === 0 ? (
+ {emptyMessage}
+ ) : (
+
+ {filteredTransactions.map((tx) => (
+
+ ))}
+
+ )}
+
+ );
+};
diff --git a/packages/components/src/kit-components/ui/transaction-table/TransactionTableSkeleton.tsx b/packages/components/src/kit-components/ui/transaction-table/TransactionTableSkeleton.tsx
new file mode 100644
index 0000000..63fc639
--- /dev/null
+++ b/packages/components/src/kit-components/ui/transaction-table/TransactionTableSkeleton.tsx
@@ -0,0 +1,95 @@
+import type React from 'react';
+import { useRef } from 'react';
+import { cn } from '@/lib/utils';
+import { Skeleton } from '../skeleton/Skeleton';
+import type { TransactionTableSkeletonProps } from './types';
+
+function toCssSize(size: NonNullable) {
+ switch (size) {
+ case 'sm':
+ return {
+ row: 'py-2 px-4',
+ icon: 'size-4',
+ token: 'size-5',
+ label: 'h-3 w-16',
+ time: 'h-3 w-24',
+ address: 'h-3 w-28',
+ amountMain: 'h-3 w-16',
+ amountFiat: 'h-2.5 w-12',
+ };
+ case 'lg':
+ return {
+ row: 'py-3.5 px-5',
+ icon: 'size-5',
+ token: 'size-7',
+ label: 'h-5 w-24',
+ time: 'h-5 w-32',
+ address: 'h-5 w-36',
+ amountMain: 'h-4 w-24',
+ amountFiat: 'h-3.5 w-16',
+ };
+ default:
+ return {
+ row: 'py-2.5 px-4',
+ icon: 'size-4',
+ token: 'size-6',
+ label: 'h-4 w-20',
+ time: 'h-4 w-28',
+ address: 'h-4 w-32',
+ amountMain: 'h-3.5 w-20',
+ amountFiat: 'h-3 w-14',
+ };
+ }
+}
+
+/**
+ * Skeleton loading state for the TransactionTable component.
+ *
+ * @example
+ * ```tsx
+ *
+ * ```
+ */
+export const TransactionTableSkeleton: React.FC = ({
+ size = 'md',
+ rowCount = 4,
+ className,
+}) => {
+ const css = toCssSize(size);
+ const keysRef = useRef([]);
+ if (keysRef.current.length !== rowCount) {
+ const next: string[] = [];
+ for (let i = 0; i < rowCount; i++) {
+ next.push(
+ keysRef.current[i] ?? globalThis.crypto?.randomUUID?.() ?? `row-${Math.random().toString(36).slice(2)}`,
+ );
+ }
+ keysRef.current = next;
+ }
+
+ return (
+
+ );
+};
diff --git a/packages/components/src/kit-components/ui/transaction-table/assets/receive.png b/packages/components/src/kit-components/ui/transaction-table/assets/receive.png
new file mode 100644
index 0000000..ecf6f0b
Binary files /dev/null and b/packages/components/src/kit-components/ui/transaction-table/assets/receive.png differ
diff --git a/packages/components/src/kit-components/ui/transaction-table/assets/sent.png b/packages/components/src/kit-components/ui/transaction-table/assets/sent.png
new file mode 100644
index 0000000..6a8f3e4
Binary files /dev/null and b/packages/components/src/kit-components/ui/transaction-table/assets/sent.png differ
diff --git a/packages/components/src/kit-components/ui/transaction-table/assets/view-dark.png b/packages/components/src/kit-components/ui/transaction-table/assets/view-dark.png
new file mode 100644
index 0000000..b0571be
Binary files /dev/null and b/packages/components/src/kit-components/ui/transaction-table/assets/view-dark.png differ
diff --git a/packages/components/src/kit-components/ui/transaction-table/assets/view-light.png b/packages/components/src/kit-components/ui/transaction-table/assets/view-light.png
new file mode 100644
index 0000000..2dc5ef7
Binary files /dev/null and b/packages/components/src/kit-components/ui/transaction-table/assets/view-light.png differ
diff --git a/packages/components/src/kit-components/ui/transaction-table/index.ts b/packages/components/src/kit-components/ui/transaction-table/index.ts
new file mode 100644
index 0000000..68beea1
--- /dev/null
+++ b/packages/components/src/kit-components/ui/transaction-table/index.ts
@@ -0,0 +1,25 @@
+export { FilterDropdown } from './FilterDropdown';
+export { TransactionRow } from './TransactionRow';
+export { TransactionTable } from './TransactionTable';
+export { TransactionTableSkeleton } from './TransactionTableSkeleton';
+export type {
+ FilterDropdownOption,
+ FilterDropdownProps,
+ TransactionDateFilter,
+ TransactionRowProps,
+ TransactionTableProps,
+ TransactionTableSize,
+ TransactionTableSkeletonProps,
+ TransactionTypeFilter,
+} from './types';
+
+export type { DerivedDirection, PrimaryAmount } from './utils';
+export {
+ formatFiatAmount,
+ formatTokenAmount,
+ formatTxDate,
+ getBlockTimeSeconds,
+ getCounterpartyAddress,
+ getPrimaryAmount,
+ getTransactionDirection,
+} from './utils';
diff --git a/packages/components/src/kit-components/ui/transaction-table/types.ts b/packages/components/src/kit-components/ui/transaction-table/types.ts
new file mode 100644
index 0000000..42f4cb4
--- /dev/null
+++ b/packages/components/src/kit-components/ui/transaction-table/types.ts
@@ -0,0 +1,72 @@
+import type { Address } from '@solana/kit';
+import type React from 'react';
+import type { ClassifiedTransaction } from 'tx-indexer';
+
+export type TransactionTableSize = 'sm' | 'md' | 'lg';
+
+export type TransactionTypeFilter = 'all' | 'sent' | 'received';
+export type TransactionDateFilter = 'all' | '7d' | '30d' | '90d';
+
+export interface TransactionTableProps {
+ /** Classified transactions (typically from useClassifiedTransactions). */
+ transactions: ReadonlyArray;
+ /** Wallet address used to classify direction (sent vs received). */
+ walletAddress?: Address;
+ /** Loading state. */
+ isLoading?: boolean;
+ /** Density/size. */
+ size?: TransactionTableSize;
+ /** Controlled date filter. */
+ dateFilter?: TransactionDateFilter;
+ /** Called when date filter changes. */
+ onDateFilterChange?: (value: TransactionDateFilter) => void;
+ /** Custom date filter options. Defaults to All time / 7d / 30d / 90d. */
+ dateFilterOptions?: ReadonlyArray>;
+ /** Controlled type filter. */
+ typeFilter?: TransactionTypeFilter;
+ /** Called when type filter changes. */
+ onTypeFilterChange?: (value: TransactionTypeFilter) => void;
+ /** Custom type filter options. Defaults to All / Sent / Received. */
+ typeFilterOptions?: ReadonlyArray>;
+ /** Message shown when no transactions are available. */
+ emptyMessage?: string;
+ /** Additional classes for the container. */
+ className?: string;
+ /** Locale for formatting (default: en-US). */
+ locale?: string;
+ /** Called when the view icon is clicked on a row. Shows a view icon on each row when provided. */
+ onViewTransaction?: (tx: ClassifiedTransaction) => void;
+ /** Optional custom row action renderer. Overrides the default view icon when provided. */
+ renderRowAction?: (tx: ClassifiedTransaction) => React.ReactNode;
+}
+
+export interface TransactionRowProps {
+ tx: ClassifiedTransaction;
+ walletAddress?: Address;
+ size?: TransactionTableSize;
+ locale?: string;
+ onViewTransaction?: (tx: ClassifiedTransaction) => void;
+ renderRowAction?: (tx: ClassifiedTransaction) => React.ReactNode;
+ /** Additional CSS classes for the row container. */
+ className?: string;
+}
+
+export interface TransactionTableSkeletonProps {
+ size?: TransactionTableSize;
+ rowCount?: number;
+ className?: string;
+}
+
+export interface FilterDropdownOption {
+ value: TValue;
+ label: string;
+}
+
+export interface FilterDropdownProps {
+ icon: React.ReactNode;
+ value: TValue;
+ options: ReadonlyArray>;
+ onChange: (value: TValue) => void;
+ /** Additional CSS classes for the dropdown root. */
+ className?: string;
+}
diff --git a/packages/components/src/kit-components/ui/transaction-table/utils.ts b/packages/components/src/kit-components/ui/transaction-table/utils.ts
new file mode 100644
index 0000000..0850615
--- /dev/null
+++ b/packages/components/src/kit-components/ui/transaction-table/utils.ts
@@ -0,0 +1,78 @@
+import type { Address } from '@solana/kit';
+import type { ClassifiedTransaction } from 'tx-indexer';
+
+export type DerivedDirection = 'sent' | 'received' | 'other';
+
+function asNumber(value: number | bigint | null | undefined): number | null {
+ if (value === null || value === undefined) return null;
+ return typeof value === 'bigint' ? Number(value) : value;
+}
+
+export function getTransactionDirection(tx: ClassifiedTransaction, walletAddress?: Address): DerivedDirection {
+ if (!walletAddress) return 'other';
+
+ // Prefer legs if present because it captures more cases (swaps, protocol interactions, etc.)
+ const legs = tx.legs;
+ if (Array.isArray(legs)) {
+ const walletLeg = legs.find((leg) => leg.accountId === walletAddress && leg.role !== 'fee');
+ if (walletLeg) {
+ return walletLeg.side === 'debit' ? 'sent' : 'received';
+ }
+ }
+
+ const sender = tx.classification.sender;
+ const receiver = tx.classification.receiver;
+
+ if (sender && sender === walletAddress) return 'sent';
+ if (receiver && receiver === walletAddress) return 'received';
+ return 'other';
+}
+
+export function getCounterpartyAddress(tx: ClassifiedTransaction, walletAddress?: Address): string | null {
+ if (tx.classification.counterparty?.address) return tx.classification.counterparty.address;
+
+ const direction = getTransactionDirection(tx, walletAddress);
+ const sender = tx.classification.sender;
+ const receiver = tx.classification.receiver;
+
+ if (direction === 'sent') return receiver ?? null;
+ if (direction === 'received') return sender ?? null;
+ return receiver ?? sender ?? null;
+}
+
+export type PrimaryAmount = NonNullable;
+
+export function getPrimaryAmount(tx: ClassifiedTransaction): PrimaryAmount | null {
+ return tx.classification.primaryAmount ?? null;
+}
+
+export function getBlockTimeSeconds(tx: ClassifiedTransaction): number | null {
+ const blockTime = tx.tx.blockTime;
+ return asNumber(blockTime);
+}
+
+export function formatTxDate(blockTimeSeconds: number | null, locale: string): string {
+ if (!blockTimeSeconds) return '—';
+ const date = new Date(blockTimeSeconds * 1000);
+ return new Intl.DateTimeFormat(locale, {
+ month: 'short',
+ day: '2-digit',
+ year: 'numeric',
+ }).format(date);
+}
+
+export function formatTokenAmount(amountUi: number, locale: string): string {
+ return new Intl.NumberFormat(locale, {
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ }).format(amountUi);
+}
+
+export function formatFiatAmount(amount: number, currency: string, locale: string): string {
+ return new Intl.NumberFormat(locale, {
+ style: 'currency',
+ currency,
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ }).format(amount);
+}
diff --git a/packages/components/src/kit-components/ui/transaction-toast/TransactionToast.test.tsx b/packages/components/src/kit-components/ui/transaction-toast/TransactionToast.test.tsx
new file mode 100644
index 0000000..4da3930
--- /dev/null
+++ b/packages/components/src/kit-components/ui/transaction-toast/TransactionToast.test.tsx
@@ -0,0 +1,342 @@
+// @vitest-environment jsdom
+import { act, cleanup, render, renderHook, screen } from '@testing-library/react';
+import type { ReactNode } from 'react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import '@testing-library/jest-dom/vitest';
+
+import { TransactionToast } from './TransactionToast';
+import { TransactionToastProvider } from './TransactionToastProvider';
+import { useTransactionToast } from './useTransactionToast';
+
+const mockSignature = '5UfDuX7hXrVoNMYhFpFdYxGE8mLqZnzCYQEHZ8Bj9K8xN2FvYYv5VT7qYRqXLwGKSk3nYhZx';
+
+afterEach(() => {
+ cleanup();
+});
+
+describe('TransactionToast', () => {
+ // for basic rendering
+ it('renders without crashing', () => {
+ render();
+ expect(screen.getByText('Transaction sent successfully')).toBeInTheDocument();
+ });
+
+ // for status icons
+ describe('status icons', () => {
+ it('renders spinning loader for pending status', () => {
+ const { container } = render();
+ const loader = container.querySelector('.animate-spin');
+ expect(loader).toBeInTheDocument();
+ });
+
+ it('renders check icon for success status', () => {
+ const { container } = render();
+ const successIcon = container.querySelector('.bg-success\\/20');
+ expect(successIcon).toBeInTheDocument();
+ });
+
+ it('renders X icon for error status', () => {
+ const { container } = render();
+ const errorIcon = container.querySelector('.bg-destructive\\/20');
+ expect(errorIcon).toBeInTheDocument();
+ });
+ });
+
+ // for message combinations (3 statuses × 3 types = 9)
+ describe('message combinations', () => {
+ // sent type
+ it('shows correct message for sent + pending', () => {
+ render();
+ expect(screen.getByText('Transaction pending...')).toBeInTheDocument();
+ });
+
+ it('shows correct message for sent + success', () => {
+ render();
+ expect(screen.getByText('Transaction sent successfully')).toBeInTheDocument();
+ });
+
+ it('shows correct message for sent + error', () => {
+ render();
+ expect(screen.getByText('Transaction failed')).toBeInTheDocument();
+ });
+
+ // received type
+ it('shows correct message for received + pending', () => {
+ render();
+ expect(screen.getByText('Transaction pending...')).toBeInTheDocument();
+ });
+
+ it('shows correct message for received + success', () => {
+ render();
+ expect(screen.getByText('Transaction received successfully')).toBeInTheDocument();
+ });
+
+ it('shows correct message for received + error', () => {
+ render();
+ expect(screen.getByText('Transaction failed')).toBeInTheDocument();
+ });
+
+ // swapped type
+ it('shows correct message for swapped + pending', () => {
+ render();
+ expect(screen.getByText('Swap pending...')).toBeInTheDocument();
+ });
+
+ it('shows correct message for swapped + success', () => {
+ render();
+ expect(screen.getByText('Swap completed successfully')).toBeInTheDocument();
+ });
+
+ it('shows correct message for swapped + error', () => {
+ render();
+ expect(screen.getByText('Swap failed')).toBeInTheDocument();
+ });
+ });
+
+ // for semantic token styles
+ describe('semantic token styles', () => {
+ it('applies bg-card and text-card-foreground tokens', () => {
+ render();
+ const element = screen.getByText('Transaction sent successfully').parentElement;
+ expect(element).toHaveClass('bg-card');
+ expect(element).toHaveClass('text-card-foreground');
+ });
+ });
+
+ // for explorer URL generation
+ describe('explorer URL generation', () => {
+ it('generates correct URL for mainnet-beta (no cluster param)', () => {
+ render();
+ const link = screen.getByRole('link', { name: /view/i });
+ expect(link).toHaveAttribute('href', `https://explorer.solana.com/tx/${mockSignature}`);
+ });
+
+ it('generates correct URL for devnet', () => {
+ render();
+ const link = screen.getByRole('link', { name: /view/i });
+ expect(link).toHaveAttribute('href', `https://explorer.solana.com/tx/${mockSignature}?cluster=devnet`);
+ });
+
+ it('generates correct URL for testnet', () => {
+ render();
+ const link = screen.getByRole('link', { name: /view/i });
+ expect(link).toHaveAttribute('href', `https://explorer.solana.com/tx/${mockSignature}?cluster=testnet`);
+ });
+ });
+
+ // for link security
+ describe('link security', () => {
+ it('opens explorer link in new tab', () => {
+ render();
+ const link = screen.getByRole('link', { name: /view/i });
+ expect(link).toHaveAttribute('target', '_blank');
+ });
+
+ it('has noopener noreferrer for security', () => {
+ render();
+ const link = screen.getByRole('link', { name: /view/i });
+ expect(link).toHaveAttribute('rel', 'noopener noreferrer');
+ });
+ });
+
+ // for accessibility
+ describe('accessibility', () => {
+ it('has status role for pending transactions', () => {
+ render();
+ const toast = screen.getByRole('status');
+ expect(toast).toBeInTheDocument();
+ });
+
+ it('has status role for successful transactions', () => {
+ render();
+ const toast = screen.getByRole('status');
+ expect(toast).toBeInTheDocument();
+ });
+
+ it('has alert role for failed transactions', () => {
+ render();
+ const toast = screen.getByRole('alert');
+ expect(toast).toBeInTheDocument();
+ });
+
+ it('has aria-live polite for non-error states', () => {
+ render();
+ const toast = screen.getByRole('status');
+ expect(toast).toHaveAttribute('aria-live', 'polite');
+ });
+
+ it('has aria-live assertive for error state', () => {
+ render();
+ const toast = screen.getByRole('alert');
+ expect(toast).toHaveAttribute('aria-live', 'assertive');
+ });
+
+ it('explorer link has descriptive accessible name', () => {
+ render();
+ const link = screen.getByRole('link');
+ expect(link).toHaveAccessibleName(/view transaction/i);
+ });
+ });
+});
+
+// Helper wrapper for hook tests
+const wrapper = ({ children }: { children: ReactNode }) => (
+ {children}
+);
+
+describe('useTransactionToast hook integration', () => {
+ // Test: hook returns the expected API
+ it('returns toast, dismiss, and update functions', () => {
+ const { result } = renderHook(() => useTransactionToast(), { wrapper });
+
+ expect(typeof result.current.toast).toBe('function');
+ expect(typeof result.current.dismiss).toBe('function');
+ expect(typeof result.current.update).toBe('function');
+ });
+
+ // Test: toast() returns a string ID
+ it('toast() returns a string ID', () => {
+ const { result } = renderHook(() => useTransactionToast(), { wrapper });
+
+ let toastId = '';
+ act(() => {
+ toastId = result.current.toast({
+ signature: mockSignature,
+ status: 'pending',
+ });
+ });
+
+ expect(typeof toastId).toBe('string');
+ expect(toastId.length).toBeGreaterThan(0);
+ });
+
+ // Test: toast() causes toast to render
+ it('toast() causes toast to render', () => {
+ const TestComponent = () => {
+ const { toast } = useTransactionToast();
+ return (
+
+ );
+ };
+
+ render(
+
+
+ ,
+ );
+
+ // Toast should not exist initially
+ expect(screen.queryByText('Transaction sent successfully')).not.toBeInTheDocument();
+
+ // Click to trigger toast
+ act(() => {
+ screen.getByText('Trigger Toast').click();
+ });
+
+ // Toast should now be visible
+ expect(screen.getByText('Transaction sent successfully')).toBeInTheDocument();
+ });
+
+ // Test: dismiss() removes the toast
+ it('dismiss() removes the toast', () => {
+ const TestComponent = () => {
+ const { toast, dismiss } = useTransactionToast();
+ return (
+ <>
+
+
+ >
+ );
+ };
+
+ render(
+
+
+ ,
+ );
+
+ // Trigger toast
+ act(() => {
+ screen.getByText('Trigger Toast').click();
+ });
+ expect(screen.getByText('Transaction sent successfully')).toBeInTheDocument();
+
+ // Dismiss toast
+ act(() => {
+ screen.getByText('Dismiss Toast').click();
+ });
+ expect(screen.queryByText('Transaction sent successfully')).not.toBeInTheDocument();
+ });
+
+ // Test: update() changes the toast status
+ it('update() changes toast from pending to success', () => {
+ const TestComponent = () => {
+ const { toast, update } = useTransactionToast();
+ return (
+ <>
+
+
+ >
+ );
+ };
+
+ render(
+
+
+ ,
+ );
+
+ // Trigger pending toast
+ act(() => {
+ screen.getByText('Trigger Toast').click();
+ });
+ expect(screen.getByText('Transaction pending...')).toBeInTheDocument();
+
+ // Update to success
+ act(() => {
+ screen.getByText('Update Toast').click();
+ });
+ expect(screen.queryByText('Transaction pending...')).not.toBeInTheDocument();
+ expect(screen.getByText('Transaction sent successfully')).toBeInTheDocument();
+ });
+
+ // Test: hook throws when used outside provider
+ it('throws error when used outside provider', () => {
+ // Suppress console.error for this test since we expect an error
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+
+ expect(() => {
+ renderHook(() => useTransactionToast());
+ }).toThrow('useTransactionToast must be used within a TransactionToastProvider');
+
+ consoleSpy.mockRestore();
+ });
+});
diff --git a/packages/components/src/kit-components/ui/transaction-toast/TransactionToast.tsx b/packages/components/src/kit-components/ui/transaction-toast/TransactionToast.tsx
new file mode 100644
index 0000000..e06599a
--- /dev/null
+++ b/packages/components/src/kit-components/ui/transaction-toast/TransactionToast.tsx
@@ -0,0 +1,118 @@
+import type { ClusterMoniker } from '@solana/client';
+import { Check, ExternalLink, Loader2, X } from 'lucide-react';
+import type React from 'react';
+import { cn } from '@/lib/utils';
+
+//define interfaces for Typescript types
+// for transaction status
+export type TransactionStatus = 'pending' | 'success' | 'error';
+
+//transaction type to determine the message shown
+export type TransactionType = 'sent' | 'received' | 'swapped';
+
+// props for triggering a toast
+export interface TransactionToastData {
+ // Solana transaction signature
+ signature: string;
+ // status of the transaction
+ status: TransactionStatus;
+ // type of transaction
+ type?: TransactionType;
+ // network for explorer URL (default: 'mainnet-beta')
+ network?: ClusterMoniker;
+}
+
+//Props for the visual toast component
+export interface TransactionToastProps extends TransactionToastData {
+ // auto-dismiss after timeout (default: 5000ms for success, infinity for pending/error)
+ duration?: number;
+ // additional CSS classes
+ className?: string;
+}
+
+// define messages for different transaction types and statuses
+const MESSAGES: Record> = {
+ sent: {
+ pending: 'Transaction pending...',
+ success: 'Transaction sent successfully',
+ error: 'Transaction failed',
+ },
+ received: {
+ pending: 'Transaction pending...',
+ success: 'Transaction received successfully',
+ error: 'Transaction failed',
+ },
+ swapped: {
+ pending: 'Swap pending...',
+ success: 'Swap completed successfully',
+ error: 'Swap failed',
+ },
+};
+
+//default duration for messages based on status
+export const DEFAULT_DURATION: Record = {
+ pending: Infinity,
+ success: 5000,
+ error: Infinity,
+};
+
+//builds Solana explorer URL based on network
+function getExplorerUrl(signature: string, network: ClusterMoniker): string {
+ const base = 'https://explorer.solana.com';
+ const isMainnet = network === 'mainnet-beta' || network === 'mainnet';
+ const cluster = isMainnet ? '' : `?cluster=${network}`;
+ return `${base}/tx/${signature}${cluster}`;
+}
+
+//component to display the toast
+export const TransactionToast: React.FC = ({
+ signature,
+ status,
+ type = 'sent',
+ network = 'mainnet-beta',
+ className,
+}) => {
+ const message = MESSAGES[type][status];
+ const explorerUrl = getExplorerUrl(signature, network);
+
+ // Accessibility: error state needs immediate attention
+ const role = status === 'error' ? 'alert' : 'status';
+ const ariaLive = status === 'error' ? 'assertive' : 'polite';
+
+ return (
+
+ {/* Icon based on status */}
+ {status === 'pending' &&
}
+ {status === 'success' && (
+
+
+
+ )}
+ {status === 'error' && (
+
+
+
+ )}
+
+ {/* Message and action */}
+
{message}
+
+ View
+
+
+
+ );
+};
diff --git a/packages/components/src/kit-components/ui/transaction-toast/TransactionToastProvider.tsx b/packages/components/src/kit-components/ui/transaction-toast/TransactionToastProvider.tsx
new file mode 100644
index 0000000..59b3a62
--- /dev/null
+++ b/packages/components/src/kit-components/ui/transaction-toast/TransactionToastProvider.tsx
@@ -0,0 +1,72 @@
+import * as ToastPrimitive from '@radix-ui/react-toast';
+import type React from 'react';
+import { createContext, useCallback, useState } from 'react';
+import { cn } from '@/lib/utils';
+import { DEFAULT_DURATION, TransactionToast, type TransactionToastData } from './TransactionToast';
+
+// define context type
+
+//internal toast with unique_ID
+interface ToastItem extends TransactionToastData {
+ id: string;
+}
+// context value provided to children
+interface TransactionToastContextValue {
+ toast: (data: TransactionToastData) => string;
+ dismiss: (id: string) => void;
+ update: (id: string, data: Partial) => void;
+}
+
+// provider props
+export interface TransactionToastProviderProps {
+ children: React.ReactNode;
+}
+
+//context
+export const TransactionToastContext = createContext(null);
+
+//provider component
+export const TransactionToastProvider: React.FC = ({ children }) => {
+ const [toasts, setToasts] = useState([]);
+
+ //toast function to add new toast
+ const toast = useCallback((data: TransactionToastData): string => {
+ const id = Math.random().toString(36).substring(2, 9);
+ setToasts((prev) => [...prev, { ...data, id }]);
+ return id;
+ }, []);
+ //dismiss function to remove toast by ID
+ const dismiss = useCallback((id: string) => {
+ setToasts((prev) => prev.filter((t) => t.id !== id));
+ }, []);
+ //update existing toast for instance pending -> success
+ const update = useCallback((id: string, data: Partial) => {
+ setToasts((prev) => prev.map((t) => (t.id === id ? { ...t, ...data } : t)));
+ }, []);
+ return (
+
+
+ {children}
+ {toasts.map((t: ToastItem) => (
+ {
+ if (!open) dismiss(t.id);
+ }}
+ className={cn(
+ 'data-[state=open]:animate-in data-[state=closed]:animate-out',
+ 'data-[state=closed]:fade-out-80 data-[state=open]:fade-in-0',
+ 'data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-right-full',
+ )}
+ >
+
+
+ ))}
+
+
+
+ );
+};
diff --git a/packages/components/src/kit-components/ui/transaction-toast/index.ts b/packages/components/src/kit-components/ui/transaction-toast/index.ts
new file mode 100644
index 0000000..cd6eb58
--- /dev/null
+++ b/packages/components/src/kit-components/ui/transaction-toast/index.ts
@@ -0,0 +1,11 @@
+export type {
+ TransactionStatus,
+ TransactionToastData,
+ TransactionToastProps,
+ TransactionType,
+} from './TransactionToast';
+export { TransactionToast } from './TransactionToast';
+export type { TransactionToastProviderProps } from './TransactionToastProvider';
+export { TransactionToastProvider } from './TransactionToastProvider';
+
+export { useTransactionToast } from './useTransactionToast';
diff --git a/packages/components/src/kit-components/ui/transaction-toast/useTransactionToast.ts b/packages/components/src/kit-components/ui/transaction-toast/useTransactionToast.ts
new file mode 100644
index 0000000..c483a27
--- /dev/null
+++ b/packages/components/src/kit-components/ui/transaction-toast/useTransactionToast.ts
@@ -0,0 +1,10 @@
+import { useContext } from 'react';
+import { TransactionToastContext } from './TransactionToastProvider';
+
+export function useTransactionToast() {
+ const context = useContext(TransactionToastContext);
+ if (!context) {
+ throw new Error('useTransactionToast must be used within a TransactionToastProvider');
+ }
+ return context;
+}
diff --git a/packages/components/src/kit-components/ui/wallet-modal/ConnectingView.tsx b/packages/components/src/kit-components/ui/wallet-modal/ConnectingView.tsx
new file mode 100644
index 0000000..29a9910
--- /dev/null
+++ b/packages/components/src/kit-components/ui/wallet-modal/ConnectingView.tsx
@@ -0,0 +1,58 @@
+import type { WalletConnectorMetadata } from '@solana/client';
+import { Loader2 } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import { ModalHeader } from './ModalHeader';
+
+export interface ConnectingViewProps {
+ /** The wallet currently being connected */
+ wallet: WalletConnectorMetadata;
+ /** Handler for back button */
+ onBack?: () => void;
+ /** Handler for close button */
+ onClose?: () => void;
+ /** Additional class names */
+ className?: string;
+}
+
+/**
+ * ConnectingView - Loading state shown while wallet is connecting
+ *
+ * Features:
+ * - Wallet icon with animated spinner ring
+ * - "Connecting to {wallet}..." title
+ * - Instructional subtitle
+ * - Back and close buttons
+ */
+export function ConnectingView({ wallet, onBack, onClose, className }: ConnectingViewProps) {
+ return (
+
+ {/* Header with back + close */}
+
+
+ {/* Wallet icon with spinner ring */}
+
+ {/* Spinner ring */}
+
+ {/* Wallet icon */}
+
+

{
+ e.currentTarget.style.display = 'none';
+ }}
+ />
+
+
+
+ {/* Text content */}
+
+
Connecting to {wallet.name}...
+
+ Check your wallet and approve the connection request.
+
+
+
+ );
+}
diff --git a/packages/components/src/kit-components/ui/wallet-modal/ErrorView.tsx b/packages/components/src/kit-components/ui/wallet-modal/ErrorView.tsx
new file mode 100644
index 0000000..eca9252
--- /dev/null
+++ b/packages/components/src/kit-components/ui/wallet-modal/ErrorView.tsx
@@ -0,0 +1,70 @@
+import { cn } from '@/lib/utils';
+import errorIcon from './assets/error-icon.svg';
+import { ModalHeader } from './ModalHeader';
+
+export interface ErrorViewProps {
+ /** Error title to display */
+ title?: string;
+ /** Error message/description */
+ message?: string;
+ /** Handler for retry button */
+ onRetry?: () => void;
+ /** Handler for close button */
+ onClose?: () => void;
+ /** Additional class names */
+ className?: string;
+}
+
+/**
+ * ErrorView - Error state shown when wallet connection fails
+ *
+ * Features:
+ * - Red error icon in circle
+ * - "Connection failed" title
+ * - Error message subtitle
+ * - Retry button
+ * - Close button
+ */
+export function ErrorView({
+ title = 'Connection failed',
+ message = 'Unable to connect. Please try again.',
+ onRetry,
+ onClose,
+ className,
+}: ErrorViewProps) {
+ return (
+
+ {/* Header with close only (no back) */}
+
+
+
+
+ {/* Error icon */}
+
+

+
+
+ {/* Text content */}
+
+
+ {/* Retry button - pill shaped outlined button */}
+
+
+ );
+}
diff --git a/packages/components/src/kit-components/ui/wallet-modal/ModalHeader.tsx b/packages/components/src/kit-components/ui/wallet-modal/ModalHeader.tsx
new file mode 100644
index 0000000..2854ca6
--- /dev/null
+++ b/packages/components/src/kit-components/ui/wallet-modal/ModalHeader.tsx
@@ -0,0 +1,81 @@
+import { ArrowLeft, X } from 'lucide-react';
+import { cn } from '@/lib/utils';
+
+export interface ModalHeaderProps {
+ /** Title text to display */
+ title: string;
+ /** ID for the title element (used for aria-labelledby) */
+ titleId?: string;
+ /** Whether to show the back button */
+ showBack?: boolean;
+ /** Handler for close button */
+ onClose?: () => void;
+ /** Handler for back button */
+ onBack?: () => void;
+ /** Additional class names */
+ className?: string;
+}
+
+/**
+ * ModalHeader - Header component with title, back, and close icons
+ *
+ * Features:
+ * - Optional back arrow (for connecting/error views)
+ * - Close X button (always visible)
+ *
+ * @example
+ * ```tsx
+ * // Wallet list view (no back button)
+ * setOpen(false)}
+ * />
+ *
+ * // Connecting view (with back button)
+ * setView('list')}
+ * onClose={() => setOpen(false)}
+ * />
+ * ```
+ */
+export function ModalHeader({ title, titleId, showBack = false, onClose, onBack, className }: ModalHeaderProps) {
+ return (
+
+ {/* Left side: Back button or title */}
+ {showBack ? (
+
+ ) : (
+
+ {title}
+
+ )}
+
+ {/* Right side: Close button */}
+
+
+ );
+}
diff --git a/packages/components/src/kit-components/ui/wallet-modal/NoWalletLink.tsx b/packages/components/src/kit-components/ui/wallet-modal/NoWalletLink.tsx
new file mode 100644
index 0000000..de55077
--- /dev/null
+++ b/packages/components/src/kit-components/ui/wallet-modal/NoWalletLink.tsx
@@ -0,0 +1,29 @@
+import { Wallet } from 'lucide-react';
+import { cn } from '@/lib/utils';
+
+export interface NoWalletLinkProps {
+ href?: string;
+ className?: string;
+}
+
+const DEFAULT_WALLET_URL = 'https://solana.com/ecosystem/explore?categories=wallet';
+
+export function NoWalletLink({ href = DEFAULT_WALLET_URL, className }: NoWalletLinkProps) {
+ return (
+
+
+ I don't have a wallet
+
+ );
+}
diff --git a/packages/components/src/kit-components/ui/wallet-modal/WalletCard.tsx b/packages/components/src/kit-components/ui/wallet-modal/WalletCard.tsx
new file mode 100644
index 0000000..7981fd2
--- /dev/null
+++ b/packages/components/src/kit-components/ui/wallet-modal/WalletCard.tsx
@@ -0,0 +1,106 @@
+import type { WalletConnectorMetadata } from '@solana/client';
+import { cn } from '@/lib/utils';
+import type { WalletLabelType } from './types';
+import { WalletLabel } from './WalletLabel';
+
+export interface WalletCardProps {
+ /** Wallet information to display */
+ wallet: WalletConnectorMetadata;
+ /** Optional label to show (Recent, Detected, Installed) */
+ label?: WalletLabelType;
+ /** Position in the list for border radius */
+ position?: 'first' | 'middle' | 'last' | 'only';
+ /** Whether this card is currently hovered/focused */
+ isHovered?: boolean;
+ /** Click handler when wallet is selected */
+ onSelect?: (wallet: WalletConnectorMetadata) => void;
+ /** Whether the card is disabled (e.g., during connection) */
+ disabled?: boolean;
+ /** Additional class names */
+ className?: string;
+}
+
+/**
+ * WalletCard - Individual wallet row in the wallet selection list
+ *
+ * Features:
+ * - Wallet icon (32x32, rounded)
+ * - Wallet name
+ * - Optional label (Recent, Detected)
+ * - Hover state with background change
+ * - Position-aware border radius (first/middle/last)
+ *
+ * @example
+ * ```tsx
+ * console.log('Selected:', w.name)}
+ * />
+ * ```
+ */
+export function WalletCard({
+ wallet,
+ label,
+ position = 'middle',
+ isHovered = false,
+ onSelect,
+ disabled = false,
+ className,
+}: WalletCardProps) {
+ // Border radius based on position
+ const positionClasses = {
+ first: 'rounded-t-2xl',
+ middle: '',
+ last: 'rounded-b-2xl',
+ only: 'rounded-2xl',
+ };
+
+ // Border classes for middle items
+ const borderClasses = position === 'middle' ? 'border-y border-border' : '';
+
+ return (
+
+ );
+}
diff --git a/packages/components/src/kit-components/ui/wallet-modal/WalletLabel.tsx b/packages/components/src/kit-components/ui/wallet-modal/WalletLabel.tsx
new file mode 100644
index 0000000..fbb6f80
--- /dev/null
+++ b/packages/components/src/kit-components/ui/wallet-modal/WalletLabel.tsx
@@ -0,0 +1,38 @@
+import { cn } from '@/lib/utils';
+import type { WalletLabelType } from './types';
+
+export interface WalletLabelProps {
+ /** The label type to display */
+ type: WalletLabelType;
+ /** Additional class names */
+ className?: string;
+}
+
+const labelText: Record = {
+ recent: 'Recent',
+ detected: 'Detected',
+ installed: 'Installed',
+};
+
+/**
+ * WalletLabel - Badge component for wallet status (Recent, Detected, etc.)
+ *
+ * @example
+ * ```tsx
+ *
+ *
+ * ```
+ */
+export function WalletLabel({ type, className }: WalletLabelProps) {
+ return (
+
+ {labelText[type]}
+
+ );
+}
diff --git a/packages/components/src/kit-components/ui/wallet-modal/WalletList.tsx b/packages/components/src/kit-components/ui/wallet-modal/WalletList.tsx
new file mode 100644
index 0000000..758399c
--- /dev/null
+++ b/packages/components/src/kit-components/ui/wallet-modal/WalletList.tsx
@@ -0,0 +1,66 @@
+import type { WalletConnectorMetadata } from '@solana/client';
+import { cn } from '@/lib/utils';
+import { WalletCard } from './WalletCard';
+
+export interface WalletListProps {
+ /** List of wallets to display */
+ wallets: WalletConnectorMetadata[];
+ /** Handler when a wallet is selected */
+ onSelect?: (wallet: WalletConnectorMetadata) => void;
+ /** Wallet ID that is currently connecting (to disable others) */
+ connectingWalletId?: string;
+ /** Additional class names */
+ className?: string;
+}
+
+/**
+ * WalletList - Container for wallet cards with proper spacing and borders
+ *
+ * Features:
+ * - Renders list of WalletCard components
+ * - Handles position-aware border radius (first/middle/last)
+ * - Disables cards when another wallet is connecting
+ *
+ * @example
+ * ```tsx
+ * console.log('Selected:', wallet.name)}
+ * />
+ * ```
+ */
+export function WalletList({ wallets, onSelect, connectingWalletId, className }: WalletListProps) {
+ // Determine position for each wallet card
+ const getPosition = (index: number): 'first' | 'middle' | 'last' | 'only' => {
+ if (wallets.length === 1) return 'only';
+ if (index === 0) return 'first';
+ if (index === wallets.length - 1) return 'last';
+ return 'middle';
+ };
+
+ if (wallets.length === 0) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {wallets.map((wallet, index) => (
+
+ ))}
+
+ );
+}
diff --git a/packages/components/src/kit-components/ui/wallet-modal/WalletModal.test.tsx b/packages/components/src/kit-components/ui/wallet-modal/WalletModal.test.tsx
new file mode 100644
index 0000000..e6e54bf
--- /dev/null
+++ b/packages/components/src/kit-components/ui/wallet-modal/WalletModal.test.tsx
@@ -0,0 +1,252 @@
+// @vitest-environment jsdom
+
+import { cleanup, fireEvent, render, screen } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import '@testing-library/jest-dom/vitest';
+
+import { WalletModal } from './WalletModal';
+
+afterEach(() => {
+ cleanup();
+});
+
+// Mock wallet data
+const mockWallets = [
+ {
+ id: 'phantom',
+ name: 'Phantom',
+ icon: 'https://phantom.app/icon.png',
+ },
+ {
+ id: 'solflare',
+ name: 'Solflare',
+ icon: 'https://solflare.com/icon.png',
+ },
+ {
+ id: 'backpack',
+ name: 'Backpack',
+ icon: 'https://backpack.app/icon.png',
+ },
+];
+
+const mockConnectingWallet = mockWallets[0];
+
+describe('WalletModal', () => {
+ describe('list view (default)', () => {
+ it('renders modal with dialog role', () => {
+ render();
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ });
+
+ it('displays modal title', () => {
+ render();
+ expect(screen.getByText('Connect Wallet')).toBeInTheDocument();
+ });
+
+ it('renders list of wallet options', () => {
+ render();
+
+ expect(screen.getByText('Phantom')).toBeInTheDocument();
+ expect(screen.getByText('Solflare')).toBeInTheDocument();
+ expect(screen.getByText('Backpack')).toBeInTheDocument();
+ });
+
+ it('calls onSelectWallet when wallet is clicked', () => {
+ const onSelectWallet = vi.fn();
+ render();
+
+ fireEvent.click(screen.getByText('Phantom'));
+
+ expect(onSelectWallet).toHaveBeenCalledWith(mockWallets[0]);
+ });
+
+ it('calls onClose when close button is clicked', () => {
+ const onClose = vi.fn();
+ render();
+
+ const closeButton = screen.getByRole('button', { name: /close/i });
+ fireEvent.click(closeButton);
+
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it('shows "no wallet" link by default', () => {
+ render();
+ expect(screen.getByText(/don't have a wallet/i)).toBeInTheDocument();
+ });
+
+ it('hides "no wallet" link when showNoWalletLink is false', () => {
+ render();
+ expect(screen.queryByText(/don't have a wallet/i)).not.toBeInTheDocument();
+ });
+
+ it('renders no wallet link when showNoWalletLink is true', () => {
+ render();
+ const link = screen.getByRole('link', { name: /don't have a wallet/i });
+ expect(link).toBeInTheDocument();
+ expect(link).toHaveAttribute('target', '_blank');
+ expect(link).toHaveAttribute('rel', 'noopener noreferrer');
+ });
+ });
+
+ describe('connecting view', () => {
+ it('shows connecting view when view is "connecting"', () => {
+ render();
+
+ // Should not show the wallet list title
+ expect(screen.queryByText('Connect Wallet')).not.toBeInTheDocument();
+ // Should show connecting message with wallet name
+ expect(screen.getByText(/Connecting to.*Phantom/i)).toBeInTheDocument();
+ });
+
+ it('shows loading indicator in connecting view', () => {
+ render();
+
+ // Should show some loading/connecting text
+ expect(screen.getByText(/connecting|opening/i)).toBeInTheDocument();
+ });
+
+ it('calls onBack when back button is clicked in connecting view', () => {
+ const onBack = vi.fn();
+ render(
+ ,
+ );
+
+ const backButton = screen.getByRole('button', { name: /back/i });
+ fireEvent.click(backButton);
+
+ expect(onBack).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls onClose when close button is clicked in connecting view', () => {
+ const onClose = vi.fn();
+ render(
+ ,
+ );
+
+ const closeButton = screen.getByRole('button', { name: /close/i });
+ fireEvent.click(closeButton);
+
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('error view', () => {
+ it('shows error view when view is "error"', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText('Connection Failed')).toBeInTheDocument();
+ expect(screen.getByText('Please try again')).toBeInTheDocument();
+ });
+
+ it('displays default error title when not provided', () => {
+ render();
+
+ // Should show some default error indication
+ expect(screen.getByText('Something went wrong')).toBeInTheDocument();
+ });
+
+ it('calls onRetry when retry button is clicked', () => {
+ const onRetry = vi.fn();
+ render();
+
+ const retryButton = screen.getByRole('button', { name: /retry|try again/i });
+ fireEvent.click(retryButton);
+
+ expect(onRetry).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls onClose when close button is clicked in error view', () => {
+ const onClose = vi.fn();
+ render();
+
+ const closeButton = screen.getByRole('button', { name: /close/i });
+ fireEvent.click(closeButton);
+
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('semantic token classes', () => {
+ it('uses bg-card semantic token on the modal container', () => {
+ const { container } = render();
+ expect(container.querySelector('.bg-card')).toBeInTheDocument();
+ });
+ });
+
+ describe('accessibility', () => {
+ it('has dialog role', () => {
+ render();
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ });
+
+ it('has aria-modal attribute', () => {
+ render();
+ expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'true');
+ });
+
+ it('has aria-labelledby pointing to title in list view', () => {
+ render();
+ const dialog = screen.getByRole('dialog');
+ expect(dialog).toHaveAttribute('aria-labelledby', 'wallet-modal-title');
+ expect(document.getElementById('wallet-modal-title')).toBeInTheDocument();
+ });
+
+ it('uses aria-label fallback in non-list views', () => {
+ render();
+ const dialog = screen.getByRole('dialog');
+ expect(dialog).not.toHaveAttribute('aria-labelledby');
+ expect(dialog).toHaveAttribute('aria-label');
+ });
+ });
+
+ describe('custom className', () => {
+ it('applies additional className', () => {
+ const { container } = render();
+ expect(container.firstChild).toHaveClass('custom-class');
+ });
+ });
+
+ describe('edge cases', () => {
+ it('handles empty wallets array', () => {
+ render();
+ // Should render without crashing
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ expect(screen.getByText('Connect Wallet')).toBeInTheDocument();
+ });
+
+ it('handles missing connectingWallet in connecting view gracefully', () => {
+ render();
+ // Should not crash, but also won't show connecting view content
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ });
+
+ it('handles missing error in error view gracefully', () => {
+ render();
+ // Should render error view without crashing
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ });
+
+ it('handles wallet selection without onSelectWallet handler', () => {
+ render();
+ // Should not crash when clicking wallet without handler
+ expect(() => fireEvent.click(screen.getByText('Phantom'))).not.toThrow();
+ });
+ });
+});
diff --git a/packages/components/src/kit-components/ui/wallet-modal/WalletModal.tsx b/packages/components/src/kit-components/ui/wallet-modal/WalletModal.tsx
new file mode 100644
index 0000000..a294ed6
--- /dev/null
+++ b/packages/components/src/kit-components/ui/wallet-modal/WalletModal.tsx
@@ -0,0 +1,94 @@
+import type { WalletConnectorMetadata } from '@solana/client';
+import { cn } from '@/lib/utils';
+import { ConnectingView } from './ConnectingView';
+import { ErrorView } from './ErrorView';
+import { ModalHeader } from './ModalHeader';
+import { NoWalletLink } from './NoWalletLink';
+import type { ModalView } from './types';
+import { WalletList } from './WalletList';
+
+export interface WalletModalProps {
+ /** List of available wallets */
+ wallets: WalletConnectorMetadata[];
+ /** Current view state */
+ view?: ModalView;
+ /** Wallet currently being connected (for connecting view) */
+ connectingWallet?: WalletConnectorMetadata | null;
+ /** Error info (for error view) */
+ error?: { title?: string; message?: string } | null;
+ /** Handler when a wallet is selected */
+ onSelectWallet?: (wallet: WalletConnectorMetadata) => void;
+ /** Handler for back button (connecting/error views) */
+ onBack?: () => void;
+ /** Handler for close button */
+ onClose?: () => void;
+ /** Handler for retry button (error view) */
+ onRetry?: () => void;
+ /** Whether to show the "I don't have a wallet" link */
+ showNoWalletLink?: boolean;
+ /** Custom URL for wallet guide */
+ walletGuideUrl?: string;
+ /** Additional class names */
+ className?: string;
+}
+
+/**
+ * WalletModal - Main modal component for wallet selection and connection
+ *
+ * Views:
+ * - list: Shows available wallets for selection
+ * - connecting: Shows loading state while connecting
+ * - error: Shows error state with retry option
+ *
+ * @example
+ * ```tsx
+ * connect(w)}
+ * onClose={() => setOpen(false)}
+ * />
+ * ```
+ */
+export function WalletModal({
+ wallets,
+ view = 'list',
+ connectingWallet,
+ error,
+ onSelectWallet,
+ onBack,
+ onClose,
+ onRetry,
+ showNoWalletLink = true,
+ walletGuideUrl,
+ className,
+}: WalletModalProps) {
+ return (
+
+ {/* List View */}
+ {view === 'list' && (
+ <>
+
+
+ {showNoWalletLink && }
+ >
+ )}
+
+ {/* Connecting View */}
+ {view === 'connecting' && connectingWallet && (
+
+ )}
+
+ {/* Error View */}
+ {view === 'error' && (
+
+ )}
+
+ );
+}
diff --git a/packages/components/src/kit-components/ui/wallet-modal/assets/backpack.png b/packages/components/src/kit-components/ui/wallet-modal/assets/backpack.png
new file mode 100644
index 0000000..97c3971
Binary files /dev/null and b/packages/components/src/kit-components/ui/wallet-modal/assets/backpack.png differ
diff --git a/packages/components/src/kit-components/ui/wallet-modal/assets/error-icon.svg b/packages/components/src/kit-components/ui/wallet-modal/assets/error-icon.svg
new file mode 100644
index 0000000..e5c11f8
--- /dev/null
+++ b/packages/components/src/kit-components/ui/wallet-modal/assets/error-icon.svg
@@ -0,0 +1,6 @@
+
diff --git a/packages/components/src/kit-components/ui/wallet-modal/assets/phantom.png b/packages/components/src/kit-components/ui/wallet-modal/assets/phantom.png
new file mode 100644
index 0000000..dde63b4
Binary files /dev/null and b/packages/components/src/kit-components/ui/wallet-modal/assets/phantom.png differ
diff --git a/packages/components/src/kit-components/ui/wallet-modal/assets/solflare.png b/packages/components/src/kit-components/ui/wallet-modal/assets/solflare.png
new file mode 100644
index 0000000..070e1fc
Binary files /dev/null and b/packages/components/src/kit-components/ui/wallet-modal/assets/solflare.png differ
diff --git a/packages/components/src/kit-components/ui/wallet-modal/index.ts b/packages/components/src/kit-components/ui/wallet-modal/index.ts
new file mode 100644
index 0000000..aa6402a
--- /dev/null
+++ b/packages/components/src/kit-components/ui/wallet-modal/index.ts
@@ -0,0 +1,25 @@
+// Main component
+
+export type { ConnectingViewProps } from './ConnectingView';
+export { ConnectingView } from './ConnectingView';
+export type { ErrorViewProps } from './ErrorView';
+export { ErrorView } from './ErrorView';
+export type { ModalHeaderProps } from './ModalHeader';
+// Sub-components
+export { ModalHeader } from './ModalHeader';
+export type { NoWalletLinkProps } from './NoWalletLink';
+export { NoWalletLink } from './NoWalletLink';
+// Types
+export type {
+ ConnectionError,
+ ModalView,
+ WalletLabelType,
+} from './types';
+export type { WalletCardProps } from './WalletCard';
+export { WalletCard } from './WalletCard';
+export type { WalletLabelProps } from './WalletLabel';
+export { WalletLabel } from './WalletLabel';
+export type { WalletListProps } from './WalletList';
+export { WalletList } from './WalletList';
+export type { WalletModalProps } from './WalletModal';
+export { WalletModal } from './WalletModal';
diff --git a/packages/components/src/kit-components/ui/wallet-modal/types.ts b/packages/components/src/kit-components/ui/wallet-modal/types.ts
new file mode 100644
index 0000000..a1d8e1f
--- /dev/null
+++ b/packages/components/src/kit-components/ui/wallet-modal/types.ts
@@ -0,0 +1,17 @@
+/**
+ * Shared types for the WalletModal component
+ */
+
+/** Wallet label types */
+export type WalletLabelType = 'recent' | 'detected' | 'installed';
+
+/** Modal view states */
+export type ModalView = 'list' | 'connecting' | 'error';
+
+/** Connection error types */
+export interface ConnectionError {
+ /** Error message to display */
+ message: string;
+ /** Original error for debugging */
+ cause?: unknown;
+}
diff --git a/packages/components/src/lib/utils.ts b/packages/components/src/lib/utils.ts
new file mode 100644
index 0000000..3f57256
--- /dev/null
+++ b/packages/components/src/lib/utils.ts
@@ -0,0 +1,53 @@
+import { type ClassValue, clsx } from 'clsx';
+import { twMerge } from 'tailwind-merge';
+
+/**
+ * Utility function for merging Tailwind CSS classes with proper precedence.
+ * Combines clsx for conditional classes and tailwind-merge for deduplication.
+ */
+export function cn(...inputs: ClassValue[]): string {
+ return twMerge(clsx(inputs));
+}
+
+/**
+ * Truncates a wallet address for display.
+ * @param address - Full wallet address string
+ * @param startChars - Number of characters to show at start (default: 4)
+ * @param endChars - Number of characters to show at end (default: 4)
+ * @returns Truncated address like "6DMh...1DkK"
+ */
+export function truncateAddress(address: string, startChars = 4, endChars = 4): string {
+ if (!address) return '';
+ if (address.length <= startChars + endChars) return address;
+ return `${address.slice(0, startChars)}...${address.slice(-endChars)}`;
+}
+
+/**
+ * Formats SOL balance for display with proper truncation for large values.
+ * @param lamports - Balance in lamports (1 SOL = 1e9 lamports)
+ * @param decimals - Number of decimal places (default: 2)
+ * @returns Formatted string like "1.12", "1.2K", "3.5M", "2.1B"
+ */
+export function formatSolBalance(lamports: number | bigint, decimals = 2): string {
+ const sol = Number(lamports) / 1e9;
+
+ // Large number formatting
+ if (sol >= 1_000_000_000) {
+ return `${(sol / 1_000_000_000).toFixed(1)}B`;
+ }
+ if (sol >= 1_000_000) {
+ return `${(sol / 1_000_000).toFixed(1)}M`;
+ }
+ if (sol >= 10_000) {
+ return `${(sol / 1_000).toFixed(1)}K`;
+ }
+ if (sol >= 1_000) {
+ // Add comma formatting for thousands
+ return sol.toLocaleString('en-US', {
+ minimumFractionDigits: decimals,
+ maximumFractionDigits: decimals,
+ });
+ }
+
+ return sol.toFixed(decimals);
+}
diff --git a/packages/components/src/main.tsx b/packages/components/src/main.tsx
new file mode 100644
index 0000000..906c72b
--- /dev/null
+++ b/packages/components/src/main.tsx
@@ -0,0 +1,13 @@
+import { StrictMode } from 'react';
+import { createRoot } from 'react-dom/client';
+import './index.css';
+// import App from './App.tsx';
+
+const rootElement = document.getElementById('root');
+if (!rootElement) throw new Error('Failed to find the root element');
+
+createRoot(rootElement).render(
+
+ Hello World
+ ,
+);
diff --git a/packages/components/src/stories/AddressDisplay.stories.ts b/packages/components/src/stories/AddressDisplay.stories.ts
new file mode 100644
index 0000000..d4968c0
--- /dev/null
+++ b/packages/components/src/stories/AddressDisplay.stories.ts
@@ -0,0 +1,159 @@
+import { address } from '@solana/kit';
+import type { Meta, StoryObj } from '@storybook/react';
+import { AddressDisplay } from '../kit-components/ui/address-display';
+
+const sampleAddress = address('Hb6dzd4pYxmFYKkJDWuhzBEUkkaE93sFcvXYtriTkmw9');
+
+const meta: Meta = {
+ title: 'Kit Components/Display/AddressDisplay',
+ component: AddressDisplay,
+ tags: ['autodocs'],
+ parameters: {
+ docs: {
+ description: {
+ component:
+ 'Displays a truncated Solana address with copy-to-clipboard functionality and an optional link to Solana Explorer. Shows full address on hover.',
+ },
+ },
+ },
+ argTypes: {
+ address: {
+ description: 'Solana public key in base58 format',
+ table: {
+ type: { summary: 'Address' },
+ },
+ },
+ network: {
+ description: 'Solana network for Explorer URL generation',
+ control: 'select',
+ options: ['mainnet-beta', 'devnet', 'testnet'],
+ table: {
+ defaultValue: { summary: 'mainnet-beta' },
+ type: { summary: 'ClusterMoniker' },
+ },
+ },
+ showExplorerLink: {
+ description: 'Whether to show the Solana Explorer link icon',
+ control: 'boolean',
+ table: {
+ defaultValue: { summary: 'true' },
+ type: { summary: 'boolean' },
+ },
+ },
+ showTooltip: {
+ description: 'Whether to show the full address tooltip on hover',
+ control: 'boolean',
+ table: {
+ defaultValue: { summary: 'true' },
+ type: { summary: 'boolean' },
+ },
+ },
+ onCopy: {
+ description: 'Callback fired after address is copied to clipboard',
+ table: {
+ type: { summary: '() => void' },
+ },
+ },
+ className: {
+ description: 'Additional CSS classes to apply to the container',
+ table: {
+ type: { summary: 'string' },
+ },
+ },
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+// default
+
+export const Default: Story = {
+ args: {
+ address: sampleAddress,
+ },
+};
+
+// for network variants
+
+export const Mainnet: Story = {
+ name: 'Network: Mainnet',
+ args: {
+ address: sampleAddress,
+ network: 'mainnet-beta',
+ },
+};
+
+export const Devnet: Story = {
+ name: 'Network: Devnet',
+ args: {
+ address: sampleAddress,
+ network: 'devnet',
+ },
+};
+
+export const Testnet: Story = {
+ name: 'Network: Testnet',
+ args: {
+ address: sampleAddress,
+ network: 'testnet',
+ },
+};
+
+// for feature variants
+
+export const WithoutExplorerLink: Story = {
+ name: 'Without Explorer Link',
+ args: {
+ address: sampleAddress,
+ showExplorerLink: false,
+ },
+};
+
+export const WithoutTooltip: Story = {
+ name: 'Without Tooltip',
+ args: {
+ address: sampleAddress,
+ showTooltip: false,
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'Hides the full-address tooltip on hover. Useful when the address is embedded in a tight layout like a dropdown.',
+ },
+ },
+ },
+};
+
+export const WithCopyCallback: Story = {
+ name: 'With Copy Callback',
+ args: {
+ address: sampleAddress,
+ onCopy: () => console.log('Address copied!'),
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'Open the browser console and click the copy icon to see the callback fire.',
+ },
+ },
+ },
+};
+
+// for playground
+
+export const Playground: Story = {
+ name: 'Playground',
+ args: {
+ address: sampleAddress,
+ network: 'mainnet-beta',
+ showExplorerLink: true,
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'Use the controls panel to experiment with all props.',
+ },
+ },
+ },
+};
diff --git a/packages/components/src/stories/BalanceCard.stories.tsx b/packages/components/src/stories/BalanceCard.stories.tsx
new file mode 100644
index 0000000..4f065c6
--- /dev/null
+++ b/packages/components/src/stories/BalanceCard.stories.tsx
@@ -0,0 +1,186 @@
+import { address, lamports } from '@solana/kit';
+import type { Meta, StoryObj } from '@storybook/react';
+import { BalanceCard } from '../kit-components/ui/balance-card';
+
+// Sample wallet address for stories
+const sampleWalletAddress = address('6DMh7fYHrKdCJwCFUQfMfNAdLADi9xqsRKNzmZA31DkK');
+const altWalletAddress = address('9xQeWvG816hKfA2H2HnXHoGZTMbNJrPpT4Hz8knSjLm4');
+
+const meta = {
+ title: 'UI/BalanceCard',
+ component: BalanceCard,
+ parameters: {
+ layout: 'centered',
+ },
+ argTypes: {
+ size: {
+ control: 'select',
+ options: ['sm', 'md', 'lg'],
+ },
+ isLoading: {
+ control: 'boolean',
+ },
+ isFiatBalance: {
+ control: 'boolean',
+ },
+ defaultExpanded: {
+ control: 'boolean',
+ },
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+// Sample token data
+const sampleTokens = [
+ { symbol: 'USDC', balance: 15.5, fiatValue: 15.5 },
+ { symbol: 'USDT', balance: 10.18, fiatValue: 10.18 },
+ { symbol: 'USDG', balance: 15.5, fiatValue: 15.5 },
+];
+
+/**
+ * Default with balance and tokens
+ */
+export const Default: Story = {
+ args: {
+ walletAddress: sampleWalletAddress,
+ totalBalance: lamports(34_810_000_000n),
+ tokenSymbol: 'SOL',
+ tokens: sampleTokens,
+ },
+};
+
+/**
+ * Empty balance
+ */
+export const EmptyBalance: Story = {
+ args: {
+ walletAddress: altWalletAddress,
+ totalBalance: lamports(0n),
+ isFiatBalance: false,
+ tokens: [],
+ defaultExpanded: true,
+ },
+};
+
+/**
+ * Loading state - shows skeleton
+ */
+export const Loading: Story = {
+ args: {
+ walletAddress: sampleWalletAddress,
+ totalBalance: lamports(0n),
+ isLoading: true,
+ },
+};
+
+/**
+ * Zero balance state
+ */
+export const ZeroBalance: Story = {
+ args: {
+ walletAddress: sampleWalletAddress,
+ totalBalance: lamports(0n),
+ isFiatBalance: false,
+ tokens: [],
+ },
+};
+
+/**
+ * Zero balance with expanded empty token list
+ */
+export const ZeroBalanceExpanded: Story = {
+ args: {
+ walletAddress: sampleWalletAddress,
+ totalBalance: lamports(0n),
+ isFiatBalance: false,
+ tokens: [],
+ defaultExpanded: true,
+ },
+};
+
+/**
+ * With tokens expanded
+ */
+export const WithTokensExpanded: Story = {
+ args: {
+ walletAddress: sampleWalletAddress,
+ totalBalance: lamports(34_810_000_000n),
+ isFiatBalance: true,
+ tokens: sampleTokens,
+ defaultExpanded: true,
+ },
+};
+
+/**
+ * Error state
+ */
+export const WithError: Story = {
+ args: {
+ walletAddress: sampleWalletAddress,
+ totalBalance: lamports(0n),
+ isFiatBalance: true,
+ error: 'Error loading tokens.',
+ onRetry: () => console.log('Retry clicked'),
+ },
+};
+
+/**
+ * Small size
+ */
+export const SmallSize: Story = {
+ args: {
+ walletAddress: sampleWalletAddress,
+ totalBalance: lamports(34_810_000_000n),
+ isFiatBalance: true,
+ tokens: sampleTokens,
+ size: 'sm',
+ },
+};
+
+/**
+ * Large size
+ */
+export const LargeSize: Story = {
+ args: {
+ walletAddress: sampleWalletAddress,
+ totalBalance: lamports(34_810_000_000n),
+ isFiatBalance: true,
+ tokens: sampleTokens,
+ size: 'lg',
+ },
+};
+
+/**
+ * Crypto balance display (non-fiat)
+ */
+export const CryptoBalance: Story = {
+ args: {
+ walletAddress: sampleWalletAddress,
+ totalBalance: lamports(1_523_400_000n),
+ isFiatBalance: false,
+ tokenSymbol: 'SOL',
+ displayDecimals: 4,
+ tokens: [{ symbol: 'SOL', balance: 1.5234 }],
+ },
+};
+
+/**
+ * Token symbol balance display (e.g. "4.50 SOL")
+ */
+export const TokenSymbolBalance: Story = {
+ args: {
+ walletAddress: sampleWalletAddress,
+ totalBalance: lamports(4_500_000_000n),
+ tokenSymbol: 'SOL',
+ tokens: sampleTokens,
+ },
+};
diff --git a/packages/components/src/stories/ConnectWalletButton.stories.tsx b/packages/components/src/stories/ConnectWalletButton.stories.tsx
new file mode 100644
index 0000000..a0b36d3
--- /dev/null
+++ b/packages/components/src/stories/ConnectWalletButton.stories.tsx
@@ -0,0 +1,581 @@
+import type { ClusterMoniker, WalletConnectorMetadata } from '@solana/client';
+import { lamports } from '@solana/client';
+import type { Lamports } from '@solana/kit';
+import { address } from '@solana/kit';
+import type { Meta, StoryObj } from '@storybook/react';
+import { useCallback, useState } from 'react';
+import { ConnectWalletButton, WalletButton, WalletDropdown } from '../kit-components/ui/connect-wallet-button';
+import backpackIcon from '../kit-components/ui/connect-wallet-button/assets/backpack.png';
+import solflareIcon from '../kit-components/ui/connect-wallet-button/assets/solflare.png';
+
+/**
+ * ConnectWalletButton - A composable Solana wallet connection component.
+ *
+ * This component handles all wallet connection states:
+ * - **Disconnected**: Shows "Connect Wallet" button
+ * - **Connecting**: Shows loading spinner
+ * - **Connected**: Shows wallet icon with dropdown for address, balance, and disconnect
+ *
+ * ## Interactive Stories
+ * The first stories are **fully interactive** - click through the entire flow!
+ */
+const meta = {
+ title: 'Components/ConnectWalletButton',
+ component: ConnectWalletButton,
+ parameters: {
+ layout: 'centered',
+ backgrounds: {
+ default: 'dark',
+ values: [
+ { name: 'dark', value: '#1a1a1a' },
+ { name: 'light', value: '#f5f5f5' },
+ ],
+ },
+ },
+ tags: ['autodocs'],
+ argTypes: {
+ status: {
+ control: 'radio',
+ options: ['disconnected', 'connecting', 'connected', 'error'],
+ description: 'Current connection status',
+ },
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+// ============================================
+// MOCK DATA - Using actual wallet icons
+// ============================================
+
+const mockBackpackWallet: WalletConnectorMetadata = {
+ id: 'backpack',
+ name: 'Backpack',
+ icon: backpackIcon,
+};
+
+const mockSolflareWallet: WalletConnectorMetadata = {
+ id: 'solflare',
+ name: 'Solflare',
+ icon: solflareIcon,
+};
+
+const mockPhantomWallet: WalletConnectorMetadata = {
+ id: 'phantom',
+ name: 'Phantom',
+ icon: backpackIcon, // Using backpack icon as placeholder
+};
+
+const mockAddress = address('6DMh7gJwvuTq3Bpf8rPVGPjzqnz1DkK3H1mVh9kP1DkK');
+const mockBalance = lamports(1120000000n); // 1.12 SOL
+
+// ============================================
+// INTERACTIVE STORIES - Full user flows
+// ============================================
+
+/**
+ * **Interactive Demo (Dark)** - Full wallet connection flow
+ *
+ * Click through the entire flow:
+ * 1. Click "Connect Wallet" -> shows loading spinner
+ * 2. After 1.5s -> connected state
+ * 3. Click wallet icon -> open dropdown
+ * 4. Click "Disconnect" -> reset
+ */
+export const Interactive: Story = {
+ args: {
+ status: 'disconnected',
+ },
+ render: function InteractiveRender(_args) {
+ type WalletType = 'backpack' | 'solflare' | 'phantom';
+ type Status = 'disconnected' | 'connecting' | 'connected';
+
+ const [status, setStatus] = useState('disconnected');
+ const [selectedWallet, setSelectedWallet] = useState('backpack');
+ const [selectedNetwork, setSelectedNetwork] = useState('devnet');
+
+ const wallets: Record = {
+ backpack: mockBackpackWallet,
+ solflare: mockSolflareWallet,
+ phantom: mockPhantomWallet,
+ };
+
+ const handleConnect = useCallback(() => {
+ setStatus('connecting');
+ setTimeout(() => setStatus('connected'), 1500);
+ }, []);
+
+ const handleDisconnect = useCallback(async () => {
+ setStatus('disconnected');
+ }, []);
+
+ const handleNetworkChange = useCallback((network: ClusterMoniker) => {
+ setSelectedNetwork(network);
+ }, []);
+
+ return (
+
+
+ Simulate wallet:
+ {(['backpack', 'solflare', 'phantom'] as const).map((w) => (
+
+ ))}
+
+
+
+
+
+ Status: {status}
+ {status === 'connected' && (
+ <>
+ {' '}
+ | Network: {selectedNetwork}
+ >
+ )}
+
+
+ );
+ },
+};
+
+/**
+ * **Interactive Demo (Light)** - Same flow with light theme
+ */
+export const InteractiveLight: Story = {
+ args: {
+ status: 'disconnected',
+ },
+ parameters: {
+ backgrounds: { default: 'light' },
+ },
+ render: function InteractiveLightRender(_args) {
+ type Status = 'disconnected' | 'connecting' | 'connected';
+ const [status, setStatus] = useState('disconnected');
+ const [selectedNetwork, setSelectedNetwork] = useState('mainnet-beta');
+
+ const handleConnect = useCallback(() => {
+ setStatus('connecting');
+ setTimeout(() => setStatus('connected'), 1500);
+ }, []);
+
+ const handleDisconnect = useCallback(async () => {
+ setStatus('disconnected');
+ }, []);
+
+ const handleNetworkChange = useCallback((network: ClusterMoniker) => {
+ setSelectedNetwork(network);
+ }, []);
+
+ return (
+
+
+
+ Status: {status}
+ {status === 'connected' && (
+ <>
+ {' '}
+ | Network: {selectedNetwork}
+ >
+ )}
+
+
+ );
+ },
+};
+
+/**
+ * **Interactive with Balance Loading** - Shows balance loading after connection
+ */
+export const InteractiveBalanceLoading: Story = {
+ args: {
+ status: 'disconnected',
+ },
+ render: function BalanceLoadingRender(_args) {
+ type Status = 'disconnected' | 'connecting' | 'connected';
+ const [status, setStatus] = useState('disconnected');
+ const [balanceLoading, setBalanceLoading] = useState(true);
+ const [balance, setBalance] = useState(null);
+
+ const handleConnect = useCallback(() => {
+ setStatus('connecting');
+ setBalanceLoading(true);
+ setBalance(null);
+ setTimeout(() => {
+ setStatus('connected');
+ setTimeout(() => {
+ setBalance(lamports(3750000000n));
+ setBalanceLoading(false);
+ }, 2000);
+ }, 1500);
+ }, []);
+
+ const handleDisconnect = useCallback(async () => {
+ setStatus('disconnected');
+ setBalance(null);
+ setBalanceLoading(true);
+ }, []);
+
+ return (
+
+
+
+ {status === 'disconnected' && 'Click to connect'}
+ {status === 'connecting' && 'Connecting...'}
+ {status === 'connected' && balanceLoading && '⏳ Balance loading...'}
+ {status === 'connected' && !balanceLoading && balance && '✅ Balance loaded!'}
+
+
+ );
+ },
+};
+
+/**
+ * **Both Themes Side-by-Side** - Compare dark and light interactive flows
+ */
+export const BothStatesInteractive: Story = {
+ args: {
+ status: 'disconnected',
+ },
+ render: function BothStatesRender(_args) {
+ type Status = 'disconnected' | 'connecting' | 'connected';
+ const [firstStatus, setFirstStatus] = useState('disconnected');
+ const [secondStatus, setSecondStatus] = useState('disconnected');
+ const [firstNetwork, setFirstNetwork] = useState('devnet');
+ const [secondNetwork, setSecondNetwork] = useState('mainnet-beta');
+
+ const handleFirstConnect = useCallback(() => {
+ setFirstStatus('connecting');
+ setTimeout(() => setFirstStatus('connected'), 1500);
+ }, []);
+
+ const handleSecondConnect = useCallback(() => {
+ setSecondStatus('connecting');
+ setTimeout(() => setSecondStatus('connected'), 1500);
+ }, []);
+
+ return (
+
+
+ Backpack
+ setFirstStatus('disconnected')}
+ selectedNetwork={firstNetwork}
+ networkStatus="connected"
+ onNetworkChange={setFirstNetwork}
+ />
+
+
+ Solflare
+ setSecondStatus('disconnected')}
+ selectedNetwork={secondNetwork}
+ networkStatus="connected"
+ onNetworkChange={setSecondNetwork}
+ />
+
+
+ );
+ },
+};
+
+// ============================================
+// STATIC STATES - Individual states for docs
+// ============================================
+
+/**
+ * **Disconnected** - Default state when no wallet is connected
+ */
+export const Disconnected: Story = {
+ args: {
+ status: 'disconnected',
+ isReady: true,
+ onConnect: () => console.log('Connect clicked'),
+ },
+};
+
+/**
+ * **Disconnected (Light)** - Light theme disconnected state
+ */
+export const DisconnectedLight: Story = {
+ args: {
+ status: 'disconnected',
+ isReady: true,
+ onConnect: () => console.log('Connect clicked'),
+ },
+ parameters: {
+ backgrounds: { default: 'light' },
+ },
+};
+
+/**
+ * **Connecting** - Loading state while connecting to wallet
+ */
+export const Connecting: Story = {
+ args: {
+ status: 'connecting',
+ isReady: true,
+ },
+};
+
+/**
+ * **Connecting (Light)** - Light theme loading state
+ */
+export const ConnectingLight: Story = {
+ args: {
+ status: 'connecting',
+ isReady: true,
+ },
+ parameters: {
+ backgrounds: { default: 'light' },
+ },
+};
+
+/**
+ * **Connected** - Shows wallet icon, click to see dropdown
+ */
+export const Connected: Story = {
+ args: {
+ status: 'connected',
+ isReady: true,
+ wallet: { address: mockAddress },
+ currentConnector: mockBackpackWallet,
+ balance: mockBalance,
+ onDisconnect: async () => console.log('Disconnect clicked'),
+ },
+};
+
+/**
+ * **Connected (Light)** - Light theme connected state
+ */
+export const ConnectedLight: Story = {
+ args: {
+ status: 'connected',
+ isReady: true,
+ wallet: { address: mockAddress },
+ currentConnector: mockSolflareWallet,
+ balance: lamports(2340000000n),
+ onDisconnect: async () => console.log('Disconnect clicked'),
+ },
+ parameters: {
+ backgrounds: { default: 'light' },
+ },
+};
+
+// ============================================
+// DROPDOWN STANDALONE - For testing dropdown directly
+// ============================================
+
+/**
+ * **Dropdown Only (Dark)** - Test dropdown component in isolation
+ */
+export const DropdownStandalone: Story = {
+ args: {
+ status: 'connected',
+ },
+ render: () => (
+
+ console.log('Disconnect')}
+ />
+
+ ),
+};
+
+/**
+ * **Dropdown with Balance Loading** - Skeleton animation
+ */
+export const DropdownBalanceLoading: Story = {
+ args: {
+ status: 'connected',
+ },
+ render: () => (
+
+ console.log('Disconnect')}
+ />
+
+ ),
+};
+
+// ============================================
+// BUTTON VARIANTS - WalletButton states
+// ============================================
+
+/**
+ * **Button Variants (Dark)** - All button states side-by-side
+ */
+export const ButtonVariants: Story = {
+ args: {
+ status: 'disconnected',
+ },
+ render: (_args) => (
+
+
+ Disconnected
+ console.log('Connect')}>
+ Connect Wallet
+
+
+
+ Connecting
+
+
+
+ Connected
+ console.log('Toggle dropdown')}
+ />
+
+
+ ),
+};
+
+// ============================================
+// ALL STATES GRID - Complete overview
+// ============================================
+
+/**
+ * **All States Grid** - Every state at a glance
+ */
+export const AllStatesGrid: Story = {
+ args: {
+ status: 'disconnected',
+ },
+ render: (_args) => (
+
+
All States
+
+
+ Connect Wallet
+
+
+
+
+
+ ),
+};
+
+// ============================================
+// EDGE CASES
+// ============================================
+
+/**
+ * **Zero Balance** - When wallet has 0 SOL
+ */
+export const ZeroBalance: Story = {
+ args: {
+ status: 'connected',
+ },
+ render: () => (
+
+ console.log('Disconnect')}
+ />
+
+ ),
+};
+
+/**
+ * **Large Balance** - Whale wallet
+ */
+export const LargeBalance: Story = {
+ args: {
+ status: 'connected',
+ },
+ render: () => (
+
+ console.log('Disconnect')}
+ />
+
+ ),
+};
+
+/**
+ * **Not Ready State** - SDK still initializing
+ */
+export const NotReady: Story = {
+ args: {
+ status: 'disconnected',
+ isReady: false,
+ },
+ render: () => (
+
+
+ isReady: false (SDK initializing...)
+
+ ),
+};
diff --git a/packages/components/src/stories/DashboardShell.stories.tsx b/packages/components/src/stories/DashboardShell.stories.tsx
new file mode 100644
index 0000000..8d4b73d
--- /dev/null
+++ b/packages/components/src/stories/DashboardShell.stories.tsx
@@ -0,0 +1,272 @@
+import type { Meta, StoryObj } from '@storybook/react';
+import { DashboardShell } from '../kit-components/ui/dashboard-shell';
+import { Skeleton } from '../kit-components/ui/skeleton';
+
+const meta: Meta = {
+ title: 'Kit Components/Layout/DashboardShell',
+ component: DashboardShell,
+ tags: ['autodocs'],
+ parameters: {
+ layout: 'fullscreen',
+ docs: {
+ description: {
+ component:
+ 'A page-level layout container for Solana dApp dashboards. Provides consistent structure with optional header slot and dot grid background pattern.',
+ },
+ },
+ },
+ argTypes: {
+ header: {
+ description: 'Optional header content (navigation, wallet button, etc.)',
+ control: false,
+ table: {
+ type: { summary: 'React.ReactNode' },
+ },
+ },
+ children: {
+ description: 'Main content area',
+ control: false,
+ table: {
+ type: { summary: 'React.ReactNode' },
+ },
+ },
+ showDotGrid: {
+ description: 'Show the dot grid background pattern',
+ control: 'boolean',
+ table: {
+ defaultValue: { summary: 'true' },
+ type: { summary: 'boolean' },
+ },
+ },
+ className: {
+ description: 'Additional CSS classes',
+ control: 'text',
+ table: {
+ type: { summary: 'string' },
+ },
+ },
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+// for default, basic shell with placeholder content
+export const Default: Story = {
+ args: {
+ children: (
+
+ ),
+ },
+};
+
+// for variants with header, dot grid, and without header
+
+export const WithHeader: Story = {
+ name: 'With Header',
+ args: {
+ header: (
+ <>
+ My dApp
+
+ >
+ ),
+ children: (
+
+ ),
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'The header slot accepts any React content. It renders in a flex container with space-between alignment.',
+ },
+ },
+ },
+};
+
+export const WithoutHeader: Story = {
+ name: 'Without Header',
+ args: {
+ children: (
+
+
No header - content starts at top
+
+ ),
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'When no header prop is provided, the header element is not rendered.',
+ },
+ },
+ },
+};
+
+export const WithDotGrid: Story = {
+ name: 'With Dot Grid (Default)',
+ args: {
+ showDotGrid: true,
+ children: (
+
+
Dot grid pattern visible in background
+
+ ),
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'The dot grid background pattern is shown by default. The pattern uses subtle dots at 3% opacity.',
+ },
+ },
+ },
+};
+
+export const WithoutDotGrid: Story = {
+ name: 'Without Dot Grid',
+ args: {
+ showDotGrid: false,
+ children: (
+
+
Solid background, no dot pattern
+
+ ),
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'Set showDotGrid={false} for a solid background without the pattern.',
+ },
+ },
+ },
+};
+
+// for realistic usage with wallet info and cards
+
+export const DashboardWithCards: Story = {
+ name: 'Composition: Dashboard with Cards',
+ args: {
+ header: (
+ <>
+ Solana Wallet
+
+ Mainnet
+
+
+ >
+ ),
+ children: (
+
+ {/* Balance Card Mock */}
+
+
Total Balance
+
$34.81
+
+
+ USDC
+ $15.50
+
+
+ USDT
+ $10.18
+
+
+
+ {/* Swap Widget Mock */}
+
+
Swap
+
+
+
+
Receive
+
1324.13 USDC
+
+
+
+
+ ),
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'A realistic dashboard layout with header containing wallet info and main content with cards.',
+ },
+ },
+ },
+};
+
+export const LoadingState: Story = {
+ name: 'Composition: Loading State',
+ args: {
+ header: (
+ <>
+
+
+ >
+ ),
+ children: (
+
+ ),
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'DashboardShell works well with Skeleton components for loading states.',
+ },
+ },
+ },
+};
+
+// for playground to experiment with different prop combinations
+
+export const Playground: Story = {
+ name: 'Playground',
+ args: {
+ showDotGrid: false,
+ header: (
+ <>
+ My dApp
+
+ >
+ ),
+ children: (
+
+
Use the controls panel to experiment
+
+ ),
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'Use the controls panel below to experiment with different prop combinations.',
+ },
+ },
+ },
+};
diff --git a/packages/components/src/stories/NetworkSwitcher.stories.tsx b/packages/components/src/stories/NetworkSwitcher.stories.tsx
new file mode 100644
index 0000000..e5bac7c
--- /dev/null
+++ b/packages/components/src/stories/NetworkSwitcher.stories.tsx
@@ -0,0 +1,288 @@
+import type { ClusterMoniker, WalletStatus } from '@solana/client';
+import type { Meta, StoryObj } from '@storybook/react';
+import { useCallback, useState } from 'react';
+import {
+ DEFAULT_NETWORKS,
+ NetworkDropdown,
+ NetworkHeader,
+ NetworkOption,
+ NetworkSwitcher,
+ NetworkTrigger,
+ StatusIndicator,
+} from '../kit-components/ui/network-switcher';
+
+const meta = {
+ title: 'Components/NetworkSwitcher',
+ component: NetworkSwitcher,
+ parameters: {
+ layout: 'centered',
+ },
+ tags: ['autodocs'],
+ argTypes: {
+ selectedNetwork: {
+ control: 'select',
+ options: ['mainnet-beta', 'testnet', 'devnet', 'localnet', 'custom'],
+ description: 'Currently selected network',
+ },
+ status: {
+ control: 'radio',
+ options: ['connected', 'error', 'connecting'],
+ description: 'Network connection status',
+ },
+ disabled: {
+ control: 'boolean',
+ description: 'Disable the switcher',
+ },
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+// ============================================================================
+// MAIN COMPONENT STORIES
+// ============================================================================
+
+/** Collapsed (trigger) */
+export const Collapsed: Story = {
+ args: {
+ selectedNetwork: 'mainnet-beta',
+ status: 'connected',
+ },
+};
+
+/** Expanded (dropdown) */
+export const Expanded: Story = {
+ args: {
+ selectedNetwork: 'mainnet-beta',
+ status: 'connected',
+ open: true,
+ },
+};
+
+// ============================================================================
+// STATUS STORIES
+// ============================================================================
+
+/** Connected status (green dot) */
+export const StatusConnected: Story = {
+ args: {
+ selectedNetwork: 'mainnet-beta',
+ status: 'connected',
+ open: true,
+ },
+};
+
+/** Error status (red dot) */
+export const StatusError: Story = {
+ args: {
+ selectedNetwork: 'mainnet-beta',
+ status: 'error',
+ open: true,
+ },
+};
+
+/** Connecting status (spinner) */
+export const StatusConnecting: Story = {
+ args: {
+ selectedNetwork: 'mainnet-beta',
+ status: 'connecting',
+ open: true,
+ },
+};
+
+// ============================================================================
+// INTERACTIVE STORIES
+// ============================================================================
+
+/** Interactive - click to open/close and switch networks */
+export const Interactive: Story = {
+ render: function InteractiveRender() {
+ const [selectedNetwork, setSelectedNetwork] = useState('mainnet-beta');
+ const [status, setStatus] = useState('connected');
+
+ const handleNetworkChange = useCallback((network: ClusterMoniker) => {
+ setStatus('connecting');
+ // Simulate connection delay
+ setTimeout(() => {
+ setSelectedNetwork(network);
+ setStatus('connected');
+ }, 1500);
+ }, []);
+
+ return (
+
+
+
+ Current: {selectedNetwork} ({status})
+
+
+ );
+ },
+ args: {
+ selectedNetwork: 'mainnet-beta',
+ },
+};
+
+// ============================================================================
+// SUB-COMPONENT STORIES
+// ============================================================================
+
+/** NetworkTrigger - collapsed state */
+export const TriggerStandalone: Story = {
+ render: () => ,
+ args: {
+ selectedNetwork: 'mainnet-beta',
+ },
+};
+
+/** NetworkDropdown - standalone */
+export const DropdownStandalone: Story = {
+ render: () => ,
+ args: {
+ selectedNetwork: 'mainnet-beta',
+ },
+};
+
+/** NetworkHeader - header row */
+export const HeaderStandalone: Story = {
+ render: () => (
+
+
+
+ ),
+ args: {
+ selectedNetwork: 'mainnet-beta',
+ },
+};
+
+/** NetworkOption - individual options */
+export const OptionStandalone: Story = {
+ render: () => (
+
+
+
+
+ ),
+ args: {
+ selectedNetwork: 'mainnet-beta',
+ },
+};
+
+/** StatusIndicator - all states */
+export const StatusIndicators: Story = {
+ render: () => (
+
+
+
+ Connected
+
+
+
+ Error
+
+
+
+ Connecting
+
+
+ ),
+ args: {
+ selectedNetwork: 'mainnet-beta',
+ },
+};
+
+// ============================================================================
+// ALL STATES GRID
+// ============================================================================
+
+/** All states comparison */
+export const AllStatesGrid: Story = {
+ render: () => (
+
+
All States
+
+
+
+ Collapsed
+
+
+
+ Connected
+
+
+
+ Error
+
+
+
+ Connecting
+
+
+
+ ),
+ args: {
+ selectedNetwork: 'mainnet-beta',
+ },
+};
+
+// ============================================================================
+// DISABLED STATE
+// ============================================================================
+
+/** Disabled state */
+export const Disabled: Story = {
+ args: {
+ selectedNetwork: 'mainnet-beta',
+ status: 'connected',
+ disabled: true,
+ },
+};
+
+// ============================================================================
+// USAGE EXAMPLE
+// ============================================================================
+
+/** Code example */
+export const UsageExample: Story = {
+ render: () => (
+
+
Usage Example
+
+ {`import { NetworkSwitcher } from '@framework-kit/components';
+import { useState } from 'react';
+
+function MyApp() {
+ const [network, setNetwork] = useState('mainnet-beta');
+ const [status, setStatus] = useState('connected');
+
+ const handleNetworkChange = async (newNetwork) => {
+ setStatus('connecting');
+ try {
+ await switchRpcEndpoint(newNetwork);
+ setNetwork(newNetwork);
+ setStatus('connected');
+ } catch {
+ setStatus('error');
+ }
+ };
+
+ return (
+
+ );
+}`}
+
+
+ ),
+ args: {
+ selectedNetwork: 'mainnet-beta',
+ },
+};
diff --git a/packages/components/src/stories/Skeleton.stories.tsx b/packages/components/src/stories/Skeleton.stories.tsx
new file mode 100644
index 0000000..be0fb01
--- /dev/null
+++ b/packages/components/src/stories/Skeleton.stories.tsx
@@ -0,0 +1,176 @@
+import type { Meta, StoryObj } from '@storybook/react';
+import { Skeleton } from '../kit-components/ui/skeleton';
+
+const meta: Meta = {
+ title: 'Kit Components/Feedback/Skeleton',
+ component: Skeleton,
+ tags: ['autodocs'],
+ parameters: {
+ docs: {
+ description: {
+ component:
+ 'A loading placeholder that mimics the shape of content while data is being fetched. Use className to define dimensions and shape.',
+ },
+ },
+ },
+ argTypes: {
+ className: {
+ description: 'CSS classes to define size and shape (e.g., "h-4 w-32 rounded-full")',
+ table: {
+ type: { summary: 'string' },
+ },
+ },
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+// default
+
+export const Default: Story = {
+ args: {
+ className: 'h-4 w-32',
+ },
+};
+
+// common use cases
+
+export const TextLine: Story = {
+ name: 'Use Case: Text Line',
+ args: {
+ className: 'h-4 w-48',
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'Placeholder for a single line of text.',
+ },
+ },
+ },
+};
+
+export const TextParagraph: Story = {
+ name: 'Use Case: Paragraph',
+ render: (args) => (
+
+
+
+
+
+ ),
+ parameters: {
+ docs: {
+ description: {
+ story: 'Multiple skeletons to represent a paragraph of text.',
+ },
+ },
+ },
+};
+
+export const Avatar: Story = {
+ name: 'Use Case: Avatar',
+ args: {
+ className: 'h-12 w-12 rounded-full',
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'Circular skeleton for user avatars or profile images.',
+ },
+ },
+ },
+};
+
+export const Button: Story = {
+ name: 'Use Case: Button',
+ args: {
+ className: 'h-10 w-24 rounded-md',
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'Placeholder for a button element.',
+ },
+ },
+ },
+};
+
+export const Card: Story = {
+ name: 'Use Case: Card',
+ args: {
+ className: 'h-32 w-full rounded-lg',
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'Large skeleton for card or image placeholders.',
+ },
+ },
+ },
+};
+
+// for composition examples
+
+export const ProfileCard: Story = {
+ name: 'Composition: Profile Card',
+ render: (args) => (
+
+ ),
+ parameters: {
+ docs: {
+ description: {
+ story: 'Multiple skeletons composed to represent a loading profile card.',
+ },
+ },
+ },
+};
+
+export const TransactionList: Story = {
+ name: 'Composition: Transaction List',
+ render: (args) => (
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
+ ),
+ parameters: {
+ docs: {
+ description: {
+ story: 'Skeleton layout for a list of transactions - common in Solana dApps.',
+ },
+ },
+ },
+};
+
+// for playground
+
+export const Playground: Story = {
+ name: 'Playground',
+ args: {
+ className: 'h-8 w-48',
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'Use the controls panel to experiment with different sizes. Try classes like "h-12 w-12 rounded-full" for an avatar.',
+ },
+ },
+ },
+};
diff --git a/packages/components/src/stories/SwapInput.stories.tsx b/packages/components/src/stories/SwapInput.stories.tsx
new file mode 100644
index 0000000..120b006
--- /dev/null
+++ b/packages/components/src/stories/SwapInput.stories.tsx
@@ -0,0 +1,180 @@
+import type { Meta, StoryObj } from '@storybook/react';
+import type { SwapTokenInfo } from '../kit-components/ui/swap-input';
+import { SwapInput } from '../kit-components/ui/swap-input';
+
+const SOL_LOGO = 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/solana/info/logo.png';
+const USDC_LOGO =
+ 'https://raw.githubusercontent.com/solana-labs/token-list/main/assets/mainnet/EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v/logo.png';
+
+const solToken: SwapTokenInfo = { symbol: 'SOL', name: 'Solana', logoURI: SOL_LOGO };
+const usdcToken: SwapTokenInfo = { symbol: 'USDC', name: 'USD Coin', logoURI: USDC_LOGO };
+
+const tokenList: SwapTokenInfo[] = [
+ solToken,
+ usdcToken,
+ { symbol: 'USDT', name: 'Tether' },
+ { symbol: 'BONK', name: 'Bonk' },
+ { symbol: 'JUP', name: 'Jupiter' },
+ { symbol: 'RAY', name: 'Raydium' },
+];
+
+const meta = {
+ title: 'Kit Components/Input/SwapInput',
+ component: SwapInput,
+ tags: ['autodocs'],
+ parameters: {
+ layout: 'centered',
+ },
+ argTypes: {
+ size: {
+ control: 'select',
+ options: ['sm', 'md', 'lg'],
+ },
+ isLoading: {
+ control: 'boolean',
+ },
+ disabled: {
+ control: 'boolean',
+ },
+ receiveReadOnly: {
+ control: 'boolean',
+ },
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+/**
+ * Default with empty amounts
+ */
+export const Default: Story = {
+ args: {
+ payAmount: '',
+ receiveAmount: '',
+ payToken: solToken,
+ receiveToken: usdcToken,
+ payTokens: tokenList,
+ receiveTokens: tokenList,
+ payBalance: '4.32',
+ onSwapDirection: () => console.log('Swap direction'),
+ onPayAmountChange: () => {},
+ onPayTokenChange: (t) => console.log('Pay token changed:', t.symbol),
+ onReceiveTokenChange: (t) => console.log('Receive token changed:', t.symbol),
+ },
+};
+
+/**
+ * Zero amounts entered
+ */
+export const ZeroAmounts: Story = {
+ args: {
+ payAmount: '0.00',
+ receiveAmount: '0.00',
+ payToken: solToken,
+ receiveToken: usdcToken,
+ payTokens: tokenList,
+ receiveTokens: tokenList,
+ payBalance: '4.32',
+ onSwapDirection: () => console.log('Swap direction'),
+ onPayAmountChange: () => {},
+ },
+};
+
+/**
+ * Filled state with amounts entered
+ */
+export const Filled: Story = {
+ args: {
+ payAmount: '1.21',
+ receiveAmount: '1324.13',
+ payToken: solToken,
+ receiveToken: usdcToken,
+ payTokens: tokenList,
+ receiveTokens: tokenList,
+ payBalance: '4.32',
+ onSwapDirection: () => console.log('Swap direction'),
+ onPayAmountChange: () => {},
+ },
+};
+
+/**
+ * Insufficient balance error state
+ */
+export const InsufficientBalance: Story = {
+ args: {
+ payAmount: '4.68',
+ receiveAmount: '1324.13',
+ payToken: solToken,
+ receiveToken: usdcToken,
+ payTokens: tokenList,
+ receiveTokens: tokenList,
+ payBalance: '4.32',
+ onSwapDirection: () => console.log('Swap direction'),
+ onPayAmountChange: () => {},
+ },
+};
+
+/**
+ * Loading/skeleton state
+ */
+export const Loading: Story = {
+ args: {
+ payAmount: '',
+ receiveAmount: '',
+ isLoading: true,
+ },
+};
+
+/**
+ * No token selected yet — with dropdown to pick one
+ */
+export const NoTokenSelected: Story = {
+ args: {
+ payAmount: '',
+ receiveAmount: '',
+ payBalance: '4.32',
+ payTokens: tokenList,
+ receiveTokens: tokenList,
+ onPayTokenChange: (t) => console.log('Pay token changed:', t.symbol),
+ onReceiveTokenChange: (t) => console.log('Receive token changed:', t.symbol),
+ onPayAmountChange: () => {},
+ },
+};
+
+/**
+ * Small size
+ */
+export const SmallSize: Story = {
+ args: {
+ ...Filled.args,
+ size: 'sm',
+ },
+};
+
+/**
+ * Large size
+ */
+export const LargeSize: Story = {
+ args: {
+ ...Filled.args,
+ size: 'lg',
+ },
+};
+
+/**
+ * Disabled state
+ */
+export const Disabled: Story = {
+ args: {
+ ...Filled.args,
+ disabled: true,
+ },
+};
diff --git a/packages/components/src/stories/TransactionTable.stories.tsx b/packages/components/src/stories/TransactionTable.stories.tsx
new file mode 100644
index 0000000..4db4372
--- /dev/null
+++ b/packages/components/src/stories/TransactionTable.stories.tsx
@@ -0,0 +1,199 @@
+import { address } from '@solana/kit';
+import type { Meta, StoryObj } from '@storybook/react';
+import type { ClassifiedTransaction } from 'tx-indexer';
+import { TransactionTable } from '../kit-components/ui/transaction-table';
+
+const meta: Meta = {
+ title: 'Kit Components/Data/TransactionTable',
+ component: TransactionTable,
+ tags: ['autodocs'],
+ parameters: {
+ layout: 'fullscreen',
+ },
+ argTypes: {
+ size: {
+ control: 'select',
+ options: ['sm', 'md', 'lg'],
+ },
+ isLoading: {
+ control: 'boolean',
+ },
+ dateFilter: {
+ control: 'select',
+ options: ['all', '7d', '30d', '90d'],
+ },
+ typeFilter: {
+ control: 'select',
+ options: ['all', 'sent', 'received'],
+ },
+ },
+ decorators: [
+ (Story) => (
+
+ ),
+ ],
+};
+
+export default meta;
+type Story = StoryObj;
+
+const WALLET = address('6DMh7fYHrKdCJwCFUQfMfNAdLADi9xqsRKNzmZA31DkK');
+const OTHER = address('Hb6dzd4pYxmFYKkJDWuhzBEUkkaE93sFcvXYtriTkmw9');
+
+function makeTransferTx(params: {
+ signature: string;
+ blockTimeSeconds: number;
+ sender: string;
+ receiver: string;
+ symbol: string;
+ amountUi: number;
+ fiat?: number;
+ logoURI?: string;
+}): ClassifiedTransaction {
+ return {
+ tx: {
+ signature: params.signature,
+ slot: 0,
+ blockTime: params.blockTimeSeconds,
+ err: null,
+ programIds: [],
+ protocol: null,
+ memo: null,
+ },
+ classification: {
+ primaryType: 'transfer',
+ primaryAmount: {
+ token: {
+ mint: 'So11111111111111111111111111111111111111112',
+ symbol: params.symbol,
+ decimals: 9,
+ logoURI: params.logoURI,
+ },
+ amountRaw: '0',
+ amountUi: params.amountUi,
+ fiat: params.fiat
+ ? {
+ currency: 'USD',
+ amount: params.fiat,
+ pricePerUnit: params.fiat / Math.max(params.amountUi, 1),
+ }
+ : undefined,
+ },
+ sender: params.sender,
+ receiver: params.receiver,
+ counterparty: {
+ type: 'unknown',
+ address: params.sender === WALLET ? params.receiver : params.sender,
+ },
+ confidence: 1,
+ },
+ legs: [
+ {
+ accountId: params.sender,
+ side: 'debit',
+ amount: {
+ token: {
+ mint: 'So11111111111111111111111111111111111111112',
+ symbol: params.symbol,
+ decimals: 9,
+ logoURI: params.logoURI,
+ },
+ amountRaw: '0',
+ amountUi: params.amountUi,
+ },
+ role: 'sent',
+ },
+ {
+ accountId: params.receiver,
+ side: 'credit',
+ amount: {
+ token: {
+ mint: 'So11111111111111111111111111111111111111112',
+ symbol: params.symbol,
+ decimals: 9,
+ logoURI: params.logoURI,
+ },
+ amountRaw: '0',
+ amountUi: params.amountUi,
+ },
+ role: 'received',
+ },
+ ],
+ } as unknown as ClassifiedTransaction;
+}
+
+const SOL_LOGO = 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/solana/info/logo.png';
+const USDC_LOGO =
+ 'https://raw.githubusercontent.com/solana-labs/token-list/main/assets/mainnet/EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v/logo.png';
+
+const SAMPLE_TXS: ClassifiedTransaction[] = [
+ makeTransferTx({
+ signature: '5xG7abc...9Kp2',
+ blockTimeSeconds: 1767139200,
+ sender: WALLET,
+ receiver: OTHER,
+ symbol: 'SOL',
+ amountUi: 3,
+ fiat: 399.62,
+ logoURI: SOL_LOGO,
+ }),
+ makeTransferTx({
+ signature: '6xG7abc...9Kp3',
+ blockTimeSeconds: 1768435200,
+ sender: OTHER,
+ receiver: WALLET,
+ symbol: 'USDC',
+ amountUi: 95,
+ logoURI: USDC_LOGO,
+ }),
+ makeTransferTx({
+ signature: '7xG7abc...9Kp4',
+ blockTimeSeconds: 1767139200,
+ sender: WALLET,
+ receiver: OTHER,
+ symbol: 'SOL',
+ amountUi: 3,
+ fiat: 399.62,
+ logoURI: SOL_LOGO,
+ }),
+ makeTransferTx({
+ signature: '8xG7abc...9Kp5',
+ blockTimeSeconds: 1767139200,
+ sender: OTHER,
+ receiver: WALLET,
+ symbol: 'USDC',
+ amountUi: 95,
+ logoURI: USDC_LOGO,
+ }),
+];
+
+const handleViewTransaction = (tx: ClassifiedTransaction) => {
+ window.open(`https://explorer.solana.com/tx/${String(tx.tx.signature)}`, '_blank');
+};
+
+export const Default: Story = {
+ args: {
+ walletAddress: WALLET,
+ transactions: SAMPLE_TXS,
+ onViewTransaction: handleViewTransaction,
+ },
+};
+
+export const Loading: Story = {
+ args: {
+ walletAddress: WALLET,
+ transactions: SAMPLE_TXS,
+ isLoading: true,
+ },
+};
+
+export const Empty: Story = {
+ args: {
+ walletAddress: WALLET,
+ transactions: [],
+ },
+};
diff --git a/packages/components/src/stories/TransactionToast.stories.tsx b/packages/components/src/stories/TransactionToast.stories.tsx
new file mode 100644
index 0000000..ca43cca
--- /dev/null
+++ b/packages/components/src/stories/TransactionToast.stories.tsx
@@ -0,0 +1,369 @@
+import type { Meta, StoryObj } from '@storybook/react';
+import {
+ TransactionToast,
+ TransactionToastProvider,
+ useTransactionToast,
+} from '../kit-components/ui/transaction-toast';
+
+const mockSignature = '5UfDuX7hXrVoNMYhFpFdYxGE8mLqZnzCYQEHZ8Bj9K8xN2FvYYv5VT7qYRqXLwGKSk3nYhZx';
+
+const meta: Meta = {
+ title: 'Kit Components/Feedback/TransactionToast',
+ component: TransactionToast,
+ tags: ['autodocs'],
+ parameters: {
+ docs: {
+ description: {
+ component:
+ 'Displays transaction status notifications with a link to Solana Explorer. Supports pending, success, and error states for sent, received, and swap transactions.',
+ },
+ },
+ },
+ argTypes: {
+ signature: {
+ description: 'Solana transaction signature (base58 encoded)',
+ table: {
+ type: { summary: 'string' },
+ },
+ },
+ status: {
+ description: 'Current status of the transaction',
+ control: 'select',
+ options: ['pending', 'success', 'error'],
+ table: {
+ type: { summary: "'pending' | 'success' | 'error'" },
+ },
+ },
+ type: {
+ description: 'Type of transaction - determines the message shown',
+ control: 'select',
+ options: ['sent', 'received', 'swapped'],
+ table: {
+ defaultValue: { summary: 'sent' },
+ type: { summary: "'sent' | 'received' | 'swapped'" },
+ },
+ },
+ network: {
+ description: 'Solana network for Explorer URL generation',
+ control: 'select',
+ options: ['mainnet-beta', 'devnet', 'testnet'],
+ table: {
+ defaultValue: { summary: 'mainnet-beta' },
+ type: { summary: 'ClusterMoniker' },
+ },
+ },
+ className: {
+ description: 'Additional CSS classes to apply',
+ table: {
+ type: { summary: 'string' },
+ },
+ },
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+// default
+
+export const Default: Story = {
+ args: {
+ signature: mockSignature,
+ status: 'success',
+ type: 'sent',
+ },
+};
+
+// for status variants
+
+export const Pending: Story = {
+ name: 'Status: Pending',
+ args: {
+ signature: mockSignature,
+ status: 'pending',
+ type: 'sent',
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'Shows a spinning loader while the transaction is being confirmed.',
+ },
+ },
+ },
+};
+
+export const Success: Story = {
+ name: 'Status: Success',
+ args: {
+ signature: mockSignature,
+ status: 'success',
+ type: 'sent',
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'Shows a green checkmark when the transaction is confirmed.',
+ },
+ },
+ },
+};
+
+export const ErrorState: Story = {
+ name: 'Status: Error',
+ args: {
+ signature: mockSignature,
+ status: 'error',
+ type: 'sent',
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'Shows a red X icon when the transaction fails. Has assertive aria-live for accessibility.',
+ },
+ },
+ },
+};
+
+// for transaction type variants
+
+export const Sent: Story = {
+ name: 'Type: Sent',
+ args: {
+ signature: mockSignature,
+ status: 'success',
+ type: 'sent',
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'Message: "Transaction sent successfully"',
+ },
+ },
+ },
+};
+
+export const Received: Story = {
+ name: 'Type: Received',
+ args: {
+ signature: mockSignature,
+ status: 'success',
+ type: 'received',
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'Message: "Transaction received successfully"',
+ },
+ },
+ },
+};
+
+export const Swapped: Story = {
+ name: 'Type: Swapped',
+ args: {
+ signature: mockSignature,
+ status: 'success',
+ type: 'swapped',
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'Message: "Swap completed successfully"',
+ },
+ },
+ },
+};
+
+// for network variants
+
+export const Mainnet: Story = {
+ name: 'Network: Mainnet',
+ args: {
+ signature: mockSignature,
+ status: 'success',
+ type: 'sent',
+ network: 'mainnet-beta',
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'Explorer link points to mainnet (no cluster param in URL).',
+ },
+ },
+ },
+};
+
+export const Devnet: Story = {
+ name: 'Network: Devnet',
+ args: {
+ signature: mockSignature,
+ status: 'success',
+ type: 'sent',
+ network: 'devnet',
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'Explorer link includes ?cluster=devnet.',
+ },
+ },
+ },
+};
+
+export const Testnet: Story = {
+ name: 'Network: Testnet',
+ args: {
+ signature: mockSignature,
+ status: 'success',
+ type: 'sent',
+ network: 'testnet',
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'Explorer link includes ?cluster=testnet.',
+ },
+ },
+ },
+};
+
+// for all status × type combinations
+
+export const AllCombinations: Story = {
+ name: 'All Combinations',
+ render: () => (
+
+ ),
+ parameters: {
+ docs: {
+ description: {
+ story: 'All 9 combinations of status (pending/success/error) and type (sent/received/swapped).',
+ },
+ },
+ },
+};
+
+// for composition: with provider
+
+const InteractiveDemo = () => {
+ const { toast, update } = useTransactionToast();
+
+ const handleTriggerToast = () => {
+ const id = toast({
+ signature: mockSignature,
+ status: 'pending',
+ type: 'sent',
+ });
+
+ // Simulate transaction confirmation after 2 seconds
+ setTimeout(() => {
+ update(id, { status: 'success' });
+ }, 2000);
+ };
+
+ const handleTriggerError = () => {
+ toast({
+ signature: mockSignature,
+ status: 'error',
+ type: 'sent',
+ });
+ };
+
+ return (
+
+
+
+
+ );
+};
+
+export const WithProvider: Story = {
+ name: 'Composition: With Provider',
+ render: () => (
+
+
+
+ ),
+ parameters: {
+ docs: {
+ description: {
+ story: 'Interactive demo using TransactionToastProvider and useTransactionToast hook. Click "Send Transaction" to see a pending toast that updates to success after 2 seconds.',
+ },
+ },
+ },
+};
+
+export const WithProviderDark: Story = {
+ name: 'Composition: With Provider (Dark)',
+ render: () => (
+
+
+
+
+
+ ),
+ parameters: {
+ backgrounds: { default: 'dark' },
+ docs: {
+ description: {
+ story: 'Same interactive demo with dark background.',
+ },
+ },
+ },
+};
+
+// for playground
+
+export const Playground: Story = {
+ name: 'Playground',
+ args: {
+ signature: mockSignature,
+ status: 'success',
+ type: 'sent',
+ network: 'mainnet-beta',
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'Use the controls panel to experiment with all props.',
+ },
+ },
+ },
+};
diff --git a/packages/components/src/stories/WalletModal.stories.tsx b/packages/components/src/stories/WalletModal.stories.tsx
new file mode 100644
index 0000000..03d71c0
--- /dev/null
+++ b/packages/components/src/stories/WalletModal.stories.tsx
@@ -0,0 +1,275 @@
+import type { WalletConnectorMetadata } from '@solana/client';
+import type { Meta, StoryObj } from '@storybook/react';
+import { useState } from 'react';
+import {
+ ConnectingView,
+ ErrorView,
+ ModalHeader,
+ type ModalView,
+ NoWalletLink,
+ WalletCard,
+ WalletLabel,
+ WalletModal,
+} from '../kit-components/ui/wallet-modal';
+import backpackIcon from '../kit-components/ui/wallet-modal/assets/backpack.png';
+// Import wallet icons from assets
+import phantomIcon from '../kit-components/ui/wallet-modal/assets/phantom.png';
+import solflareIcon from '../kit-components/ui/wallet-modal/assets/solflare.png';
+
+// Mock wallet data
+const MOCK_WALLETS: WalletConnectorMetadata[] = [
+ {
+ id: 'phantom',
+ name: 'Phantom',
+ icon: phantomIcon,
+ ready: true,
+ },
+ {
+ id: 'solflare',
+ name: 'Solflare',
+ icon: solflareIcon,
+ ready: true,
+ },
+ {
+ id: 'backpack',
+ name: 'Backpack',
+ icon: backpackIcon,
+ ready: true,
+ },
+];
+
+const meta = {
+ title: 'Components/WalletModal',
+ component: WalletModal,
+ parameters: {
+ layout: 'centered',
+ backgrounds: {
+ default: 'dark',
+ values: [
+ { name: 'dark', value: '#18181B' },
+ { name: 'light', value: '#F4F4F5' },
+ ],
+ },
+ },
+ tags: ['autodocs'],
+ argTypes: {
+ view: {
+ control: 'select',
+ options: ['list', 'connecting', 'error'],
+ },
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+// ============================================
+// MAIN MODAL STORIES
+// ============================================
+
+/** Default wallet list view */
+export const ListView: Story = {
+ args: {
+ wallets: MOCK_WALLETS,
+ view: 'list',
+ },
+};
+
+/** Connecting state */
+export const Connecting: Story = {
+ args: {
+ wallets: MOCK_WALLETS,
+ view: 'connecting',
+ connectingWallet: MOCK_WALLETS[0],
+ },
+};
+
+/** Error state */
+export const ErrorState: Story = {
+ args: {
+ wallets: MOCK_WALLETS,
+ view: 'error',
+ error: {
+ title: 'Connection failed',
+ message: 'Unable to connect. Please try again.',
+ },
+ },
+};
+
+/** Empty wallet list */
+export const EmptyWalletList: Story = {
+ args: {
+ wallets: [],
+ view: 'list',
+ },
+};
+
+/** Single wallet in list */
+export const SingleWallet: Story = {
+ args: {
+ wallets: [MOCK_WALLETS[0]],
+ view: 'list',
+ },
+};
+
+/** Without "I don't have a wallet" link */
+export const WithoutNoWalletLink: Story = {
+ args: {
+ wallets: MOCK_WALLETS,
+ view: 'list',
+ showNoWalletLink: false,
+ },
+};
+
+// ============================================
+// INTERACTIVE STORIES
+// ============================================
+
+/** Interactive modal with full flow */
+export const Interactive: Story = {
+ args: {
+ wallets: MOCK_WALLETS,
+ },
+ render: function InteractiveModal(args) {
+ const [view, setView] = useState('list');
+ const [connectingWallet, setConnectingWallet] = useState(null);
+ const [error, setError] = useState<{ title?: string; message?: string } | null>(null);
+
+ const handleSelectWallet = (wallet: WalletConnectorMetadata) => {
+ setConnectingWallet(wallet);
+ setView('connecting');
+
+ // Simulate connection attempt
+ setTimeout(() => {
+ // 50% chance of success
+ if (Math.random() > 0.5) {
+ alert(`Connected to ${wallet.name}!`);
+ setView('list');
+ setConnectingWallet(null);
+ } else {
+ setError({
+ title: 'Connection failed',
+ message: 'User rejected the connection request.',
+ });
+ setView('error');
+ }
+ }, 2000);
+ };
+
+ const handleBack = () => {
+ setView('list');
+ setConnectingWallet(null);
+ setError(null);
+ };
+
+ const handleRetry = () => {
+ if (connectingWallet) {
+ handleSelectWallet(connectingWallet);
+ }
+ };
+
+ const handleClose = () => {
+ alert('Modal closed');
+ };
+
+ return (
+
+ );
+ },
+};
+
+// ============================================
+// SUB-COMPONENT STORIES
+// ============================================
+
+/** WalletCard - All positions */
+export const WalletCardPositions: Story = {
+ args: {
+ wallets: MOCK_WALLETS,
+ },
+ render: () => (
+
+
+
+
+
+ ),
+};
+
+/** WalletLabel - All variants */
+export const WalletLabelVariants: Story = {
+ args: { wallets: MOCK_WALLETS },
+ render: () => (
+
+
+
+
+
+ ),
+};
+
+/** ModalHeader - Variants */
+export const ModalHeaderVariants: Story = {
+ args: { wallets: MOCK_WALLETS },
+ render: () => (
+
+ ),
+};
+
+/** ConnectingView - Standalone */
+export const ConnectingViewStandalone: Story = {
+ args: { wallets: MOCK_WALLETS },
+ render: () => (
+
+
+
+ ),
+};
+
+/** ErrorView - Standalone */
+export const ErrorViewStandalone: Story = {
+ args: { wallets: MOCK_WALLETS },
+ render: () => (
+
+
+
+ ),
+};
+
+/** NoWalletLink - Standalone */
+export const NoWalletLinkStandalone: Story = {
+ args: { wallets: MOCK_WALLETS },
+ render: () => (
+
+
+
+ ),
+};
+
+/** All states grid */
+export const AllStatesGrid: Story = {
+ args: { wallets: MOCK_WALLETS },
+ render: () => (
+
+
+
+
+
+ ),
+};
diff --git a/packages/components/tsconfig.app.json b/packages/components/tsconfig.app.json
new file mode 100644
index 0000000..c63ee7b
--- /dev/null
+++ b/packages/components/tsconfig.app.json
@@ -0,0 +1,34 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "types": ["vite/client"],
+ "skipLibCheck": true,
+
+ /* Path aliases */
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./src/*"]
+ },
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true
+ },
+ "include": ["src"]
+}
diff --git a/packages/components/tsconfig.json b/packages/components/tsconfig.json
new file mode 100644
index 0000000..9ae8d5f
--- /dev/null
+++ b/packages/components/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "compilerOptions": {
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "files": [],
+ "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
+}
diff --git a/packages/components/tsconfig.node.json b/packages/components/tsconfig.node.json
new file mode 100644
index 0000000..e75109e
--- /dev/null
+++ b/packages/components/tsconfig.node.json
@@ -0,0 +1,26 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+ "target": "ES2023",
+ "lib": ["ES2023"],
+ "module": "ESNext",
+ "types": ["node"],
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/packages/components/vite.config.ts b/packages/components/vite.config.ts
new file mode 100644
index 0000000..5fcdd41
--- /dev/null
+++ b/packages/components/vite.config.ts
@@ -0,0 +1,14 @@
+import path from 'node:path';
+import tailwindcss from '@tailwindcss/vite';
+import react from '@vitejs/plugin-react';
+import { defineConfig } from 'vite';
+
+// https://vite.dev/config/
+export default defineConfig({
+ plugins: [react(), tailwindcss()],
+ resolve: {
+ alias: {
+ '@': path.resolve(__dirname, './src'),
+ },
+ },
+});
diff --git a/packages/components/vitest.shims.d.ts b/packages/components/vitest.shims.d.ts
new file mode 100644
index 0000000..03b1801
--- /dev/null
+++ b/packages/components/vitest.shims.d.ts
@@ -0,0 +1 @@
+///
diff --git a/vitest.config.ts b/vitest.config.ts
index 3a95025..ef9ac50 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -1,9 +1,13 @@
-import { resolve } from 'node:path';
+import path, { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
+import { storybookTest } from '@storybook/addon-vitest/vitest-plugin';
+import { playwright } from '@vitest/browser-playwright';
import { defineConfig } from 'vitest/config';
-const workspaceRoot = fileURLToPath(new URL('.', import.meta.url));
+const dirname = typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url));
+// More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon
+const workspaceRoot = fileURLToPath(new URL('.', import.meta.url));
export default defineConfig({
root: workspaceRoot,
test: {
@@ -21,6 +25,32 @@ export default defineConfig({
reportsDirectory: './coverage',
include: ['packages/**/*.{ts,tsx}', 'examples/**/*.{ts,tsx}', 'tests/**/*.{ts,tsx}'],
},
+ projects: [
+ {
+ extends: true,
+ plugins: [
+ // The plugin will run tests for the stories defined in your Storybook config
+ // See options at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon#storybooktest
+ storybookTest({
+ configDir: path.join(dirname, '.storybook'),
+ }),
+ ],
+ test: {
+ name: 'storybook',
+ browser: {
+ enabled: true,
+ headless: true,
+ provider: playwright({}),
+ instances: [
+ {
+ browser: 'chromium',
+ },
+ ],
+ },
+ setupFiles: ['packages/components/.storybook/vitest.setup.ts'],
+ },
+ },
+ ],
},
resolve: {
alias: {