diff --git a/apps/web/src/features/design-system/design-system-page.tsx b/apps/web/src/features/design-system/design-system-page.tsx
new file mode 100644
index 0000000..1657234
--- /dev/null
+++ b/apps/web/src/features/design-system/design-system-page.tsx
@@ -0,0 +1,295 @@
+import { useState } from "react"
+import { Button } from "@workspace/ui/components/button"
+import { Input } from "@workspace/ui/components/input"
+import { Badge } from "@workspace/ui/components/badge"
+import { Tabs, TabsList, TabsTrigger, TabsContent } from "@workspace/ui/components/tabs"
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@workspace/ui/components/dialog"
+import { Skeleton } from "@workspace/ui/components/skeleton"
+import { useTheme } from "@/ui/theme-provider"
+import { Separator } from "@workspace/ui/components/separator"
+
+function ComponentSection({
+ title,
+ description,
+ children,
+}: {
+ title: string
+ description?: string
+ children: React.ReactNode
+}) {
+ return (
+
+
+
{title}
+ {description &&
{description}
}
+
+ {children}
+
+ )
+}
+
+function ButtonExamples() {
+ return (
+
+
+
Variants
+
+
+
+
+
+
+
+
+
+
+
+
Sizes
+
+
+
+
+
+
+
+
+
+
+
+
+
+
States
+
+
+
+
+
+
+ )
+}
+
+function InputExamples() {
+ const [value, setValue] = useState("")
+
+ return (
+
+
+
+
+
+
+
+
+
setValue(e.target.value)} placeholder="Type here..." />
+ {value &&
You typed: {value}
}
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+function BadgeExamples() {
+ return (
+
+
+
Variants
+
+ Default
+ Secondary
+ Outline
+ Destructive
+
+
+
+
+
With Icon
+
+
+ ✓ Success
+
+
+ ✕ Error
+
+
+ ⓘ Info
+
+
+
+
+ )
+}
+
+function SkeletonExamples() {
+ return (
+
+
+
Text Skeleton
+
+
+
+
+
+
+
+
+
Avatar Skeleton
+
+
+
+
+
+
+
+ )
+}
+
+function DialogExamples() {
+ return (
+
+
+
+ )
+}
+
+function TabsExamples() {
+ return (
+
+
+ Tab 1
+ Tab 2
+ Tab 3
+
+
+ Content for Tab 1
+
+
+ Content for Tab 2
+
+
+ Content for Tab 3
+
+
+ )
+}
+
+export function DesignSystemPage() {
+ const { theme, setTheme } = useTheme()
+ const [viewport, setViewport] = useState<"mobile" | "tablet" | "desktop">("desktop")
+
+ return (
+
+
+
+
+
+
Design System Gallery
+
+ Development-only gallery showing all components and tokens
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/apps/web/src/routes/design-system.tsx b/apps/web/src/routes/design-system.tsx
new file mode 100644
index 0000000..0d64b01
--- /dev/null
+++ b/apps/web/src/routes/design-system.tsx
@@ -0,0 +1,15 @@
+import { createFileRoute } from "@tanstack/react-router"
+import { DesignSystemPage } from "@/features/design-system/design-system-page"
+
+export const Route = createFileRoute("/design-system")({
+ component: () => {
+ if (import.meta.env.PROD) {
+ return (
+
+
Design system gallery is only available in development.
+
+ )
+ }
+ return
+ },
+})
diff --git a/packages/ui/README.md b/packages/ui/README.md
new file mode 100644
index 0000000..ff0bc68
--- /dev/null
+++ b/packages/ui/README.md
@@ -0,0 +1,73 @@
+# @workspace/ui
+
+A shared React component library with accessible UI primitives built with Tailwind CSS and Base UI.
+
+## Components
+
+All components are located in `src/components/` and exported through the package's export map in `package.json`.
+
+- **Button** - Versatile button component with multiple variants and sizes
+- **Input** - Text input field with support for invalid states
+- **Badge** - Label component for categorization
+- **Dialog** - Modal dialog for focused user interactions
+- **Tabs** - Tabbed interface for content organization
+- **Tabs** - Tabbed interface with keyboard navigation support
+- **Slider** - Range input slider component
+- **Tooltip** - Contextual information on hover
+- **Sheet** - Side panel component
+- **Skeleton** - Content placeholder loaders
+- **Separator** - Divider component
+- **TokenAvatar** - Token icon with fallback initials
+- **TokenPair** - Overlapping token pair visual
+- **TransactionStatus** - Transaction state indicator with actions
+
+## Testing
+
+All interactive components include accessibility tests using `vitest-axe` to ensure WCAG compliance.
+
+### Running tests
+
+```bash
+# Run all tests
+npm run test
+
+# Run tests in watch mode
+npm run test -- --watch
+
+# View test results in UI
+npm run test:ui
+
+# Generate coverage report
+npm run test:coverage
+```
+
+### Test coverage
+
+Every interactive primitive has automated accessibility checks that:
+- Verify no critical or serious axe violations
+- Test keyboard navigation for interactive components
+- Validate ARIA attributes and roles
+- Check focus management
+
+## Development
+
+```bash
+# Format code
+npm run format
+
+# Type check
+npm run typecheck
+
+# Lint code
+npm run lint
+```
+
+## Usage
+
+Import components from the package:
+
+```tsx
+import { Button } from "@workspace/ui/components/button"
+import { Input } from "@workspace/ui/components/input"
+import { TokenAvatar } from "@workspace/ui/components/token-avatar"
+```
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 6aa97b8..7f14893 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -6,7 +6,10 @@
"scripts": {
"lint": "eslint",
"format": "prettier --write \"**/*.{ts,tsx}\"",
- "typecheck": "tsc --noEmit"
+ "typecheck": "tsc --noEmit",
+ "test": "vitest",
+ "test:ui": "vitest --ui",
+ "test:coverage": "vitest --coverage"
},
"dependencies": {
"@base-ui/react": "^1.4.1",
@@ -23,15 +26,22 @@
"zod": "^3.25.76"
},
"devDependencies": {
+ "@repo/vitest-config": "workspace:*",
"@tailwindcss/vite": "^4.1.18",
+ "@testing-library/jest-dom": "^6.9.1",
+ "@testing-library/react": "^16.3.2",
+ "@testing-library/user-event": "^14.6.1",
"@turbo/gen": "^2.8.1",
"@types/node": "^25.1.0",
"@types/react": "^19.2.10",
"@types/react-dom": "^19.2.3",
"@tanstack/eslint-config": "^0.3.0",
+ "axe-core": "^4.12.1",
"eslint": "^9.39.2",
"tailwindcss": "^4.1.18",
- "typescript": "^5.9.3"
+ "typescript": "^5.9.3",
+ "vitest": "3",
+ "vitest-axe": "^0.1.0"
},
"exports": {
"./globals.css": "./src/styles/globals.css",
diff --git a/packages/ui/setup-tests.ts b/packages/ui/setup-tests.ts
new file mode 100644
index 0000000..7604e66
--- /dev/null
+++ b/packages/ui/setup-tests.ts
@@ -0,0 +1,7 @@
+import "@testing-library/jest-dom/vitest"
+import { afterEach } from "vitest"
+import { cleanup } from "@testing-library/react"
+
+afterEach(() => {
+ cleanup()
+})
diff --git a/packages/ui/src/components/button.test.tsx b/packages/ui/src/components/button.test.tsx
new file mode 100644
index 0000000..ebf096b
--- /dev/null
+++ b/packages/ui/src/components/button.test.tsx
@@ -0,0 +1,28 @@
+import { describe, it, expect } from "vitest"
+import { render } from "@testing-library/react"
+import { axe } from "vitest-axe"
+import { Button } from "./button"
+
+describe("Button accessibility", () => {
+ it("has no accessibility violations", async () => {
+ const { container } = render()
+ const results = await axe(container)
+ expect(results).toHaveNoViolations()
+ })
+
+ it("supports disabled state without violations", async () => {
+ const { container } = render()
+ const results = await axe(container)
+ expect(results).toHaveNoViolations()
+ })
+
+ it("with icon has proper sizing", async () => {
+ const { container } = render(
+
+ )
+ const results = await axe(container)
+ expect(results).toHaveNoViolations()
+ })
+})
diff --git a/packages/ui/src/components/dialog.test.tsx b/packages/ui/src/components/dialog.test.tsx
new file mode 100644
index 0000000..090afd8
--- /dev/null
+++ b/packages/ui/src/components/dialog.test.tsx
@@ -0,0 +1,70 @@
+import { describe, it, expect, vi } from "vitest"
+import { render, screen, waitFor } from "@testing-library/react"
+import userEvent from "@testing-library/user-event"
+import { axe } from "vitest-axe"
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "./dialog"
+
+describe("Dialog accessibility", () => {
+ it("has no accessibility violations when closed", async () => {
+ const { container } = render(
+
+ )
+ const results = await axe(container)
+ expect(results).toHaveNoViolations()
+ })
+
+ it("has no accessibility violations when open", async () => {
+ const user = userEvent.setup()
+ const { container } = render(
+
+ )
+ const results = await axe(container)
+ expect(results).toHaveNoViolations()
+ })
+
+ it("supports keyboard escape to close", async () => {
+ const user = userEvent.setup()
+ const { container } = render(
+
+ )
+
+ const trigger = screen.getByRole("button", { name: "Open" })
+ await user.click(trigger)
+
+ await waitFor(() => {
+ expect(screen.getByText("Dialog content")).toBeInTheDocument()
+ })
+
+ await user.keyboard("{Escape}")
+
+ await waitFor(() => {
+ expect(screen.queryByText("Dialog content")).not.toBeInTheDocument()
+ })
+
+ const results = await axe(container)
+ expect(results).toHaveNoViolations()
+ })
+})
diff --git a/packages/ui/src/components/input.test.tsx b/packages/ui/src/components/input.test.tsx
new file mode 100644
index 0000000..d553166
--- /dev/null
+++ b/packages/ui/src/components/input.test.tsx
@@ -0,0 +1,46 @@
+import { describe, it, expect } from "vitest"
+import { render, screen } from "@testing-library/react"
+import userEvent from "@testing-library/user-event"
+import { axe } from "vitest-axe"
+import { Input } from "./input"
+
+describe("Input accessibility", () => {
+ it("has no accessibility violations", async () => {
+ const { container } = render()
+ const results = await axe(container)
+ expect(results).toHaveNoViolations()
+ })
+
+ it("supports typing without violations", async () => {
+ const user = userEvent.setup()
+ const { container } = render()
+
+ const input = screen.getByRole("textbox")
+ await user.type(input, "test")
+
+ expect(input).toHaveValue("test")
+
+ const results = await axe(container)
+ expect(results).toHaveNoViolations()
+ })
+
+ it("supports disabled state", async () => {
+ const { container } = render()
+ const input = screen.getByRole("textbox")
+
+ expect(input).toBeDisabled()
+
+ const results = await axe(container)
+ expect(results).toHaveNoViolations()
+ })
+
+ it("supports invalid state", async () => {
+ const { container } = render()
+ const input = screen.getByRole("textbox")
+
+ expect(input).toHaveAttribute("aria-invalid", "true")
+
+ const results = await axe(container)
+ expect(results).toHaveNoViolations()
+ })
+})
diff --git a/packages/ui/src/components/sheet.test.tsx b/packages/ui/src/components/sheet.test.tsx
new file mode 100644
index 0000000..c8db446
--- /dev/null
+++ b/packages/ui/src/components/sheet.test.tsx
@@ -0,0 +1,70 @@
+import { describe, it, expect, vi } from "vitest"
+import { render, screen, waitFor } from "@testing-library/react"
+import userEvent from "@testing-library/user-event"
+import { axe } from "vitest-axe"
+import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "./sheet"
+
+describe("Sheet accessibility", () => {
+ it("has no accessibility violations when closed", async () => {
+ const { container } = render(
+
+ Open
+
+
+ Test Sheet
+
+ Sheet content
+
+
+ )
+ const results = await axe(container)
+ expect(results).toHaveNoViolations()
+ })
+
+ it("has no accessibility violations when open", async () => {
+ const user = userEvent.setup()
+ const { container } = render(
+
+
+
+ Test Sheet
+
+ Sheet content
+
+
+ )
+ const results = await axe(container)
+ expect(results).toHaveNoViolations()
+ })
+
+ it("supports keyboard escape to close", async () => {
+ const user = userEvent.setup()
+ const { container } = render(
+
+ Open
+
+
+ Test Sheet
+
+ Sheet content
+
+
+ )
+
+ const trigger = screen.getByRole("button", { name: "Open" })
+ await user.click(trigger)
+
+ await waitFor(() => {
+ expect(screen.getByText("Sheet content")).toBeInTheDocument()
+ })
+
+ await user.keyboard("{Escape}")
+
+ await waitFor(() => {
+ expect(screen.queryByText("Sheet content")).not.toBeInTheDocument()
+ })
+
+ const results = await axe(container)
+ expect(results).toHaveNoViolations()
+ })
+})
diff --git a/packages/ui/src/components/slider.test.tsx b/packages/ui/src/components/slider.test.tsx
new file mode 100644
index 0000000..71d43a1
--- /dev/null
+++ b/packages/ui/src/components/slider.test.tsx
@@ -0,0 +1,23 @@
+import { describe, it, expect } from "vitest"
+import { render } from "@testing-library/react"
+import userEvent from "@testing-library/user-event"
+import { axe } from "vitest-axe"
+import { Slider } from "./slider"
+
+describe("Slider accessibility", () => {
+ it("has no accessibility violations", async () => {
+ const { container } = render()
+ const results = await axe(container)
+ expect(results).toHaveNoViolations()
+ })
+
+ it("supports keyboard navigation", async () => {
+ const user = userEvent.setup()
+ const { container } = render()
+
+ const slider = container.querySelector("[role='slider']")
+ expect(slider).toHaveAttribute("aria-valuemin", "0")
+ expect(slider).toHaveAttribute("aria-valuemax", "100")
+ expect(slider).toHaveAttribute("aria-valuenow")
+ })
+})
diff --git a/packages/ui/src/components/tabs.test.tsx b/packages/ui/src/components/tabs.test.tsx
new file mode 100644
index 0000000..b549aa2
--- /dev/null
+++ b/packages/ui/src/components/tabs.test.tsx
@@ -0,0 +1,65 @@
+import { describe, it, expect, vi } from "vitest"
+import { render, screen } from "@testing-library/react"
+import userEvent from "@testing-library/user-event"
+import { axe } from "vitest-axe"
+import { Tabs, TabsList, TabsTrigger, TabsContent } from "./tabs"
+
+describe("Tabs accessibility", () => {
+ it("has no accessibility violations", async () => {
+ const { container } = render(
+
+
+ Tab 1
+ Tab 2
+
+ Content 1
+ Content 2
+
+ )
+ const results = await axe(container)
+ expect(results).toHaveNoViolations()
+ })
+
+ it("supports keyboard navigation", async () => {
+ const user = userEvent.setup()
+ render(
+
+
+ Tab 1
+ Tab 2
+ Tab 3
+
+ Content 1
+ Content 2
+ Content 3
+
+ )
+
+ const tab1 = screen.getByRole("tab", { name: "Tab 1" })
+ const tab2 = screen.getByRole("tab", { name: "Tab 2" })
+
+ expect(tab1).toHaveAttribute("data-state", "active")
+
+ await user.click(tab2)
+ expect(tab2).toHaveAttribute("data-state", "active")
+ expect(tab1).toHaveAttribute("data-state", "inactive")
+
+ expect(screen.getByText("Content 2")).toBeInTheDocument()
+ })
+
+ it("has proper ARIA attributes", async () => {
+ render(
+
+
+ Tab 1
+ Tab 2
+
+ Content 1
+ Content 2
+
+ )
+
+ const tab1 = screen.getByRole("tab", { name: "Tab 1" })
+ expect(tab1).toHaveAttribute("data-state")
+ })
+})
diff --git a/packages/ui/src/components/token-avatar.tsx b/packages/ui/src/components/token-avatar.tsx
new file mode 100644
index 0000000..29d89d1
--- /dev/null
+++ b/packages/ui/src/components/token-avatar.tsx
@@ -0,0 +1,144 @@
+import { cva } from "class-variance-authority"
+import { cn } from "@workspace/ui/lib/utils"
+import type { VariantProps } from "class-variance-authority"
+import { useState } from "react"
+
+const tokenAvatarVariants = cva(
+ "inline-flex shrink-0 items-center justify-center rounded-full overflow-hidden ring-1 ring-border",
+ {
+ variants: {
+ size: {
+ xs: "size-5",
+ sm: "size-6",
+ md: "size-8",
+ lg: "size-10",
+ xl: "size-12",
+ "2xl": "size-16",
+ },
+ variant: {
+ solid: "",
+ outline: "ring-2",
+ },
+ },
+ defaultVariants: {
+ size: "md",
+ variant: "solid",
+ },
+ }
+)
+
+type TokenAvatarProps = {
+ symbol: string
+ src?: string
+ size?: VariantProps["size"]
+ variant?: VariantProps["variant"]
+ className?: string
+ aria?: "img" | "presentation"
+}
+
+function getInitials(symbol: string): string {
+ return symbol.toUpperCase().slice(0, 2)
+}
+
+function getColorForSymbol(symbol: string): string {
+ const colors: Record = {
+ BTC: "bg-orange-500/15 text-orange-400",
+ ETH: "bg-indigo-500/15 text-indigo-400",
+ XLM: "bg-sky-500/15 text-sky-400",
+ USDC: "bg-blue-500/15 text-blue-400",
+ USDT: "bg-red-500/15 text-red-400",
+ SOL: "bg-purple-500/15 text-purple-400",
+ BNB: "bg-yellow-500/15 text-yellow-400",
+ GLV: "bg-teal-500/15 text-teal-400",
+ }
+ return colors[symbol.toUpperCase()] ?? "bg-muted/60 text-muted-foreground"
+}
+
+export function TokenAvatar({
+ symbol,
+ src,
+ size = "md",
+ variant = "solid",
+ className,
+ aria = "img",
+}: TokenAvatarProps) {
+ const [imageError, setImageError] = useState(false)
+ const showImage = !!src && !imageError
+ const colorClasses = getColorForSymbol(symbol)
+
+ const sizeMap: Record = {
+ xs: 20,
+ sm: 24,
+ md: 32,
+ lg: 40,
+ xl: 48,
+ "2xl": 64,
+ }
+
+ const fontSize = sizeMap[size ?? "md"] * 0.35
+
+ return (
+
+ {showImage ? (
+

setImageError(true)}
+ />
+ ) : (
+
+ {getInitials(symbol)}
+
+ )}
+
+ )
+}
+
+export function TokenPair({
+ symbol1,
+ symbol2,
+ src1,
+ src2,
+ size = "md",
+ className,
+}: {
+ symbol1: string
+ symbol2: string
+ src1?: string
+ src2?: string
+ size?: VariantProps["size"]
+ className?: string
+}) {
+ const sizeMap: Record = {
+ xs: 20,
+ sm: 24,
+ md: 32,
+ lg: 40,
+ xl: 48,
+ "2xl": 64,
+ }
+
+ const containerSize = sizeMap[size ?? "md"]
+ const secondTokenOffset = containerSize * 0.35
+
+ return (
+
+ )
+}
diff --git a/packages/ui/src/components/transaction-status.tsx b/packages/ui/src/components/transaction-status.tsx
new file mode 100644
index 0000000..9b2129a
--- /dev/null
+++ b/packages/ui/src/components/transaction-status.tsx
@@ -0,0 +1,159 @@
+import { cva } from "class-variance-authority"
+import { cn } from "@workspace/ui/lib/utils"
+import type { VariantProps } from "class-variance-authority"
+
+const statusVariants = cva("flex items-start gap-3", {
+ variants: {
+ variant: {
+ pending: "",
+ success: "",
+ confirming: "",
+ failed: "",
+ },
+ },
+})
+
+export type TransactionStatusState =
+ | { status: "signing"; label?: string }
+ | { status: "submitting"; label?: string }
+ | { status: "confirming"; label?: string }
+ | { status: "success"; hash: string; label?: string; onExplorer?: () => void }
+ | { status: "failed"; error: string; label?: string; onRetry?: () => void }
+
+type Props = {
+ state: TransactionStatusState
+ className?: string
+}
+
+function StatusIcon({ status }: { status: TransactionStatusState["status"] }) {
+ switch (status) {
+ case "signing":
+ return (
+
+ )
+ case "submitting":
+ return (
+
+ )
+ case "confirming":
+ return (
+
+ )
+ case "success":
+ return (
+
+ )
+ case "failed":
+ return (
+
+ )
+ }
+}
+
+function StatusContent({ state }: { state: TransactionStatusState }) {
+ switch (state.status) {
+ case "signing":
+ return (
+
+
+ {state.label ?? "Waiting for signature..."}
+
+
+ )
+ case "submitting":
+ return (
+
+
+ {state.label ?? "Submitting transaction..."}
+
+
+ )
+ case "confirming":
+ return (
+
+
+ {state.label ?? "Confirming transaction..."}
+
+
+ )
+ case "success":
+ return (
+
+
+ {state.label ?? "Transaction confirmed"}
+
+ {state.hash}
+ {state.onExplorer && (
+
+ )}
+
+ )
+ case "failed":
+ return (
+
+
+ {state.label ?? "Transaction failed"}
+
+
{state.error}
+ {state.onRetry && (
+
+ )}
+
+ )
+ }
+}
+
+export function TransactionStatus({ state, className }: Props) {
+ return (
+
+
+
+
+ )
+}
diff --git a/packages/ui/vitest.config.ts b/packages/ui/vitest.config.ts
new file mode 100644
index 0000000..3c8bbee
--- /dev/null
+++ b/packages/ui/vitest.config.ts
@@ -0,0 +1,24 @@
+import { defineConfig, mergeConfig } from "vitest/config"
+import { reactConfig } from "@repo/vitest-config/react"
+
+export default mergeConfig(
+ reactConfig,
+ defineConfig({
+ test: {
+ include: ["src/**/*.{test,spec}.{ts,tsx}"],
+ setupFiles: ["./setup-tests.ts"],
+ deps: {
+ inline: [
+ "react",
+ "react-dom",
+ "react/jsx-runtime",
+ "react/jsx-dev-runtime",
+ "@testing-library/react",
+ "@testing-library/user-event",
+ "@testing-library/jest-dom",
+ "vitest-axe",
+ ],
+ },
+ },
+ })
+)