---
title: Color, Icons, and Contrast
slug: color-icons-and-contrast
category: Design
summary: How to spend color and icons so a screen tells the eye where to look — grey by default, one meaning per color, status color in text/badges vs entity color in chart marks, never color alone, and the WCAG contrast floors including the 3:1 rule for chart bars.
tags: [design, color, icons, accessibility, wcag, contrast, badges, ux-default]
status: stable
visibility: public
source_project: llamapress.ai admin dashboards
layers: [view]
related:
  - title: Progressive Disclosure for Dense Detail Pages
    url: /cookbook/progressive-disclosure-detail-page
    summary: Run that guide FIRST — it decides WHAT goes on the screen and at which layer. This guide decides how the survivors look.
  - title: Choosing the Right Chart
    url: /cookbook/choosing-the-right-chart
    summary: The third guide in the family — which chart answers which question. It defers to this guide for palettes and contrast.
---

# Color, Icons, and Contrast

> ⚠️ **Cookbook example — not live code.** (KEEP THIS CALLOUT.) Every code block below
> is an **example snippet**, **not part of the llamapress.ai codebase**, and **not
> running on this server**. This is a reference recipe for a **Leo instance (an AI coding
> agent) to implement in its own app** — read it to understand the pattern, then recreate
> it there.

Color and icons are a **preattentive budget**: the eye spends them before the reader
processes a single word. A single amber badge on a grey page is found instantly. Six
hues at equal saturation cancel each other out — the "rainbow effect" — and the page
reads slower than plain text. This guide is the spending discipline: every unit of
color buys exactly one meaning, and no meaning ever depends on color alone.

> **When to use:** whenever you add a color, a badge, an icon, a chart bar, or an
> up/down delta — and whenever a page looks like "badge soup" and you can't say why.
> **When not to:** brand/marketing pages where color is decoration, not information.

---

## The 80/20 in one breath

1. **Grey is the default.** Color is an exception that carries meaning. If you can't
   state a color's one meaning in a sentence, make it grey.
2. **Pick ≤3 semantic colors plus neutrals** and write down what each means. Then never
   use a hue for anything else.
3. **Split the two color systems by channel:** *status* color (needs a human / broken /
   moving up or down) lives in **text, badges, and icons**; *entity* color (which
   series/category is this) lives in **chart marks only**. They never share a channel,
   so "green = up" and "green = the Spreadsheets series" can coexist.
4. **Never encode by color alone.** Every color is backed by an arrow, a sign, a word,
   an icon, or position. Greyscale-print the page: every value must still read.
5. **Name colors by role in one helper**, never `text-green-600` sprinkled in views.
6. **Meet the contrast floors:** text 4.5:1, large text 3:1, and — the one everyone
   misses — meaningful icons and chart bars **3:1** (WCAG 1.4.11).

---

## Layer 1 — Design tokens: colors named by role, in one place

One hue hard-coded in twelve views is un-auditable and un-changeable. Abstract color by
**role**, the way design systems use tokens (`danger`, not `red-60`). One role → one
class string → one file.

```ruby
# app/helpers/design_tokens_helper.rb
module DesignTokensHelper
  # STATUS system — lives in text, badges, borders, icons. Never in chart marks.
  STATUS_TONES = {
    attention: "bg-amber-500 text-white",                       # needs a human NOW — ideally ONE per screen
    danger:    "bg-rose-50 text-rose-700 border border-rose-200", # broken / destructive
    up:        "text-emerald-700",                              # metric moving the right way — TEXT only, never a badge
    down:      "text-rose-700",                                 # metric moving the wrong way
    calm:      "bg-slate-100 text-slate-600"                    # everything informational
  }.freeze

  # ENTITY system — lives in chart marks (bars, segments, legend swatches) ONLY.
  # One stable hue per entity, identical on every screen, forever.
  ENTITY_COLORS = {
    "Spreadsheets" => "bg-emerald-500",   # semantic resonance: spreadsheets read as green
    "Documents"    => "bg-violet-500",    # distinct from green under deuteranopia
    "Other"        => "bg-slate-400",     # the residual bucket reads as neutral
    "All"          => "bg-indigo-500"     # the aggregate, not a real segment
  }.freeze

  def status_tone(tone)  = STATUS_TONES.fetch(tone)
  def entity_color(name) = ENTITY_COLORS.fetch(name, "bg-slate-400")
end
```

Why `emerald`/`rose` and not `green`/`red`: red–green is the exact pair lost to the most
common color-vision deficiency (about 1 in 12 men). Emerald-600 and rose-600 sit near
the color-blind-safe Okabe–Ito bluish-green/vermillion pair; pure green-500/red-500
do not.

## Layer 2 — The View: redundant encoding for every colored value

The delta indicator is the worked example of "never color alone". Direction lives in
the **arrow**, magnitude in the **signed number**, and color only accelerates what the
glyphs already say. Greyscale it and nothing is lost.

```erb
<%# app/views/shared/_delta.html.erb — locals: value: (signed numeric change), baseline: %>
<% if value.positive? %>
  <span class="<%= status_tone(:up) %>" title="vs <%= baseline %> last period">
    <i class="fas fa-arrow-trend-up"></i> +<%= value %>
  </span>
<% elsif value.negative? %>
  <span class="<%= status_tone(:down) %>" title="vs <%= baseline %> last period">
    <i class="fas fa-arrow-trend-down"></i> <%= value %>
  </span>
<% else %>
  <span class="text-slate-500" title="vs <%= baseline %> last period">
    <i class="fas fa-minus"></i> no change
  </span>
<% end %>
```

Three rules visible in that snippet:

- **"No change" is a word, not an absent color.** A bare grey `0` reads as missing data.
- The green is `text-emerald-700` — a **text** color, never a filled badge. A green
  badge would collide with the green entity bar it might sit next to.
- The `title` puts the baseline on hover. Hover may hide **elaboration of something
  visible** ("vs 327 last period"); it may never hide the fact itself. A bare number
  whose movement exists only in a tooltip is a bug.

The one allowed crossover between the two systems: a **direct label inside a chart**
may take its series color (labelling a bar "Spreadsheets" in the bar's own green kills
a legend lookup). That text is part of the mark. Everywhere else, a number, badge, or
arrow in body copy never takes an entity hue.

## Layer 3 — Icons

Icon research is blunt, and the rules are short:

- **An icon never carries meaning alone.** It repeats meaning that visible text already
  carries, or it sits beside a visible label. Hover-revealed labels don't exist on touch.
- **The 5-second rule:** if it takes you more than 5 seconds to think of the right icon
  for a concept, no icon communicates it. Use a word.
- **One icon per meaning, app-wide.** A second icon for the same idea is badge soup in
  another costume.

A minimal vocabulary that covers most admin screens (Font Awesome shown; swap inline
SVG or plain glyphs — `›`, `▲`, `▼` — if FA isn't loaded in your app):

| Icon | Means | Notes |
|---|---|---|
| `fa-arrow-trend-up` / `fa-arrow-trend-down` | metric rose / fell | always beside the signed number |
| `fa-minus` | no meaningful change | |
| `fa-triangle-exclamation` | needs a human (amber) | |
| `fa-circle-exclamation` | broken (red/rose) | |
| `fa-chevron-right`, rotating on open | a disclosure door | the visible affordance collapsed content demands |
| `fa-circle-info` | explanation available on hover **and** focus | elaboration only, never the fact |

## Layer 4 — Contrast floors (WCAG 2.2 Level AA)

| What | Ratio | Criterion |
|---|---|---|
| Normal text | **4.5:1** | 1.4.3 Contrast (Minimum) |
| Large text (≥24 px, or ≥18.66 px bold) | **3:1** | 1.4.3 |
| **Icons and chart marks that carry meaning**, component boundaries, focus rings | **3:1** against adjacent colors | **1.4.11 Non-text Contrast** |

**1.4.11 is the one that gets missed.** It explicitly covers "lines in graphs, pie
slices" and standalone icons. Concretely, in Tailwind terms:

- A `bg-gray-200` bar on a white card **fails** — the bar carries the value. Use
  `gray-400` or darker for meaningful bars; keep `gray-100` only for the empty track
  *behind* a fill (the track is decorative, the fill is not).
- A pale badge (`bg-amber-50`) needs a **border or dark text** to reach 3:1 — the pale
  fill alone will not.

Verify, don't eyeball — light mid-greys and pastel ambers fail far more often than they
look like they do. Any contrast-checker with the two hex values settles it.

---

## Gotchas (the hard-won stuff)

- **`divide-*` silently repaints accent borders.** Tailwind's `divide-y divide-gray-100`
  emits a sibling rule that sets **all four** border sides on every row after the first —
  so a `border-l-4 border-amber-600` accent renders amber on row 1 and grey on every row
  below, which reads as "only the first one is urgent". On any list whose rows carry an
  accent border, drop `divide-*` and use per-side utilities:

  ```erb
  <%# app/views/items/index.html.erb %>
  <ul class="border-t border-gray-100">            <%# not divide-y %>
    <li class="border-b border-b-gray-100 border-l-4
               <%= flagged ? 'border-l-amber-600' : 'border-l-transparent' %>">
  ```

- **A correct color can render grey because the wrong stylesheet loaded.** If a page can
  render under more than one layout (a fallback path, an email preview, an embedded
  view), the second layout may ship a different CSS bundle where your classes don't
  exist. Screenshot the page before believing a palette is applied.
- **Reserve the most saturated tone for the single most important thing.** If two
  elements are both the loudest, neither is. One `attention` block per screen.
- **Roles are structural, not colored.** "Theirs vs ours", "parent vs child" are roles —
  show them with indentation, position, or a subtle left border, like a chat transcript.
  Tinting one side blue and the other purple burns the hues you need for real alerts.
- **Blue means interactive, only.** The moment a blue badge means a status, every link
  on the page loses its affordance.
- **The greyscale test is the acceptance test.** Screenshot the page, drop it to
  greyscale, and confirm every state, direction, and value still reads. If anything
  vanishes, a color is carrying meaning alone.
- **Hover content must also appear on focus.** A CSS-only `group-hover:` with no
  `:focus-visible` state locks out keyboard and screen-reader users. Pair `title`
  attributes with focusable elements.

---

## Ship checklist

```
[ ] Grey is the default; I can state each color's ONE meaning in a sentence
[ ] Colors are named by ROLE in one helper, not hard-coded hues in views
[ ] Entity colors match every other screen showing the same entity
[ ] Marks carry identity; text/icons carry status — never the same channel
[ ] No meaning depends on color alone (arrow / sign / word / position backs it)
[ ] Greyscale screenshot: every value still readable
[ ] Text ≥ 4.5:1; meaningful icons and bars ≥ 3:1
[ ] Every icon repeats visible text or sits beside a label; one icon per meaning
[ ] Anything hidden on hover is elaboration, and also appears on focus
[ ] Squint test: exactly one thing on the page is loudest
```

## Files this pattern touches

```
app/helpers/design_tokens_helper.rb       # STATUS_TONES + ENTITY_COLORS, the single source
app/views/shared/_delta.html.erb          # the redundant-encoded delta indicator
```

## How to adapt to your schema

1. Rename the entities in `ENTITY_COLORS` to **your** app's series/categories (plans,
   channels, product lines). Pick hues with semantic resonance where one exists (a
   brand's real color, "spreadsheets are green"), then never change them.
2. Keep `STATUS_TONES` to the five roles shown. Resist adding a sixth — a new state
   almost always maps to `attention`, `danger`, or `calm`.
3. If your app has no charts, you can drop `ENTITY_COLORS` entirely; the status system
   and the contrast floors still apply to every badge and icon.
4. No Font Awesome? Swap the icon table for inline SVGs or text glyphs (`▲ ▼ › !`);
   the rules (label, one-per-meaning, 3:1) are unchanged.
