🎨 Make it look right everywhere
Phones, dark mode, accessibility and every state your app can be in, turned into design tokens and checks instead of hope.
You'll leave with
- Design tokens on :root and a dark mode that works on 'system dark' phones
- A layout measured at 390px with 44px targets and passing contrast
- Loading, empty, stale, offline and error states designed on purpose
Most people will open your app on a phone, some with it set to dark mode. First Bites, my first-foods tracker for babies, once showed a light-green box on a black screen to every phone set to dark, because one small setting was never written onto the page. You don't need ten devices to catch that. You need a few decisions written down once, as design tokens, and checks that see your page the way a phone does: 390 pixels wide, in both themes. I'm a data person, not a designer, so everything here is a rule or a measurement, not taste.
What are design tokens (CSS variables)?
Named values written once, like --accent: #007aff, and used everywhere as var(--accent): change one line and everything using it changes. #007aff is a hex color: red, green and blue, two characters each, from 00 to ff.
Why 390 pixels?
Pages are laid out in CSS pixels, not physical dots; a phone packs two or three dots into each. Several iPhones are 390 CSS pixels wide. Desktop checks use 1280.
⚡ The 60-second version
- Every color, size and motion becomes a token, with light and dark values.
- A tiny script always writes "light" or "dark" onto the page.
- Measure at 390 pixels in both themes: no sideways scrolling, 44-pixel buttons, readable contrast.
- Open on a working example, design every state, and let Claude review while you decide.
Turn "make it nice" into tokens
The brief for Baby Signs, my baby sign language app, was one line: super clean, Apple-level polish. Nobody can build "polish". What got built was a short list of decisions: the system font (already on the device, so nothing downloads), one accent (Apple's system blue, #007AFF, and #0A84FF in dark mode), one card style (rounded white cards on a light grey page), a 44-pixel minimum for anything you tap, one motion curve for sheets that slide up, and a light and a dark value for every color. Each became a CSS variable, and no other rule picks its own color. That's why it shipped with an iOS look instead of being eyeballed screen by screen.
What's :root?
CSS's name for the page's outermost element, <html>. Variables set there reach every element, so that's where tokens live.
In VS Code, ask Claude Code, the AI assistant working in your project folder:
EXAMPLE — YOU ASK CLAUDE CODE
What's happening underneath: the values move into one place; nothing changes on screen yet. Yours should look roughly like this (Baby Signs' font and blues, my other values; check yours in step 3):
:root {
color-scheme: light;
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--bg: #f2f2f7; /* light grey page */
--card: #ffffff; /* the one card style... */
--radius: 14px; /* ...with rounded corners */
--ink: #1c1c1e; /* main text */
--muted: #6c6c70; /* secondary text */
--accent: #007aff; /* Apple's blue: icons, outlines */
--accent-ink: #0062cc; /* darker blue: links, filled buttons (step 3) */
--on-accent: #ffffff; /* text on a filled button */
--tap: 44px; /* the smallest thing a thumb must hit */
--ease: cubic-bezier(.32, .72, 0, 1); /* the one motion curve */
--fs-title: 22px; --fs-body: 15px; --fs-meta: 13px; --fs-micro: 12px;
}
:root[data-theme="dark"] {
color-scheme: dark;
--bg: #000000;
--card: #1c1c1e;
--ink: #ffffff;
--muted: #98989f;
--accent: #0a84ff;
--accent-ink: #0a84ff;
--on-accent: #000000;
}
body { margin: 0; font: var(--fs-body)/1.5 var(--font); background: var(--bg); color: var(--ink); }
.card { background: var(--card); border-radius: var(--radius); padding: 16px; }
.primary { background: var(--accent-ink); color: var(--on-accent); }
.sheet { transition: transform .5s var(--ease); }
color-scheme makes scrollbars and form controls match. Nothing sets data-theme yet, so the dark values wait for step 2.
Reuse pieces people already know
When Acento, my Argentine Spanish trainer, got an accent mode, it reused the syllable chips learners already knew: the same green, amber and red, now colored by acoustic distance (how far your sound is from the target). Only the label changed, from "words matched" to "accent match". Less code, one feel, fewer bugs.
Own your core content format
Baby Signs draws its own hands as SVG instead of embedding someone else's videos, so it works offline, adds no tracking or outside brand, and can't vanish when a third party changes something.
What's SVG?
An image format made of shapes and written as text, so it can sit inside your HTML ("inline SVG"). It stays sharp at any size.
The trap: an inline <svg> with only a viewBox (its internal coordinates) has no size of its own. Baby Signs' drawings blew past their cards and covered the text, and only screenshots caught it. Set width, height and preserveAspectRatio (how the drawing fits), and give its box a fixed size with overflow: hidden:
<div class="stage">
<svg viewBox="0 0 200 200" width="200" height="200" preserveAspectRatio="xMidYMid meet"><!-- your drawing --></svg>
</div>
.stage { width: 160px; height: 160px; overflow: hidden; }
.stage svg { width: 100%; height: 100%; }
Dark mode that works for "system dark" phones
Dark mode is two jobs: dark values for every token (step 1), and deciding which set applies. I tripped on the second.
What are a media query and an attribute?
A media query is CSS that applies only while a condition is true, like @media (prefers-color-scheme: dark) when the device is set to dark. An attribute is extra information in an HTML tag, like data-theme="dark" in <html data-theme="dark">; :root[data-theme="dark"] matches only while it's there.
📍 First Bites' dark styles were [data-theme="dark"] rules, which worked when you picked Dark in the app. Phones set to dark system-wide were detected with a media query instead, so the attribute was never written and those rules skipped them: a light-green box on black. The fix was a three-way setting (System, Light, Dark) that always writes the answer onto <html>. Baby Signs did that from day one.
Moving every color into tokens also let me delete First Bites' pile of !important dark-mode patches (CSS's "win no matter what" flag).
The prompt ends with screenshots, which Claude takes with check.mjs, the script from Check the AI's work that drives a headless browser (a real Chrome or Edge with no window). No check.mjs yet? Claude will write it.
EXAMPLE — YOU ASK CLAUDE CODE
What's happening underneath: before anything draws, the script writes light or dark on <html>, and the dark block swaps every token at once. The choice lives under myapp.prefs, this device's preferences from Build static-first, in localStorage (the browser's per-site storage). On a first visit nothing is saved, so the phone's setting wins, and the change listener follows the phone when it flips at sunset. try/catch keeps the page working when a private window blocks storage. myapp is your app's short name.
<meta name="theme-color" content="#f2f2f7">
<script>
function readPrefs() {
try { return JSON.parse(localStorage.getItem('myapp.prefs')) || {}; } catch (e) { return {}; }
}
function applyTheme() {
const pref = readPrefs().theme || 'system';
const dark = pref === 'dark' ||
(pref === 'system' && matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.dataset.theme = dark ? 'dark' : 'light';
const meta = document.querySelector('meta[name="theme-color"]');
if (meta) meta.content = dark ? '#000000' : '#f2f2f7';
}
applyTheme();
matchMedia('(prefers-color-scheme: dark)').addEventListener('change', applyTheme);
</script>
The theme-color tag tints the phone's status bar, so its two colors match your two --bg values. The menu saves the choice and calls the same function:
<label>Theme
<select id="theme">
<option value="system">System</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</label>
<script>
const themePick = document.getElementById('theme');
themePick.value = readPrefs().theme || 'system';
themePick.addEventListener('change', () => {
const prefs = readPrefs();
prefs.theme = themePick.value;
try { localStorage.setItem('myapp.prefs', JSON.stringify(prefs)); } catch (e) {}
applyTheme();
});
</script>
Print stays light on purpose, or a report printed from dark mode comes out dark. This goes below the dark block, so it wins:
@media print {
:root[data-theme] { color-scheme: light; --bg: #ffffff; --card: #ffffff; --ink: #000000; --muted: #444444; }
}
Check it yourself in Chrome or Edge on a computer (Safari hides these tools until you switch them on; phones can't do this):
- Open your app at
http://localhost:3000(or the addressnpx serveprinted) and press F12 (Mac: Cmd+Option+I) for DevTools, the browser's built-in developer tools. Click inside DevTools once, so the shortcuts below reach it. - With your menu on System, press Ctrl+Shift+P (Mac: Cmd+Shift+P), type
darkand pick Emulate CSS prefers-color-scheme: dark. The page should go dark with no light boxes. - Open the Elements tab: the
<html>line should includedata-theme="dark". - Press Ctrl+P (Mac: Cmd+P) for print preview. It should be light.
⚠️ Don't test by picking Dark in your own menu: that writes the attribute itself and hides exactly First Bites' bug. Test like a phone: menu on System, system set to dark.
🦥 Skipped: a copy of the dark values in @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { … } }, which First Bites keeps for the instant before its script runs. Add it if you ever see a flash of light before dark.
Phones first: measure, don't assume
The List, my explorer for a New York property-tax roll, looked fine on my laptop. At 390 pixels wide, its map was 154 pixels wide behind its legend, and the table was cut off mid-column. One media query for screens up to 720 pixels fixed most of it: stack everything, turn the sidebar into a sheet (a panel that slides in) opened by a "Filters (n)" button, give the map 34% of the screen's height, hide the legend, and show three key table columns. This is its shape, with The List's class names (use yours); it goes last in your CSS, so it wins:
@media (max-width: 720px) {
.layout { display: block; } /* stack instead of side by side */
.map { height: 34vh; } /* vh = 1% of the screen's height */
.legend, .hide-sm { display: none; } /* desktop-only: add class="hide-sm" */
}
See it at 390, by hand
In Chrome or Edge with DevTools open (click inside it first), press Ctrl+Shift+M (Mac: Cmd+Shift+M) for the device toolbar above the page. Choose Responsive in its first menu and type 390 and 844 in its two boxes. Try to scroll sideways (Shift plus the mouse wheel): if anything moves, something is too wide.
💡 For a real phone: npx serve also prints a Network address that a phone on the same Wi-Fi can open (if Windows asks, let Node use private networks).
Measure it with check.mjs
Eyes miss things, so measure: sideways scrolling, tap targets (anything a finger presses) under 44 by 44 pixels, and text contrast under 4.5:1.
What's a contrast ratio, and what's WCAG AA?
Contrast ratio compares how light text is against its background, from 1:1 (invisible) to 21:1 (black on white). WCAG is the web's accessibility standard; its AA level asks for 4.5:1 for normal text, and 3:1 for large text, outlines and icons.
EXAMPLE — YOU ASK CLAUDE CODE
What's happening underneath: the script sets a phone-sized screen and a dark system, then asks the page. Is it wider than the screen (scrollWidth bigger than clientWidth)? How big is each button really (getBoundingClientRect, the browser's own measurement)? It avoids window.innerWidth, which headless browsers sometimes misreport. Run node check.mjs yourself in a free terminal tab (Terminal → New Terminal), and confirm a pair in WebAIM's contrast checker.
The fixes that matter most
- Contrast. First Bites' white text on a light coral button failed AA; the fix was a darker accent for filled buttons plus a token for their text, step 1's
--accent-inkand--on-accent. White on Apple's#007AFFmeasures 4.02:1, so links and filled buttons use#0062cc(5.80:1); in dark mode their text flips to black, since white on#0A84FFis 3.65:1. Grey text chosen by eye often fails. - 44-pixel targets. First Bites' icons were smaller than that; the
--taptoken (code below) sets every button's minimum. - A small type scale (a short list of named font sizes). First Bites' body text was extra heavy; now it's weight 400 to 500, heavy only for numbers and titles. The List uses 12 to 13px for dense tables, 15px for reading, nothing smaller.
- A visible focus outline. Press Tab repeatedly: each control should show a clear ring, the way keyboard users get around.
- A label on every control. A dropdown without a visible label gets
aria-label="Your state", the name a screen reader (software that reads pages aloud) announces. My price tool added that in one review pass, plusinputmode="numeric"(the number keypad for ZIP codes) andaria-live="polite", which makes the reader announce results that update in place. - A floating button that never covers the last row. First Bites' floating add button sat on the last item, so it couldn't be tapped; bottom padding equal to the nav bar plus the button fixed it.
button, select, .btn { min-height: var(--tap); min-width: var(--tap); }
.btn { display: inline-flex; align-items: center; justify-content: center; }
:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
body { overflow-x: clip; }
td.num { text-align: right; font-variant-numeric: tabular-nums; }
Three traps that look like bugs
min-heightdoes nothing on a plain link. Links are inline (they flow like words in a sentence) and ignore height; an audit found my 44-pixel rule had no effect on link-style buttons. Give themclass="btn": itsdisplay: inline-flexmakes them obey.width: 100vwadds a sideways scrollbar.vwis 1% of the window's width, scrollbar included, so the element ends up wider than the page; an audit caught a full-width nav bar doing this to every page. Usewidth: 100%, withoverflow-x: cliponbodyas a safety net. Clip, not hidden:hiddencan stop a sticky header (one that stays put as you scroll) from sticking.- Tabular or monospace figures on a big number. They give every character, even the comma, the same width so columns line up. The List's 28px headline count read "31 , 541", like a typo. Keep them for table columns: the
td.numline.
Never greet visitors with an empty form
Visitors leave a blank tool without trying it, so the first screen should already show the product working.
- Pre-load an example. A private variant of my price tool opens with one popular procedure already loaded, and "popular searches" buttons as the empty state. Rare settings fold into a
<details>section (HTML's built-in fold-out: tap its summary line to open it), and everyday words like "brain MRI" find the billing code. - A front page for a bare visit. Type The List's address alone and you get a short front page: the headline count, one big search box, an "Explore the map" link and the two key findings. A shared link carries its view in URL parameters (the part of an address after
?, like?q=mri) and skips straight into the full app. The trap: code that writes default parameters into the address first makes every visit look shared, so check for parameters before anything else. - Simple by default, "Full data" one click away. The List's top bar went from 8 controls with insider labels to 4, with the rest behind one "Full data" switch that remembers your choice. Nothing was removed, only folded. "Threshold bucket" became "Which rule applies": plain English first, expert detail second.
- Widgets for someone who has never used one. A box on one of my pages began as an empty fake terminal only programmers would use. Now it starts filled with a working example beside a real Run button, and visitor text is never run as code (no
eval()), only matched against a short allowlist of commands.
Ask Claude: "With no URL parameters, show a front page: the headline number, a search box and three popular-search buttons that run a search. Any parameter goes straight to the full app. Read the parameters before anything writes defaults, and write none to the URL while the front page shows." Then open the bare address and reload twice: the front page should still be there.
Design every state
Real apps spend a lot of time somewhere other than "everything loaded", and each state (what the screen shows in one situation) deserves a design. Habooby, my Phoenix dust and air-quality map, replaced an opaque loading screen with these:
| State | What the visitor sees |
|---|---|
| Loading | The map at once, a thin progress line, and "Still loading…" after 6 seconds |
| Stale | "as of 2:40 PM" once the data is over 45 minutes old |
| Failed feed | What failed, in a sentence, plus a Retry button |
| Outside coverage | A note outside the county its rain gauges cover |
| Good news | "No dust advisories right now" |
| Empty | An example, never a blank form |
| Offline | "You're offline. Showing what you last loaded." |
The first five rows are Habooby's; most apps need the last two as well.
EXAMPLE — YOU ASK CLAUDE CODE
What's happening underneath: each state is a branch in the code that draws your page. The switch, new URLSearchParams(location.search).get('debugState') from Check the AI's work, shows rare states on demand instead of waiting for an outage.
Ask for location after saying why
Habooby used to ask for your location on load. Most people refuse a cold prompt, and some leave. Now it explains the benefit, asks only when you tap "Use my location", and quietly reuses an earlier yes. Removing the automatic prompt also hid a stats card, and a user asked "where did the left panel go?" It's back, opening on its own only when permission is already granted, so it never triggers a prompt.
Lead with one hero number
My price tool, the Medical Price & Bill Coach, first buried its useful number among statistics. Every result card now leads with one action, "Ask to pay $X: about 2x what Medicare pays", plus a Copy button, plain labels ("Medicare benchmark" became "What Medicare pays") and the negotiation scripts shown by default.
Teaching and feedback that's kind
- A loop doesn't teach; numbered frames do. My verdict on Baby Signs' first animated signs: "just okay… I don't think they'll work to teach." Each sign now shows a start and an end frame, captioned 1 and 2 to match its two written steps, with the animation tucked behind "Watch it move".
- Three things today, not the whole catalog. Baby Signs introduces at most three new signs at a time. Once the code's cap was 2 while the welcome text promised three, and the live smoke test (a run-through of the real site after a deploy) caught it. Now one constant,
NEW_AT_ONCE = 3, drives the logic and the sentence. - Partial credit, forgiving drills, quiet warnings. Acento colors each syllable green, amber (one letter off: half credit) or red, passes at a friendly 85%, and lets the learner make the final tap; its typing drills forgive missing accents. A user found First Bites' warnings "a bit overwhelming", so a Quiet mode folds the secondary ones into one-line notes, while the first-run disclaimer, the safety page and the choking and infant-CPR guide stay unconditional.
- When v1 looks too simple, build a model. Baby Signs v1 drew each finger as one straight line, so an "O" hand was a ring, and I rejected it. v2 models a hand (four jointed fingers, a two-part thumb, a palm, a wrist), so all 15 handshapes come from the same code, and its fingers fold in parallel, like real ones.
Small polish that reads as care
A favicon you can tell apart. Nearly every one of my apps once shipped the same recolored icon, and two had none, so their tabs showed the browser's generic globe. At 16 pixels, the same shape in a new color reads as the same icon. Now each app gets a rounded square in its own accent with one bold white symbol, written into the page as an SVG data URI, so there's no image file.
What are a favicon and a data URI?
A favicon is the tiny icon in the browser tab. A data URI is a whole file written into an address starting with data:, so there's nothing extra to download.
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%230062cc'/%3E%3Ctext x='32' y='44' font-family='system-ui,sans-serif' font-size='36' font-weight='700' text-anchor='middle' fill='%23fff'%3EM%3C/text%3E%3C/svg%3E">
Replace the M with your app's letter and 0062cc with your accent, keeping the %23 in front: a raw # starts a URL fragment (the end part of an address) and breaks the icon. Then look at it at real size in the browser tab: one of mine, a kettlebell, read as a padlock at 16 pixels.
Animation that rests. The radar sweep on traqqit.com, my apps' home page, pauses offscreen and in hidden tabs, and visitors whose system asks for reduced motion (a setting for people sensitive to movement) get a still frame. Heavy pages deserve a Low graphics option too: a 3D browser game of mine looked like it was skipping on weaker laptops, and your own machine flatters you.
Ask Claude: "Make every animation pause offscreen and in hidden tabs, and show a still frame when the system asks for reduced motion." To check, open DevTools' Command Menu as in step 2, type reduced, pick Emulate CSS prefers-reduced-motion: reduce and reload: the animation should stop on a still frame.
Let AI review; you decide
Claude is a tireless reviewer and a poor decider. Three habits keep it useful:
- A brief and a do-not-touch list. Habooby's polish pass covered mobile ergonomics, first-run and data states, and copy. Its data pipelines, color ramps and basemap were off-limits in writing, and it shipped without breaking the data side.
- Several designs, fresh judges, then an attack. traqqit.com's design came from 4 genuinely different directions and 3 judges in fresh sessions (new Claude conversations that haven't seen your discussion), then an attack on the winner for mobile, accessibility, performance, honest copy and polish. Show the judges phone screenshots too, and copy the old page first (
cp index.html index.old.html) so rollback is the same command with the names swapped. - A "deliberately not done" list. A Claude review of The List brought the front page and fewer controls, but it also called the green "success" color pointless. I kept it, because "not on the roll" genuinely is good news, and wrote down why, so the next session doesn't suggest it again.
EXAMPLE — YOU ASK CLAUDE CODE
What's happening underneath: Claude judges only what you show it and can't tell taste from rules. Sort its list into do and don't, a reason for each, and add the don'ts to NOTES.md, the notes file Claude reads each session (from Work with Claude Code):
## Deliberately not done
- YYYY-MM-DD Kept the green "all clear" color. The review called it pointless; for my users it's good news, and green says so.
If you send one prompt from this lesson, make it this: "Make every color a token on :root with System, Light and Dark that always sets data-theme, then screenshot every screen at 390px in both themes." Underneath, one attribute on <html> swaps every token at once.
Your turn
- Ask Claude to search your CSS for hex colors. Done when every match sits inside the
:rootblocks. - With your menu on System, emulate dark in DevTools. Done when
<html>showsdata-theme="dark", no screen has a light box, and print preview is light. - Run
node check.mjs. Done when, at 390×844 in both themes, it finds no sideways scrolling, no control under 44px and no token pair under 4.5:1. - Press Tab through every screen. Done when each control shows a visible ring and every input and menu has a label.
- Force each state with
?debugState=. Done when the loading, empty, stale, offline and error screenshots each say what's happening and what to do next. - Run one scoped design review. Done when NOTES.md has a rejected suggestion and its reason under "Deliberately not done".
Next, Maps, charts and dashboards: your data on a map or a chart without a framework, and without getting rate-limited. Your tokens and 390-pixel checks come along.