FairGarden Design

Icon

A Material Symbols Rounded UI icon, drawn as inline SVG and filled in currentColor, so it takes the ink of the part it sits in.

import { Icon } from '@fairgarden-private/design/components/Icon'

Icons are per-icon SVG paths generated from @material-symbols/svg-400 to svg-700, so there is no icon font to load. Give an icon a label when it stands alone; beside a visible label or inside a named control, omit it and the icon is hidden from assistive technology.

Size tiers

Size tiers

Three tiers, never scaled in between: inline 16 px beside text, tag 20 px in tags and chips, block 36 px in feature blocks. Each tier draws the Material Symbols weight whose stroke matches its line weight, so an icon sits at the same weight as the rules and edges around it.

IconSizes.tsx
import { Icon } from '@fairgarden-private/design/components/Icon'

export function IconSizes() {
  return (
    <>
      <Icon name="search" size="inline" label="Search, inline (16 px)" />
      <Icon name="search" size="tag" label="Search, tag (20 px)" />
      <Icon name="search" size="block" label="Search, block (36 px)" />
    </>
  )
}

Weights

Rest and emphasis weights

The weight follows the tier. weight="emphasis" draws the next stroke weight at the same size, as the weight-change cue of a pressed or selected state; the glyph never grows.

TierSizeTarget strokeRest weightEmphasis weight
inline16 px--ds-stroke-1-25600 (1.32 px)700 (1.57 px)
tag20 px--ds-stroke-1-5500 (1.42 px)700 (1.96 px)
block36 px--border-size-2400 (2.25 px)600 (2.97 px)

The weights were measured from the packages' stems, not guessed; iconTierWeights in @fairgarden-private/design/icons/paths holds them.

TierRestEmphasis
inline 16 px600700
tag 20 px500700
block 36 px400600
IconWeights.tsx
import { Icon } from '@fairgarden-private/design/components/Icon'
import { iconTierWeights } from '@fairgarden-private/design/icons/paths'
import styles from './weights.module.css'

const tiers = [
  { size: 'inline', px: 16 },
  { size: 'tag', px: 20 },
  { size: 'block', px: 36 },
] as const

/** Each tier at rest and at emphasis: the stroke steps up one tier, the size never changes. */
export function IconWeights() {
  return (
    <div className={styles.grid}>
      <span className={styles.head}>Tier</span>
      <span className={styles.head}>Rest</span>
      <span className={styles.head}>Emphasis</span>
      {tiers.map(({ size, px }) => (
        <div key={size} className={styles.row}>
          <code className={styles.name}>
            {size} {px} px
          </code>
          <span className={styles.sample}>
            <Icon name="search" size={size} />
            <Icon name="close" size={size} />
            <code className={styles.weight}>{iconTierWeights[size].rest}</code>
          </span>
          <span className={styles.sample}>
            <Icon name="search" size={size} weight="emphasis" />
            <Icon name="close" size={size} weight="emphasis" />
            <code className={styles.weight}>{iconTierWeights[size].emphasis}</code>
          </span>
        </div>
      ))}
    </div>
  )
}

Hover and press

Hover and press

An icon inside a control thickens on hover instead of changing color or size. Give the control the iconHost class and the icon weight="interactive": the icon swaps to its emphasis weight while the control is hovered or pressed, and keeps its rest weight while the control is disabled or busy. In a CSS module, compose the class instead:

close-button.module.css
.base {
  composes: iconHost from '@fairgarden-private/design/components/Icon/icon.module.css';
}
IconHover.tsx
import { Icon, iconHost } from '@fairgarden-private/design/components/Icon'
import styles from './hover.module.css'

/**
 * `weight="interactive"` draws the rest weight and swaps to emphasis while
 * the element carrying `iconHost` is hovered or pressed. A disabled host
 * keeps the rest weight.
 */
export function IconHover() {
  return (
    <div className={styles.row}>
      <button type="button" className={`${iconHost} ${styles.button}`}>
        <Icon name="close" weight="interactive" label="Close" />
      </button>
      <button type="button" className={`${iconHost} ${styles.button}`}>
        <Icon name="menu" size="tag" weight="interactive" label="Menu" />
      </button>
      <a href="#hover" className={`${iconHost} ${styles.link}`}>
        See all
        <Icon name="chevron_right" weight="interactive" className={styles.glyph} />
      </a>
      <button type="button" disabled className={`${iconHost} ${styles.button}`}>
        <Icon name="add" weight="interactive" label="Add (unavailable)" />
      </button>
    </div>
  )
}

The inventory

The inventory

The UI inventory, by Material Symbols name. Glyphs that point along the inline axis (arrow_forward, chevron_left, chevron_right) mirror in right-to-left layouts.

  • search
  • arrow_forward
  • arrow_upward
  • expand_more
  • close
  • remove
  • add
  • check
  • circle
  • chevron_right
  • chevron_left
  • menu
  • more_horiz
  • play_arrow
  • pause
  • download
  • zoom_in
  • zoom_out
  • recenter
  • help
  • mail
IconInventory.tsx
import { Icon } from '@fairgarden-private/design/components/Icon'
import { iconNames } from '@fairgarden-private/design/icons/paths'
import styles from './inventory.module.css'

export function IconInventory() {
  return (
    <ul className={styles.grid}>
      {iconNames.map((name) => (
        <li key={name} className={styles.cell}>
          <Icon name={name} size="tag" />
          <code className={styles.name}>{name}</code>
        </li>
      ))}
    </ul>
  )
}

On grounds

On paper, forest and leaf

Icon has no primary or secondary props. Its color is its part's ink role, so it follows the ground and the page mode without a selector of its own.

paper
forest
leaf
IconGrounds.tsx
import { PresetGround } from '@/components/PresetGround'
import { Icon } from '@fairgarden-private/design/components/Icon'
import styles from './grounds.module.css'

const presets = ['paper', 'forest', 'leaf'] as const

/** No color props: the icon is filled in currentColor, so it takes the ink around it. */
export function IconGrounds() {
  return (
    <div className={styles.row}>
      {presets.map((preset) => (
        <PresetGround key={preset} preset={preset} className={styles.face}>
          <Icon name="check" size="block" label="Checked" />
          <span className={styles.name}>{preset}</span>
        </PresetGround>
      ))}
    </div>
  )
}

API Reference

A Material Symbols Rounded UI icon, drawn as inline SVG in currentColor (FILL 0) at its tier’s calibrated weight. The one FILL 1 instance, circle, is a selection mark only and must always travel with a second cue (a bar, a weight change, an edge change or a word): state is never shown by fill alone [D166].

PropTypeDescription
name
| 'menu'
| 'search'
| 'circle'
| 'arrow_forward'
| 'arrow_upward'
| 'expand_more'
| 'close'
| 'remove'
| 'add'
| 'check'
| 'chevron_right'
| 'chevron_left'
| 'more_horiz'
| 'play_arrow'
| 'pause'
| 'download'
| 'zoom_in'
| 'zoom_out'
| 'recenter'
| 'help'
| 'mail'
| undefined

The §6.10 inventory name (Material Symbols Rounded).

label
string | undefined

Accessible name. Omit it for an icon beside a visible label or inside a named control: the icon is then hidden from assistive technology.

weight
IconWeightState | undefined

rest (default) draws the tier’s calibrated weight. emphasis pins the next stroke tier’s weight at the same size, as the weight-change cue of a pressed or selected state. interactive draws rest and swaps to emphasis while an ancestor carrying iconHost is hovered or pressed; it ships both paths, so use it only inside such a host.

icon

Icon classes (§6.10) [D166]. size is the Material Symbols tier: inline 16 px (--size-px-3), tag 20 px (--size-px-4), block 36 px (--ds-size-icon-block). The icon has no color axes: it is filled in currentColor and takes its part’s ink role.

type icon = icon
iconHost

The host class for weight="interactive" icons (§10.1 icon states). Add it to the element whose hover or press swaps the icon to its emphasis weight (a button, a cell, a link), or compose it in the host’s module: composes: iconHost from '../Icon/icon.module.css'. Kept out of the client module, so a server component reads the class name itself.

type iconHost = string
IconName
type IconName =
  | 'menu'
  | 'search'
  | 'circle'
  | 'arrow_forward'
  | 'arrow_upward'
  | 'expand_more'
  | 'close'
  | 'remove'
  | 'add'
  | 'check'
  | 'chevron_right'
  | 'chevron_left'
  | 'more_horiz'
  | 'play_arrow'
  | 'pause'
  | 'download'
  | 'zoom_in'
  | 'zoom_out'
  | 'recenter'
  | 'help'
  | 'mail'
IconProps

Props for Icon: SVG props (without children, color, fill and size), the size tier, the name and the weight state.

type IconProps = Omit<React.SVGProps<SVGSVGElement>, 'children' | 'color' | 'fill' | 'size'> &
  VariantProps<__type> & {
    name:
      | 'menu'
      | 'search'
      | 'circle'
      | 'arrow_forward'
      | 'arrow_upward'
      | 'expand_more'
      | 'close'
      | 'remove'
      | 'add'
      | 'check'
      | 'chevron_right'
      | 'chevron_left'
      | 'more_horiz'
      | 'play_arrow'
      | 'pause'
      | 'download'
      | 'zoom_in'
      | 'zoom_out'
      | 'recenter'
      | 'help'
      | 'mail';
    label?: string;
    weight?: IconWeightState;
  }
IconTier
type IconTier = 'inline' | 'tag' | 'block'
IconWeightState

Which weight instance an icon draws (§10.1 icon states) [D166]:

  • rest: the tier’s calibrated weight;
  • emphasis: the next stroke tier’s weight at the same size, pinned;
  • interactive: rest, swapping to emphasis while an iconHost ancestor is hovered or pressed.
type IconWeightState = 'rest' | 'emphasis' | 'interactive'

Specification: DESIGN-SYSTEM.md §6.10 (icons) and §1.5.12 (sizing).