# Iconography

> Icon library, weight conventions, sizing scale, and import patterns for Phosphor Bold icons.

Roadie uses [Phosphor Icons](https://phosphoricons.com/) at the Bold weight.
The thicker strokes pair with the system's rounded, approachable visual
language and stay legible at small sizes.

## Principles

- Use icons intentionally. Pair them with text labels wherever possible, and
  avoid decorative-only icons that add clutter without aiding comprehension.
- Prefer existing icons. Browse [Phosphor's library](https://phosphoricons.com/)
  before considering custom SVGs. Reusing established metaphors keeps the
  system consistent.
- Keep it consistent. Bold by default, Fill only for active or selected
  states, and Duotone only for large decorative icons in a tile or graphic
  above 48px. Never mix
  Regular, Thin, or Light weights.

## Visual style

Bold weight provides thicker strokes that are more legible at small sizes and
create a confident, approachable feel. Here are some commonly used icons in
the system.

```tsx
{/* Heart */}
<HeartIcon weight='bold' className='size-6' />
{/* Star */}
<StarIcon weight='bold' className='size-6' />
{/* Plus */}
<PlusIcon weight='bold' className='size-6' />
{/* Trash */}
<TrashIcon weight='bold' className='size-6' />
{/* MagnifyingGlass */}
<MagnifyingGlassIcon weight='bold' className='size-6' />
{/* Gear */}
<GearIcon weight='bold' className='size-6' />
{/* CaretDown */}
<CaretDownIcon weight='bold' className='size-6' />
{/* CheckCircle */}
<CheckCircleIcon weight='bold' className='size-6' />
```

## Icon sizes

Four fixed tiers map to component contexts. Set an icon's size with a Tailwind
`size-*` class in `className`, never Phosphor's `size` prop. On the docs site,
each tier is drawn at its class with its size in pixels.

| Class    | Size |
| -------- | ---- |
| `size-3` | 12px |
| `size-4` | 16px |
| `size-5` | 20px |
| `size-6` | 24px |

| Tier | Class    | Use for                                                       |
| ---- | -------- | ------------------------------------------------------------- |
| XS   | `size-3` | Badges, tags, compact elements, and chevrons in small buttons |
| SM   | `size-4` | Buttons, and inline with body text (the default)              |
| MD   | `size-5` | Navigation and standalone actions                             |
| LG   | `size-6` | Page headers, feature cards, and hero sections                |

### Sizing inside components

Badge, Button, IconButton, and IconTile size the icons inside them, so an icon
there needs no size class. Badge sizes its icon to the text (`1em`). Button
and IconButton size an icon to the button, unless the icon has a `size-*`
class of its own: 16px in a Button and 20px in an IconButton by default.
IconTile sizes its icon to the tile.

## Icon weights

Three weights are used in the system. Bold is the default for everything.
Fill is reserved for active or selected states. Duotone is for large,
decorative icons in a tile or graphic above 48px. Use it on feature surfaces,
in an `IconTile` larger than `lg`, and in an `EmptyState` tile at its default
size, where a bold
outline alone looks hollow.

| Weight    | When to use                                                             |
| --------- | ----------------------------------------------------------------------- |
| `bold`    | The default for all icons                                               |
| `fill`    | Active or selected states only                                          |
| `duotone` | Decorative icons in a tile or graphic above 48px, such as an `IconTile` |

```tsx
{/* bold */}
<HeartIcon weight='bold' className='size-5' />
<StarIcon weight='bold' className='size-5' />
<CheckCircleIcon weight='bold' className='size-5' />
{/* fill */}
<HeartIcon weight='fill' className='size-5' />
<StarIcon weight='fill' className='size-5' />
<CheckCircleIcon weight='fill' className='size-5' />
{/* duotone */}
<IconTile intent='accent' size='xl'>
  <HeartIcon weight='duotone' />
</IconTile>
<IconTile intent='accent' size='xl'>
  <StarIcon weight='duotone' />
</IconTile>
<IconTile intent='accent' size='xl'>
  <CheckCircleIcon weight='duotone' />
</IconTile>
```

[Navigator](https://ticketsolutionsptyltd.github.io/roadie/components/navigator.md) is the exception. Its destinations render
duotone at 24px. Navigator applies the weight itself, so pass it a bare icon.

### Bold and fill in navigation

Navigation items use Bold when inactive and Fill when active.

```tsx
<Tabs defaultValue='favourites' emphasis='normal'>
  <Tabs.List>
    <Tabs.Tab
      value='favourites'
      render={(props, state) => (
        <button {...props}>
          <HeartIcon className='size-4' weight={state.active ? 'fill' : 'bold'} />
          Favourites
        </button>
      )}
    />
    <Tabs.Tab
      value='starred'
      render={(props, state) => (
        <button {...props}>
          <StarIcon className='size-4' weight={state.active ? 'fill' : 'bold'} />
          Starred
        </button>
      )}
    />
    <Tabs.Tab
      value='settings'
      render={(props, state) => (
        <button {...props}>
          <GearIcon className='size-4' weight={state.active ? 'fill' : 'bold'} />
          Settings
        </button>
      )}
    />
    <Tabs.Indicator />
  </Tabs.List>
</Tabs>
```

## Icon tiles

Frame an icon in an `IconTile` when it needs to stand on its own, such as
leading a dialog, a list row, or an empty state. The tile sizes and centres
the icon for you, so pass a bare icon as children with no size class.

```tsx
<IconTile intent='accent' size='lg'>
  <HeartIcon weight='bold' />
</IconTile>
<IconTile intent='success' size='lg'>
  <CheckCircleIcon weight='bold' />
</IconTile>
<IconTile intent='accent' size='lg' emphasis='strong'>
  <StarIcon weight='bold' />
</IconTile>
<IconTile intent='danger' size='lg'>
  <TrashIcon weight='bold' />
</IconTile>
```

Tile the item, not the action. Inside buttons, menu items, tabs, badges, and
inputs, use a bold icon with no tile, at the size the control sets or
`size-4` where it sets none. See [Icon Tile](https://ticketsolutionsptyltd.github.io/roadie/components/icon-tile.md).

Tiles are the main place `weight='duotone'` is welcome. Use `weight='bold'` up
to a 48px tile (`lg`), then switch to duotone above 48px, where a bold outline
can look hollow on the larger surface. Keep Fill reserved for active states
as elsewhere.

## Icon colour

Icons inherit `currentColor` by default. Use `text-*` utilities to set icon
colour, or let them inherit from a parent. An intent only sets variables, so
it colours an icon through a `text-*` utility on the icon or its parent, such
as a Badge.

```tsx
{/* text-normal */}
<HeartIcon weight='bold' className='size-6 text-normal' />
{/* text-subtle */}
<HeartIcon weight='bold' className='size-6 text-subtle' />
{/* text-strong */}
<HeartIcon weight='bold' className='size-6 text-strong' />
{/* intent-danger */}
<HeartIcon weight='bold' className='size-6 text-subtle intent-danger' />
{/* intent-success */}
<CheckCircleIcon weight='bold' className='size-6 text-subtle intent-success' />
```

## Import conventions

### Use the Icon suffix export

Phosphor exports each icon with an Icon suffix. Always use the suffixed export. The bare name is deprecated.

**Do**

```tsx
import {
  HeartIcon,
  StarIcon
} from '@phosphor-icons/react/ssr'
```

Import the `Icon`-suffixed export directly.

**Don’t**

```tsx
import {
  Heart,
  Star
} from '@phosphor-icons/react/ssr'
```

Bare names are deprecated and can collide with component or variable
names.

### Use the SSR import for server components

Phosphor's /ssr entry point doesn't use React context, so it works in server components.

**Do**

```tsx
// Server component (no 'use client')
import {
  HeartIcon
} from '@phosphor-icons/react/ssr'
```

Use `@phosphor-icons/react/ssr` in server components.

**Don’t**

```tsx
// Server component (no 'use client')
import {
  HeartIcon
} from '@phosphor-icons/react'
```

The standard entry reads React context, which server components can't
use, so it fails there.

## Guidelines

- Always set `weight='bold'` explicitly. Phosphor's default is Regular, not
  Bold.
- Use `aria-label` on icon-only buttons. `IconButton` enforces it as a
  required prop.
- Browse icons at [phosphoricons.com](https://phosphoricons.com/), and filter
  by Bold weight to see what's available.
- Icons inherit `currentColor`. Use `text-*` utilities for colour, not inline
  styles or Phosphor's `color` prop.
- Inside Badge, Button, IconButton, and IconTile, icons size themselves, so
  they need no size class.
- Use `@phosphor-icons/react/ssr` in server components. Only use
  `@phosphor-icons/react` in files with `'use client'`.

### Pair icons with text labels

Icons are most effective when paired with text. Use icon-only buttons only when the action is universally understood, and always provide an aria-label.

**Do**

```tsx
<>
  <Button emphasis='strong'>
    <PlusIcon weight='bold' />
    Add item
  </Button>
  <IconButton aria-label='Delete item' emphasis='subtle'>
    <TrashIcon weight='bold' />
  </IconButton>
</>
```

Pair icons with visible text. For icon-only buttons, use `IconButton`,
which requires `aria-label`.

**Don’t**

```tsx
<Button emphasis='strong'>
  <PlusIcon weight='bold' />
</Button>
```

Don't use a button with only an icon and no label or `aria-label`. Screen
readers won't know what the button does.

### Use Bold weight consistently

All icons use weight='bold'. Don't mix weights, which breaks visual harmony.

**Do**

```tsx
<>
  <HeartIcon weight='bold' className='size-5' />
  <StarIcon weight='bold' className='size-5' />
  <GearIcon weight='bold' className='size-5' />
  <TrashIcon weight='bold' className='size-5' />
</>
```

All icons at Bold weight for a consistent stroke width.

**Don’t**

```tsx
<>
  <HeartIcon weight='bold' className='size-5' />
  <StarIcon weight='regular' className='size-5' />
  <GearIcon weight='thin' className='size-5' />
  <TrashIcon weight='light' className='size-5' />
</>
```

Mixing weights creates visual inconsistency. Regular, Thin, and Light look
out of place next to Bold.

### Reserve Fill for active states

Fill weight signals that something is selected or active. Don't use it as the default.

**Do**

```tsx
<>
  <Toggle emphasis='subtler' intent='accent' defaultPressed>
    <HeartIcon weight='fill' />
    Active
  </Toggle>
  <Toggle emphasis='subtler' intent='accent'>
    <StarIcon weight='bold' />
    Inactive
  </Toggle>
</>
```

Fill for the active item, Bold for everything else.

**Don’t**

```tsx
<>
  <Toggle emphasis='subtler' intent='accent' defaultPressed>
    <HeartIcon weight='fill' />
    Active
  </Toggle>
  <Toggle emphasis='subtler' intent='accent'>
    <StarIcon weight='fill' />
    Inactive
  </Toggle>
</>
```

Fill on inactive items removes the visual distinction for the active
state.

### Reserve duotone for large, decorative icons

Duotone adds depth at large sizes but muddies legibility when small.

**Do**

```tsx
<IconTile size='2xl' shape='circle' intent='accent'>
  <HeartIcon weight='duotone' />
</IconTile>
```

Use `weight='duotone'` in a tile or graphic above 48px. The two-tone fill
reads as intentional depth on feature surfaces and in a large `IconTile`.

**Don’t**

```tsx
<Badge emphasis='subtler'>
  <HeartIcon weight='duotone' />
  Favourites
</Badge>
```

Don't use duotone for small inline or UI icons. The lighter secondary
layer turns muddy at small sizes. Use `weight='bold'` instead.

### Match icon size to context

Use the sizing scale to keep icons proportional to their surroundings.

**Do**

```tsx
<>
  <Badge intent='success'>
    <CheckCircleIcon weight='bold' />
    Verified
  </Badge>
  <Button size='sm'>
    <PlusIcon weight='bold' />
    Add
  </Button>
</>
```

Let components size icons, or use the tier for the context.

**Don’t**

```tsx
<>
  <Button size='xs'>
    <CheckCircleIcon weight='bold' className='size-6' />
    Verify
  </Button>
  <Button size='sm'>
    <PlusIcon weight='bold' className='size-6' />
    Add
  </Button>
</>
```

Oversized icons next to small text look unbalanced. Match the icon tier to
the text size.

Docs updated 10 Oct 2026.
