Three.js From Zero · Article s11-02

S11-02 The Material/Color Picker UI Layer

Season 11 · Article 02 · Configurator

The Material/Color Picker UI Layer

A production-quality picker UX. Swatch grid with selection animation, material tiles, cost / lead-time tags, full keyboard navigation, aria-live announcements, and graceful "unavailable" states. The UI layer that turns S11-01's spine into a thing a real shopper can use.

~30 min read a11y keyboard nav optimistic UI

1. Why the picker is the product

S11-01 gave us a store and a scene. That covers about 20% of what shoppers actually need. The other 80% lives in the picker — the thing they touch every five seconds while configuring. If the picker is sluggish, mis-labels swatches, breaks under keyboard nav, or silently lets users select an unavailable combination, conversion drops. There's data here: production case studies (Lucid Air Designer, Crate & Barrel via Threekit, Tag Heuer, Specialized) consistently show the picker is where lift happens.

"Production-quality picker" means six things, and we're going to build all six in this one file:

  1. A swatch grid with a clear selection state and a soft scale-up animation.
  2. Material tiles with metadata (price delta, lead time, availability).
  3. Full keyboard navigation: Tab between groups, arrow keys within a group.
  4. An aria-live region that announces picks for screen readers.
  5. Optimistic UI — the swatch flips selected the moment you click, before the GPU even draws the new frame.
  6. A graceful unavailable state — greyed swatch, tooltip on focus, no silent failure.

2. The data model — every option carries metadata

Most picker tutorials hard-code an array of hex strings. Production ones don't. Each option carries metadata the UI surfaces directly: a name (for labeling and screen readers), a price delta, a lead-time tag, an availability flag.

const COLORS = [
  { id: 'emerald',  hex: '#10b981', name: 'Emerald',  price: 0,   stock: 'in' },
  { id: 'sky',      hex: '#0ea5e9', name: 'Sky',      price: 0,   stock: 'in' },
  { id: 'rose',     hex: '#fb7185', name: 'Rose',     price: 25,  stock: 'in' },
  { id: 'gold',     hex: '#facc15', name: 'Saffron',  price: 25,  stock: 'low' },
  { id: 'plum',     hex: '#a855f7', name: 'Plum',     price: 25,  stock: 'in' },
  { id: 'mineral',  hex: '#94a3b8', name: 'Mineral',  price: 0,   stock: 'in' },
  { id: 'ink',      hex: '#1f2937', name: 'Ink',      price: 0,   stock: 'in' },
  { id: 'bone',     hex: '#f4d8b6', name: 'Bone',     price: 0,   stock: 'out' },
];

const MATERIALS = [
  { id: 'matte',       name: 'Matte',       price: 0,   lead: '2–4 wk' },
  { id: 'glossy',      name: 'Lacquer',     price: 80,  lead: '3–5 wk' },
  { id: 'metallic',    name: 'Metallic',    price: 140, lead: '4–6 wk' },
  { id: 'anisotropic', name: 'Brushed',     price: 180, lead: '5–8 wk', constraint: c => c.color !== 'bone' },
];

The constraint function on Brushed is a tiny preview of S11-06's rule engine: the option is only available if the predicate passes. Here, brushed metal isn't offered with bone color (rule from a hypothetical brand guideline). The picker UI consults this on every render.

3. Optimistic UI (the <100ms perception target)

The S8-01 split makes optimistic UI almost free. The user clicks a swatch; the click handler calls store.setState immediately; the picker's own subscriber sees the new state and applies the aria-checked + scale animation; the scene's subscriber separately rebinds material slots. The picker doesn't wait for the GPU. There's no "loading…" spinner.

The same loop covers the slow case too. Imagine a future "save preset" call to a server. The picker still flips selected immediately; if the server fails, you flip it back. Same code path. No spinner state machine.

4. Keyboard navigation — Tab + arrows

This is where most picker implementations fail. The pattern that wins:

  • Each group is a single tabindex stop. Pressing Tab moves between Color, Material, Cushion. It does NOT step through every swatch — that's miserable on a phone with a Bluetooth keyboard.
  • Within a group, arrow keys move focus. The currently-selected option is the one with tabindex="0"; the rest are tabindex="-1". Roving tabindex pattern.
  • Space / Enter selects. Pure focus moves don't change state, just like a radio group.
  • Home / End jump to first / last. Power-user nicety.
function onKey(e, group) {
  const items = [...group.querySelectorAll('[role=radio]')];
  const i = items.indexOf(document.activeElement);
  if (i === -1) return;
  let next = i;
  if (e.key === 'ArrowRight' || e.key === 'ArrowDown') next = (i + 1) % items.length;
  if (e.key === 'ArrowLeft'  || e.key === 'ArrowUp')   next = (i - 1 + items.length) % items.length;
  if (e.key === 'Home') next = 0;
  if (e.key === 'End')  next = items.length - 1;
  if (e.key === ' ' || e.key === 'Enter') items[i].click();
  if (next !== i) {
    items.forEach(it => it.tabIndex = -1);
    items[next].tabIndex = 0;
    items[next].focus();
    e.preventDefault();
  }
}

5. aria-live for screen readers

Sighted users see the swatch animate. Screen-reader users get nothing — unless you provide an aria-live region. One paragraph in the DOM, off-screen via the .sr-only pattern, with aria-live="polite". Whenever state changes, write the new selection into it. Screen readers announce.

<div id="live" class="sr-only" aria-live="polite" aria-atomic="true"></div>

// in the subscriber:
live.textContent = `Color: ${color.name}. Material: ${material.name}. ` +
                   `Total adjustment: $${totalDelta}.`;

Use polite, never assertive, for picker updates. Assertive interrupts; polite waits for the screen reader to finish what it's saying. Picker changes aren't urgent.

6. The "unavailable" pattern

What do you do when, say, the user picked "bone" color and Brushed metal isn't available with bone? Three things, in order:

  1. Don't hide the option. Hiding breaks user mental models. They think they're going crazy.
  2. Grey it out and show a striped overlay. Visual cue: this exists, but not for you right now.
  3. Tooltip on focus / hover explaining why. "Brushed metal not available with Bone color. Try Mineral or Ink."

Set aria-disabled="true" (not the disabled attribute — disabled removes from tab order, which means the screen reader skips and the user doesn't get the explanation). Keep it focusable, keep the tooltip readable.

7. Live demo — eight colors, four materials, full keyboard nav

Tab to a group, arrow keys within, Space to select. Try the bone color, then arrow to Brushed metal — it's greyed out with a tooltip. The aria-live region announces every change (turn on VoiceOver / NVDA / TalkBack to hear it).

Color · Emerald $0
Material · Matte $0
Total adjustment $0

8. CSS that makes the swatch feel alive

Selection animation is one CSS rule. The "selected" state is signaled by a 2px emerald border + an 8% scale-up + an inner white dot — readable from across a tablet:

.swatch[aria-checked="true"] {
  border-color: var(--accent);
  transform: scale(1.08);
}
.swatch[aria-checked="true"]::after {
  content: '';
  position: absolute;
  inset: 28%;
  border-radius: 50%;
  background: rgba(255,255,255,0.85);
}

Don't tie selection state to JS class toggles when an ARIA attribute already carries the truth. [aria-checked="true"] is the selector. One source of truth — the markup.

9. The "low stock" tag

Production configurators often surface a small "Low stock" or "Made to order" tag next to swatches with limited inventory. It's a lift-the-fold signal: shoppers click those first because of scarcity. It's also a kindness — if a color will ship in 10 weeks instead of 3, you want to set the expectation up front. We tag stock states in the data model and surface them in the swatch tooltip.

10. Optimistic 3D update — the choreography

The order of events when a user clicks a swatch:

  1. t=0ms: click handler fires, calls store.setState({ color: 'rose' }).
  2. t=0ms: store notifies subscribers synchronously.
  3. t=0ms: picker subscriber updates DOM (aria-checked flips, animation starts via CSS transition).
  4. t=0ms: scene subscriber pokes slots.body.color.set('#fb7185'); needsUpdate set.
  5. t=~16ms: next requestAnimationFrame tick draws. User sees the 3D update.
  6. t=~50ms: aria-live region update is announced (browsers debounce these slightly).

Total latency from click to visible change: under 50ms in practice. That's the perception target you want — anything under 100ms feels instantaneous.

11. References & further reading

  • WAI-ARIA Authoring Practices — radio group pattern. The roving tabindex used here is the canonical implementation.
  • Lucid Air Designer. Best in-class a11y picker in the wild — keyboard-navigable, every option has a name and tag, no silent unavailability.
  • Tag Heuer / Cartier configurators. Material tiles with finish previews + lead-time copy. The pattern in §3.
  • Threekit guide to 3D Product Configuration. Vendor-biased but accurate on the picker UX tradeoffs.

12. Takeaways

  • Each option carries metadata. Hex is the smallest part.
  • Roving tabindex inside a radiogroup. Tab between groups, arrows within.
  • aria-checked drives both visuals (CSS) and a11y. One source of truth.
  • aria-live="polite" + an .sr-only region for screen readers.
  • Unavailable = greyed + striped + tooltip + aria-disabled. Never hidden.
  • Optimistic UI is free under the S8-01 split. The picker doesn't wait for the GPU.