diff --git a/packages/react-ui/src/components/TokenListItem/TokenListItem.stories.tsx b/packages/react-ui/src/components/TokenListItem/TokenListItem.stories.tsx
new file mode 100644
index 00000000..7e57147d
--- /dev/null
+++ b/packages/react-ui/src/components/TokenListItem/TokenListItem.stories.tsx
@@ -0,0 +1,66 @@
+import type { Meta, StoryObj } from '@storybook/react-vite'
+
+import { TokenListItem } from '.'
+
+const meta = {
+ title: 'Shared/TokenListItem',
+ component: TokenListItem,
+ parameters: { layout: 'centered' },
+ tags: ['autodocs'],
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const Token: Story = {
+ args: {
+ symbol: 'ETH',
+ subtitle: '3 networks',
+ iconName: 'ethereum',
+ value: '$0.00',
+ change: '+13.31%',
+ },
+}
+
+export const InclChain: Story = {
+ args: {
+ symbol: 'ETH',
+ subtitle: 'Arbitrum',
+ subtitleIcon: 'arbitrum',
+ iconName: 'ethereum',
+ value: '$0.00',
+ change: '+13.31%',
+ },
+}
+
+export const Network: Story = {
+ args: {
+ symbol: 'ETH',
+ subtitle: '3 networks',
+ iconName: 'ethereum',
+ iconVariant: 'network',
+ value: '$0.00',
+ change: '+13.31%',
+ },
+}
+
+export const NegativeChange: Story = {
+ args: {
+ symbol: 'ETH',
+ subtitle: '3 networks',
+ iconName: 'ethereum',
+ value: '$0.00',
+ change: '-2.15%',
+ },
+}
+
+export const Loading: Story = {
+ args: { symbol: '', loading: true },
+}
diff --git a/packages/react-ui/src/components/TokenListItem/TokenListItem.test.tsx b/packages/react-ui/src/components/TokenListItem/TokenListItem.test.tsx
new file mode 100644
index 00000000..54de5cdc
--- /dev/null
+++ b/packages/react-ui/src/components/TokenListItem/TokenListItem.test.tsx
@@ -0,0 +1,62 @@
+/**
+ * @vitest-environment happy-dom
+ */
+import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import { TokenListItem } from '.'
+
+afterEach(() => cleanup())
+
+describe('TokenListItem', () => {
+ it('renders symbol, subtitle, value, and change', () => {
+ render(
+ ,
+ )
+ expect(screen.getByText('ETH')).toBeDefined()
+ expect(screen.getByText('3 networks')).toBeDefined()
+ expect(screen.getByText('$0.00')).toBeDefined()
+ expect(screen.getByText('+13.31%')).toBeDefined()
+ })
+
+ it('renders as a button when onClick is provided', () => {
+ const onClick = vi.fn()
+ render()
+ fireEvent.click(screen.getByRole('button'))
+ expect(onClick).toHaveBeenCalledTimes(1)
+ })
+
+ it('renders as a non-interactive div when no onClick', () => {
+ render()
+ expect(screen.queryByRole('button')).toBeNull()
+ })
+
+ it('applies positive color by default for positive change', () => {
+ render()
+ const change = screen.getByText('+13.31%')
+ expect(change.className).toContain('text-positive')
+ })
+
+ it('applies negative color for change starting with -', () => {
+ render()
+ const change = screen.getByText('-2.15%')
+ expect(change.className).toContain('text-negative')
+ })
+
+ it('renders the skeleton when loading', () => {
+ render()
+ expect(screen.queryByText('ETH')).toBeNull()
+ })
+
+ it('does not fire onClick when disabled', () => {
+ const onClick = vi.fn()
+ render()
+ fireEvent.click(screen.getByRole('button'))
+ expect(onClick).not.toHaveBeenCalled()
+ })
+})
diff --git a/packages/react-ui/src/components/TokenListItem/index.tsx b/packages/react-ui/src/components/TokenListItem/index.tsx
new file mode 100644
index 00000000..32078d36
--- /dev/null
+++ b/packages/react-ui/src/components/TokenListItem/index.tsx
@@ -0,0 +1,192 @@
+import type { ButtonHTMLAttributes, HTMLAttributes } from 'react'
+
+import { cn } from '../../utils/common'
+import { Icon, type IconName } from '../Icon'
+import { Text } from '../Text'
+
+export type TokenListItemIconVariant = 'token' | 'network'
+
+interface CommonProps {
+ /** Primary text — usually a token symbol like "ETH". */
+ symbol: string
+ /** Subtitle text under the symbol (e.g. "3 networks" or "Arbitrum"). */
+ subtitle?: string
+ /** Kit `Icon` name for the main slot (preferred for known chains/tokens). */
+ iconName?: IconName
+ /** URL of the main icon image (token logo). Used when `iconName` is absent. */
+ imageSource?: string
+ /** Kit `Icon` name rendered inline before the subtitle text (e.g. chain logo). */
+ subtitleIcon?: IconName
+ /** Right-aligned value (e.g. "$0.00"). */
+ value?: string
+ /** Right-aligned percentage change. Coloured green by default, red when
+ * the string starts with a leading "-". */
+ change?: string
+ /** Shape of the main icon. `'token'` = 44x44 rounded square (default),
+ * `'network'` = 36x36 circle. */
+ iconVariant?: TokenListItemIconVariant
+ /** Render a skeleton placeholder instead of the content. */
+ loading?: boolean
+}
+
+export type TokenListItemProps = CommonProps &
+ (
+ | ({ onClick: ButtonHTMLAttributes['onClick'] } & Omit<
+ ButtonHTMLAttributes,
+ keyof CommonProps | 'children'
+ >)
+ | ({ onClick?: undefined } & Omit<
+ HTMLAttributes,
+ keyof CommonProps | 'children'
+ >)
+ )
+
+function IconBlock({
+ iconName,
+ imageSource,
+ variant,
+}: {
+ iconName?: IconName
+ imageSource?: string
+ variant: TokenListItemIconVariant
+}) {
+ const isNetwork = variant === 'network'
+ return (
+
+ {iconName ? (
+
+ ) : imageSource ? (
+

+ ) : null}
+
+ )
+}
+
+function TokenListItemSkeleton({
+ className,
+}: {
+ className?: string | undefined
+}) {
+ return (
+
+ )
+}
+
+export function TokenListItem({
+ symbol,
+ subtitle,
+ iconName,
+ imageSource,
+ subtitleIcon,
+ value,
+ change,
+ iconVariant = 'token',
+ loading = false,
+ className,
+ onClick,
+ ...rest
+}: TokenListItemProps) {
+ if (loading) return
+
+ const changeColor = change?.startsWith('-')
+ ? 'zd:text-negative'
+ : 'zd:text-positive'
+
+ const content = (
+ <>
+
+
+
+
{symbol}
+ {subtitle && (
+
+ {subtitleIcon && (
+
+
+
+ )}
+
+ {subtitle}
+
+
+ )}
+
+
+ {(value || change) && (
+
+ {value && {value}}
+ {change && (
+
+ {change}
+
+ )}
+
+ )}
+ >
+ )
+
+ const baseClassName = cn(
+ 'zd:w-full zd:p-2 zd:flex zd:flex-row zd:items-center zd:justify-between zd:gap-2 zd:text-left',
+ className,
+ )
+
+ if (onClick) {
+ return (
+
+ )
+ }
+
+ return (
+ )}
+ >
+ {content}
+
+ )
+}
diff --git a/packages/smart-routing-address-react-ui/src/components/TokenChainPill/TokenChainPill.stories.tsx b/packages/smart-routing-address-react-ui/src/components/TokenChainPill/TokenChainPill.stories.tsx
index 123dba9c..417718ea 100644
--- a/packages/smart-routing-address-react-ui/src/components/TokenChainPill/TokenChainPill.stories.tsx
+++ b/packages/smart-routing-address-react-ui/src/components/TokenChainPill/TokenChainPill.stories.tsx
@@ -26,7 +26,6 @@ export const InteractiveToken: Story = {
args: {
label: 'USDC',
logoBg: '#2775CA',
- logoInitial: 'U',
onClick: () => {},
},
}
@@ -36,7 +35,6 @@ export const InteractiveChain: Story = {
args: {
label: 'Base',
logoBg: '#0052FF',
- logoInitial: 'B',
onClick: () => {},
},
}
@@ -51,7 +49,6 @@ export const Display: Story = {
args: {
label: 'Arbitrum One',
logoBg: '#28A0F0',
- logoInitial: 'A',
},
}
@@ -62,7 +59,6 @@ export const DisplayForcedDisabled: Story = {
args: {
label: 'Arbitrum One',
logoBg: '#28A0F0',
- logoInitial: 'A',
onClick: () => {},
disabled: true,
},
diff --git a/packages/smart-routing-address-react-ui/src/components/TokenChainPill/TokenChainPill.test.tsx b/packages/smart-routing-address-react-ui/src/components/TokenChainPill/TokenChainPill.test.tsx
index 7aef8baa..e3179e35 100644
--- a/packages/smart-routing-address-react-ui/src/components/TokenChainPill/TokenChainPill.test.tsx
+++ b/packages/smart-routing-address-react-ui/src/components/TokenChainPill/TokenChainPill.test.tsx
@@ -22,7 +22,7 @@ describe('TokenChainPill', () => {
})
it('renders the initial placeholder when logoUri is absent', () => {
- render()
+ render()
expect(screen.getByText('B')).toBeDefined()
})
diff --git a/packages/smart-routing-address-react-ui/src/components/TokenChainPill/index.tsx b/packages/smart-routing-address-react-ui/src/components/TokenChainPill/index.tsx
index ad249d75..930fda0d 100644
--- a/packages/smart-routing-address-react-ui/src/components/TokenChainPill/index.tsx
+++ b/packages/smart-routing-address-react-ui/src/components/TokenChainPill/index.tsx
@@ -4,15 +4,10 @@ import type { KeyboardEvent } from 'react'
export interface TokenChainPillProps {
/** Text label rendered next to the logo (e.g., "USDC", "Base"). */
label: string
- /** URL of the logo image; when omitted, a `logoBg` + `logoInitial` placeholder is drawn. */
+ /** URL of the logo image; when omitted, a `logoBg` + first letter of `label` placeholder is drawn. */
logoUri?: string
/** Fallback background color when no `logoUri` is supplied. */
logoBg?: string
- /** Fallback initial letter shown inside the placeholder circle. Defaults
- * to the first character of `label` (uppercased) — so most callers can
- * omit this and let the pill derive it. Pass explicitly to override
- * (e.g. `label="Arbitrum One"` but you want `"A"` regardless of casing). */
- logoInitial?: string
/** Click handler; when supplied and not `disabled`, the pill becomes a keyboard-accessible button. */
onClick?: () => void
/** When true, renders as a dimmed, non-interactive pill (no chevron). */
@@ -24,11 +19,12 @@ export function TokenChainPill({
label,
logoUri,
logoBg = '#E6EFFB',
- logoInitial = label.charAt(0).toUpperCase(),
onClick,
disabled,
className,
}: TokenChainPillProps) {
+ const logoInitial = label.charAt(0).toUpperCase()
+
const interactive = Boolean(onClick) && !disabled
const handleKeyDown = (event: KeyboardEvent) => {