diff --git a/packages/key-ui/.storybook/main.ts b/packages/key-ui/.storybook/main.ts
new file mode 100644
index 000000000..38735a63f
--- /dev/null
+++ b/packages/key-ui/.storybook/main.ts
@@ -0,0 +1,12 @@
+import type { StorybookConfig } from '@storybook/react-vite'
+
+const config: StorybookConfig = {
+ stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
+ addons: ['@storybook/addon-vitest', '@storybook/addon-docs'],
+ framework: {
+ name: '@storybook/react-vite',
+ options: {},
+ },
+}
+
+export default config
diff --git a/packages/key-ui/.storybook/preview.tsx b/packages/key-ui/.storybook/preview.tsx
new file mode 100644
index 000000000..f02471439
--- /dev/null
+++ b/packages/key-ui/.storybook/preview.tsx
@@ -0,0 +1,84 @@
+import type { Preview, ReactRenderer } from '@storybook/react-vite'
+import type { DecoratorFunction } from 'storybook/internal/types'
+import { useEffect } from 'react'
+import '../src/styles/base.css'
+
+const mobileViewports = {
+ iPhoneSE: {
+ name: 'iPhone SE',
+ styles: { width: '375px', height: '667px' },
+ type: 'mobile' as const,
+ },
+ iPhone13: {
+ name: 'iPhone 13',
+ styles: { width: '390px', height: '844px' },
+ type: 'mobile' as const,
+ },
+ iPhone13ProMax: {
+ name: 'iPhone 13 Pro Max',
+ styles: { width: '428px', height: '926px' },
+ type: 'mobile' as const,
+ },
+}
+
+const preview: Preview = {
+ parameters: {
+ viewport: {
+ viewports: mobileViewports,
+ defaultViewport: 'iPhone13',
+ },
+ backgrounds: {
+ disable: true,
+ },
+ controls: {
+ matchers: {
+ color: /(background|color)$/i,
+ date: /Date$/i,
+ },
+ },
+ },
+ globalTypes: {
+ theme: {
+ name: 'Theme',
+ description: 'Color theme',
+ defaultValue: 'light',
+ toolbar: {
+ icon: 'paintbrush',
+ items: [
+ { value: 'light', title: 'Light', icon: 'sun' },
+ { value: 'dark', title: 'Dark', icon: 'moon' },
+ ],
+ dynamicTitle: true,
+ },
+ },
+ },
+ decorators: [
+ ((Story, context) => {
+ const theme = context.globals['theme'] || 'light'
+
+ useEffect(() => {
+ if (theme === 'dark') {
+ document.documentElement.classList.add('dark')
+ } else {
+ document.documentElement.classList.remove('dark')
+ }
+ }, [theme])
+
+ return (
+
+ )
+ }) as DecoratorFunction,
+ ],
+}
+
+export default preview
diff --git a/packages/key-ui/.storybook/vitest.setup.ts b/packages/key-ui/.storybook/vitest.setup.ts
new file mode 100644
index 000000000..73b806072
--- /dev/null
+++ b/packages/key-ui/.storybook/vitest.setup.ts
@@ -0,0 +1,7 @@
+import { beforeAll } from 'vitest'
+import { setProjectAnnotations } from '@storybook/react-vite'
+import * as previewAnnotations from './preview'
+
+const annotations = setProjectAnnotations([previewAnnotations])
+
+beforeAll(annotations.beforeAll)
diff --git a/packages/key-ui/package.json b/packages/key-ui/package.json
index 33cb30214..954dca3d5 100644
--- a/packages/key-ui/package.json
+++ b/packages/key-ui/package.json
@@ -39,8 +39,10 @@
"typecheck": "tsc --noEmit",
"typecheck:run": "tsc --noEmit",
"test": "vitest",
- "test:run": "vitest run --passWithNoTests",
- "test:storybook": "echo 'No storybook'",
+ "test:run": "vitest run --project unit --passWithNoTests",
+ "test:storybook": "vitest run --project storybook --passWithNoTests",
+ "storybook": "storybook dev -p 6008",
+ "storybook:build": "storybook build",
"lint:run": "oxlint .",
"i18n:run": "echo 'No i18n'",
"theme:run": "echo 'No theme'"
@@ -54,13 +56,18 @@
"react-dom": "^19.0.0"
},
"devDependencies": {
+ "@storybook/addon-docs": "^10.0.0",
+ "@storybook/addon-vitest": "^10.0.0",
+ "@storybook/react-vite": "^10.0.0",
"@testing-library/react": "^16.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
+ "@vitest/browser-playwright": "^4.0.0",
"clsx": "^2.1.1",
"eslint-plugin-file-component-constraints": "workspace:*",
"jsdom": "^26.1.0",
"oxlint": "^1.32.0",
+ "storybook": "^10.0.0",
"tailwind-merge": "^3.4.0",
"typescript": "^5.9.3",
"vite": "^7.3.0",
diff --git a/packages/key-ui/src/address-display/AddressDisplay.stories.tsx b/packages/key-ui/src/address-display/AddressDisplay.stories.tsx
new file mode 100644
index 000000000..0a4351c38
--- /dev/null
+++ b/packages/key-ui/src/address-display/AddressDisplay.stories.tsx
@@ -0,0 +1,81 @@
+import type { Meta, StoryObj } from '@storybook/react-vite'
+import { AddressDisplay } from './AddressDisplay'
+
+const meta: Meta = {
+ title: 'Components/AddressDisplay',
+ component: AddressDisplay,
+ parameters: {
+ layout: 'centered',
+ },
+ tags: ['autodocs'],
+ argTypes: {
+ mode: {
+ control: 'select',
+ options: ['auto', 'compact', 'standard', 'detailed', 'full'],
+ },
+ copyable: {
+ control: 'boolean',
+ },
+ },
+}
+
+export default meta
+type Story = StoryObj
+
+const SAMPLE_ADDRESS = '0x1234567890abcdef1234567890abcdef12345678'
+
+export const Default: Story = {
+ args: {
+ address: SAMPLE_ADDRESS,
+ },
+}
+
+export const Compact: Story = {
+ args: {
+ address: SAMPLE_ADDRESS,
+ mode: 'compact',
+ },
+}
+
+export const Standard: Story = {
+ args: {
+ address: SAMPLE_ADDRESS,
+ mode: 'standard',
+ },
+}
+
+export const Detailed: Story = {
+ args: {
+ address: SAMPLE_ADDRESS,
+ mode: 'detailed',
+ },
+}
+
+export const Full: Story = {
+ args: {
+ address: SAMPLE_ADDRESS,
+ mode: 'full',
+ },
+}
+
+export const NotCopyable: Story = {
+ args: {
+ address: SAMPLE_ADDRESS,
+ copyable: false,
+ },
+}
+
+export const CustomChars: Story = {
+ args: {
+ address: SAMPLE_ADDRESS,
+ startChars: 10,
+ endChars: 8,
+ },
+}
+
+export const Empty: Story = {
+ args: {
+ address: '',
+ placeholder: 'No address',
+ },
+}
diff --git a/packages/key-ui/src/amount-display/AmountDisplay.stories.tsx b/packages/key-ui/src/amount-display/AmountDisplay.stories.tsx
new file mode 100644
index 000000000..8b8718379
--- /dev/null
+++ b/packages/key-ui/src/amount-display/AmountDisplay.stories.tsx
@@ -0,0 +1,106 @@
+import type { Meta, StoryObj } from '@storybook/react-vite'
+import { AmountDisplay } from './AmountDisplay'
+
+const meta: Meta = {
+ title: 'Components/AmountDisplay',
+ component: AmountDisplay,
+ parameters: {
+ layout: 'centered',
+ },
+ tags: ['autodocs'],
+ argTypes: {
+ sign: {
+ control: 'select',
+ options: ['auto', 'always', 'never'],
+ },
+ color: {
+ control: 'select',
+ options: ['auto', 'default', 'positive', 'negative'],
+ },
+ size: {
+ control: 'select',
+ options: ['xs', 'sm', 'md', 'lg', 'xl'],
+ },
+ weight: {
+ control: 'select',
+ options: ['normal', 'medium', 'semibold', 'bold'],
+ },
+ },
+}
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {
+ args: {
+ value: '1234.56789',
+ symbol: 'ETH',
+ },
+}
+
+export const Positive: Story = {
+ args: {
+ value: '1234.56789',
+ symbol: 'ETH',
+ sign: 'always',
+ color: 'auto',
+ },
+}
+
+export const Negative: Story = {
+ args: {
+ value: '-1234.56789',
+ symbol: 'ETH',
+ sign: 'auto',
+ color: 'auto',
+ },
+}
+
+export const Compact: Story = {
+ args: {
+ value: '1234567.89',
+ symbol: 'ETH',
+ compact: true,
+ },
+}
+
+export const Large: Story = {
+ args: {
+ value: '1234.56789',
+ symbol: 'ETH',
+ size: 'xl',
+ weight: 'bold',
+ },
+}
+
+export const Loading: Story = {
+ args: {
+ value: '0',
+ symbol: 'ETH',
+ loading: true,
+ },
+}
+
+export const Hidden: Story = {
+ args: {
+ value: '1234.56789',
+ symbol: 'ETH',
+ hidden: true,
+ },
+}
+
+export const NoAnimation: Story = {
+ args: {
+ value: '1234.56789',
+ symbol: 'ETH',
+ animated: false,
+ },
+}
+
+export const HighDecimals: Story = {
+ args: {
+ value: '0.00000001',
+ symbol: 'BTC',
+ decimals: 8,
+ },
+}
diff --git a/packages/key-ui/src/token-icon/TokenIcon.stories.tsx b/packages/key-ui/src/token-icon/TokenIcon.stories.tsx
new file mode 100644
index 000000000..68e520081
--- /dev/null
+++ b/packages/key-ui/src/token-icon/TokenIcon.stories.tsx
@@ -0,0 +1,92 @@
+import type { Meta, StoryObj } from '@storybook/react-vite'
+import { TokenIcon } from './index'
+
+const meta: Meta = {
+ title: 'Components/TokenIcon',
+ component: TokenIcon.Root,
+ parameters: {
+ layout: 'centered',
+ },
+ tags: ['autodocs'],
+ argTypes: {
+ size: {
+ control: 'select',
+ options: ['xs', 'sm', 'md', 'lg', 'xl'],
+ },
+ },
+}
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {
+ args: {
+ size: 'md',
+ },
+ render: (args) => (
+
+
+ ETH
+
+ ),
+}
+
+export const Small: Story = {
+ args: {
+ size: 'sm',
+ },
+ render: (args) => (
+
+
+ BTC
+
+ ),
+}
+
+export const Large: Story = {
+ args: {
+ size: 'xl',
+ },
+ render: (args) => (
+
+
+ SOL
+
+ ),
+}
+
+export const Fallback: Story = {
+ args: {
+ size: 'md',
+ },
+ render: (args) => (
+
+
+ ??
+
+ ),
+}
+
+export const NoImage: Story = {
+ args: {
+ size: 'lg',
+ },
+ render: (args) => (
+
+ ABC
+
+ ),
+}
+
+export const AllSizes: Story = {
+ render: () => (
+
+ {(['xs', 'sm', 'md', 'lg', 'xl'] as const).map((size) => (
+
+
+ ETH
+
+ ))}
+
+ ),
+}
diff --git a/packages/key-ui/vitest.config.ts b/packages/key-ui/vitest.config.ts
new file mode 100644
index 000000000..04fa357a4
--- /dev/null
+++ b/packages/key-ui/vitest.config.ts
@@ -0,0 +1,45 @@
+import { defineConfig } from 'vitest/config'
+import { storybookTest } from '@storybook/addon-vitest/vitest-plugin'
+import { playwright } from '@vitest/browser-playwright'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const dirname = path.dirname(fileURLToPath(import.meta.url))
+
+export default defineConfig({
+ test: {
+ projects: [
+ // Unit tests (jsdom)
+ {
+ extends: './vite.config.ts',
+ test: {
+ name: 'unit',
+ globals: true,
+ environment: 'jsdom',
+ include: ['src/**/*.test.{ts,tsx}'],
+ exclude: ['src/**/*.stories.test.{ts,tsx}'],
+ },
+ },
+ // Storybook component tests (real browser)
+ {
+ extends: './vite.config.ts',
+ plugins: [
+ storybookTest({
+ configDir: path.join(dirname, '.storybook'),
+ storybookScript: 'pnpm storybook --ci',
+ }),
+ ],
+ test: {
+ name: 'storybook',
+ browser: {
+ enabled: true,
+ provider: playwright(),
+ headless: true,
+ instances: [{ browser: 'chromium' }],
+ },
+ setupFiles: ['./.storybook/vitest.setup.ts'],
+ },
+ },
+ ],
+ },
+})
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 8407f6c6e..82899a25d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -741,6 +741,15 @@ importers:
specifier: ^19.0.0
version: 19.2.3(react@19.2.3)
devDependencies:
+ '@storybook/addon-docs':
+ specifier: ^10.0.0
+ version: 10.1.10(@types/react@19.2.7)(esbuild@0.27.2)(rollup@4.54.0)(storybook@10.1.10(@testing-library/dom@10.4.0)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2))
+ '@storybook/addon-vitest':
+ specifier: ^10.0.0
+ version: 10.1.10(@vitest/browser-playwright@4.0.16)(@vitest/browser@4.0.16(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3))(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2))(vitest@4.0.16))(@vitest/runner@4.0.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(storybook@10.1.10(@testing-library/dom@10.4.0)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vitest@4.0.16)
+ '@storybook/react-vite':
+ specifier: ^10.0.0
+ version: 10.1.10(esbuild@0.27.2)(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rollup@4.54.0)(storybook@10.1.10(@testing-library/dom@10.4.0)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.9.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2))
'@testing-library/react':
specifier: ^16.0.0
version: 16.3.1(@testing-library/dom@10.4.0)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -750,6 +759,9 @@ importers:
'@types/react-dom':
specifier: ^19.0.0
version: 19.2.3(@types/react@19.2.7)
+ '@vitest/browser-playwright':
+ specifier: ^4.0.0
+ version: 4.0.16(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3))(playwright@1.57.0)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2))(vitest@4.0.16)
clsx:
specifier: ^2.1.1
version: 2.1.1
@@ -762,6 +774,9 @@ importers:
oxlint:
specifier: ^1.32.0
version: 1.35.0
+ storybook:
+ specifier: ^10.0.0
+ version: 10.1.10(@testing-library/dom@10.4.0)(prettier@3.7.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
tailwind-merge:
specifier: ^3.4.0
version: 3.4.0