Most developers can make a button blue, centre a div and copy a flexbox cheat sheet.
CSS experts know what happens when the browser disagrees with you: when the page flashes white before it turns dark, when a phone's address bar eats the bottom of your layout, when a "simple" gradient border refuses to follow rounded corners, and when your grey text looks elegant on your monitor and invisible in sunlight.
I found out which one I was while styling APIC, a free photo editor that runs entirely in your browser: edit, convert, compress, resize and remove backgrounds, with no sign-up. It is one HTML file of about 12,400 lines, and its whole look lives in just ~1,100 lines of CSS inside it. This post is about those 1,100 lines (and the HTML around them).
Every test below is a real decision or a real bug from that file. The code is trimmed for reading, but the ideas are exactly what ships. 👇
New here? Good. This post is written for you too. Every test has a plain English part first, then the code, then the "what is really going on" part. If you only read the plain English parts, you will still learn something true. If you are an expert, skip straight to the code and the last line of each test.
🧰 Five words you will see (read this once)
| Word | What it means, no jargon |
|---|---|
Custom property (--bg) | A variable for CSS. Define a colour once, use it everywhere with var(--bg). |
Pseudo-element (::before) | A free extra box the browser lets you attach to any element, with no extra HTML. |
| Stacking context | A "layer fence". Things inside it can be ordered among themselves, but cannot escape it. |
| Viewport | The part of the page you can currently see on screen. |
| Paint | The moment the browser actually draws pixels on your screen. |
📊 APIC's CSS by the numbers
These are counted straight from the file:
- ~1,100 lines of CSS
- 666 uses of
var(--...): the whole design runs on variables - 33
@mediaqueries - 20 uses of
color-mix() - 12
@keyframesanimations - 18
:focus-visiblerules (keyboard users get a visible outline) - 8
prefers-reduced-motion: reduceguards (people who get dizzy from animation get less of it) - 20
<details>elements (accordions with no JavaScript)
Now the tests. Score yourself honestly.
Test 1: Does your dark site flash white before it turns dark?
In plain English. You choose "light mode", you reload the page, and for a split second the screen is dark before it switches. That flash is the browser drawing the page with the wrong colours, then fixing them. It looks cheap, and it hurts if you are in a dark room.
Why it happens. The browser paints as soon as it has your HTML and CSS. If the code that picks your theme runs after that, the first frame is already wrong.
The fix. Pick the theme in a tiny script in the <head>, so it runs before the first paint:
<head> <script> // Runs before anything is drawn (function () { var mode = 'system'; try { var m = localStorage.getItem('apic_theme'); if (m === 'light' || m === 'dark' || m === 'system') mode = m; } catch (e) {} // storage can be blocked, so never trust it var t = mode === 'system' ? (matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark') : mode; document.documentElement.setAttribute('data-theme', t); })(); </script> And the CSS only has to change a handful of variables:
:root { /* dark is the default */ --bg: #07070a; --bg-2: #0d0d13; --bg-3: #14141c; --line: rgba(150, 255, 210, .14); --text: #eef1f6; --text-muted: #8b8fa3; } html[data-theme="light"] { /* light only overrides the values */ --bg: #f2f3ee; --bg-2: #e8e9e1; --bg-3: #dcded2; --line: rgba(10, 40, 25, .14); --text: #0c0e0a; --text-muted: #5b5f52; } body { background: var(--bg); color: var(--text); } APIC's light theme overrides 8 variables. The other 600+ places that use colours never needed to know a second theme exists.
Three small details separate "it works" from "it works every time":
-
try/catcharoundlocalStorage. Private modes and strict settings can make it throw. A theme script that crashes leaves you with no theme at all. - Three modes, not two:
light,darkandsystem. "Follow my device" is a real choice, so it gets stored as one. - The script is inline and in the head. A script loaded from a file, or placed at the bottom, runs too late. Being render-blocking is exactly what you want here.
Developer: toggles a class after the page loads.
Expert: decides the theme before the first pixel exists.
Test 2: Can one line make a see-through header that works in both themes?
In plain English. A sticky header that is slightly see-through, so content blurs underneath it as you scroll, looks premium. The trap: "slightly see-through" needs a colour with transparency, and you have two themes with two different background colours.
The old way: write rgba(7, 7, 10, .82) for dark, then write a second rgba(...) for light. Now you have two numbers to keep in sync forever.
The expert way: color-mix() takes the colour from your variable and mixes it with "nothing":
header.top { position: sticky; top: 0; background: color-mix(in srgb, var(--bg) 82%, transparent); /* 82% bg, 18% see-through */ backdrop-filter: blur(12px); /* blur what's behind it */ border-bottom: 1px solid var(--line); } /* when you scroll, the border quietly turns slightly green */ header.top.scrolled { border-bottom-color: color-mix(in srgb, var(--green) 22%, var(--line)); } Switch the theme and --bg changes, so the header changes with it. No second rule. APIC uses this trick 20 times: hover glows, drag-and-drop highlights, active buttons, the lot.
rgba() cannot do this, because it wants raw numbers, not "whatever --bg currently is". color-mix() works in all current major browsers.
Developer: copies a colour and lowers its alpha, twice.
Expert: derives colours from the tokens, so there is only one source of truth.
Test 3: Why does my glow lag, or vanish behind the wrong thing?
In plain English. APIC's main card has a soft rainbow glow behind it. When you open a photo, the glow fades out so the picture is easy to see. Simple idea. Two things can go wrong: the glow ends up in the wrong layer, and the animation stutters on cheap phones.
.studio { position: relative; isolation: isolate; /* 1. build a layer fence around this card */ } .studio::before { /* the glow is a free extra box */ content: ""; position: absolute; inset: -1px; z-index: -1; /* behind the card's content... */ border-radius: inherit; background: var(--grad-brand); /* green → cyan → pink */ opacity: .28; filter: blur(18px); will-change: opacity, transform; transition: opacity .9s var(--ease-out), transform 1.1s var(--ease-out); } .studio.has-image::before { /* JS adds .has-image; CSS does the animation */ opacity: 0; transform: scale(.94); } @media (prefers-reduced-motion: reduce) { .studio::before { transition: none; } /* 3. respect people who opt out of motion */ } 1. isolation: isolate creates a stacking context, the "layer fence" from the glossary. Without it, z-index: -1 means "go behind whatever the page decides", and that can change when some ancestor gets a transform or opacity later. With the fence, it means "behind this card's content", always.
2. Only cheap properties animate. Fading (opacity) and moving or scaling (transform) are the two things browsers can usually animate on the GPU without re-measuring the page. The expensive part is the blur, and the blur never changes, so it is drawn once and then just faded. Animating box-shadow, width or filter itself would redo that work on every frame.
3. Reduced motion. Some people get dizzy or distracted by animation, and their system tells your site so. One @media line is the whole accommodation.
Notice who does what: JavaScript only flips a class (has-image). CSS owns every pixel of the animation. That split keeps both sides small.
Developer: animates box-shadow and wonders why it is janky.
Expert: animates only opacity and transform, and builds a fence first.
Test 4: Can you put a gradient on a rounded border?
In plain English. You want a thin rainbow outline around a rounded floating bar. Sounds like one line: border: 1px solid <gradient>. It is not. A border can only be a flat colour, and the tool made for fancy borders, border-image, ignores border-radius. Your corners come out square.
The trick: don't use a border at all. Draw a gradient box, then cut a hole in the middle, so only a 1px ring is left:
#apicDock::before { content: ""; position: absolute; inset: -1px; border-radius: inherit; /* the ring follows the rounded corners */ padding: 1px; /* 1px = the thickness of the ring */ background: var(--grad-brand); opacity: .55; pointer-events: none; /* never block clicks on the real bar */ -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); -webkit-mask-composite: xor; /* older browsers */ mask: linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0); mask-composite: exclude; /* standard */ } How to read it. A mask says "show the element only where the mask is solid". Here are two solid masks:
- one covers the content box (the middle),
- one covers the whole box.
exclude means "show where they don't overlap". The only place the two masks differ is the thin padding strip around the edge. That strip is your ring, and because the element itself has rounded corners, so does the ring.
APIC ships both the -webkit- version and the standard one, because older browsers only know the prefixed one.
Developer: gives up and uses a wrapper <div> with a gradient background and a smaller <div> on top.
Expert: subtracts one shape from another with a mask.
Test 5: How do you animate something to "gone", when display: none can't be animated?
In plain English. When you open a photo, APIC's tagline fades and slides up, and its space collapses so the editor moves up smoothly. When you close the photo, it comes back. You cannot animate display, and you cannot animate height: auto. So how?
.eyebrow { /* visible state */ max-height: 40px; overflow: hidden; opacity: 1; transform: none; visibility: visible; transition: max-height .5s var(--ease-out), opacity .35s ease, transform .5s var(--ease-out), visibility 0s linear 0s; /* flip to visible immediately */ } body.studio-active .eyebrow { /* hidden state */ max-height: 0; opacity: 0; transform: translateY(-12px); visibility: hidden; pointer-events: none; transition: max-height .5s var(--ease-out), opacity .35s ease, transform .5s var(--ease-out), visibility 0s linear .5s; /* ...but wait .5s before hiding */ } Two ideas hold this up:
-
max-heightinstead ofheight. Browsers can animate between two numbers (40px → 0px) but not between a number andauto. Pick a ceiling that is safely bigger than the content, and animate to it. - The delayed
visibilityflip. That last line is the expert part.visibility: hiddenmakes an element unclickable, unfocusable and invisible to screen readers, which is what you want eventually. But if it flipped instantly, the fade would never be seen. So when hiding, it waits.5s(the length of the animation) and then flips. When showing, it flips right away so the fade-in can be seen.
Without this, you get one of two bad endings: an invisible tagline that keyboard users can still tab into, or a tagline that vanishes with no animation at all.
Developer: display: none and a jump.
Expert: fades first, then removes it from the page's reach.
Test 6: Why does 100vh hide the bottom of my layout on a phone?
In plain English. On a phone, the browser's address bar slides away as you scroll. 100vh means "the screen height with the bar hidden", so when the bar is showing, the bottom 60 or so pixels of your layout are tucked underneath it. Buttons you can't tap. That is not your imagination.
Modern CSS has three honest heights:
| Unit | Meaning |
|---|---|
lvh | the large viewport: bars hidden |
svh | the small viewport: bars showing, so it always fits |
dvh | the dynamic viewport: follows the bars live |
APIC uses them on purpose. The photo area must never push the tools off screen, so it uses svh:
:root { --apic-header: 66px; /* Old browsers: vh. Photo is never smaller than 260px, never taller than 60% of the screen, and always leaves 330px for the title bar, tabs and first controls. */ --apic-img-max: max(260px, min(60vh, calc(100vh - 330px - (var(--apic-header) - 62px)))); } @supports (height: 100svh) { /* Browsers that understand svh override it */ :root { --apic-img-max: max(260px, min(60svh, calc(100svh - 330px - (var(--apic-header) - 62px)))); } } body.studio-active .studio-wrap { min-height: calc(100vh - var(--apic-header)); /* fallback */ min-height: calc(100svh - var(--apic-header)); /* wins if understood */ } Read max(260px, min(60svh, …)) as a sandwich: "at least 260px, at most 60% of the screen, and never so tall that the controls fall off." APIC's full-screen design studio (a position: fixed; inset: 0 overlay) is different: it should fill the screen exactly, and keep doing so as the bar slides in and out, so it uses dvh: #dsApp { height: 100vh; height: 100dvh; }.
The pattern is also worth stealing: write the old value first, then the new one on the next line. A browser that doesn't understand the second line ignores it. No JavaScript, no feature detection.
Developer: height: 100vh, tests on desktop, ships.
Expert: knows which of three heights each element actually means.
Test 7: Is a small screen the same thing as a finger?
In plain English. No. A small laptop window is small but has a precise mouse. An iPad is big but has a thumb. If your "mobile layout" is triggered by width, both get the wrong thing.
CSS can ask about the pointer instead:
@media (pointer: coarse) { /* "the main input is a finger" */ html { -webkit-tap-highlight-color: transparent; -webkit-text-size-adjust: 100%; } .btn, .tool-btn, .quick-btn { min-height: 44px; } /* finger-sized targets */ select, input[type=number], input[type=text], textarea { font-size: 16px; /* stops the iPhone zoom-in */ min-height: 44px; } input[type=range] { min-height: 32px; } } /* the area you draw on with a finger or mouse */ #overlay.active { cursor: crosshair; touch-action: none; /* drawing must not scroll or zoom the page */ -webkit-touch-callout: none; user-select: none; } Three small rules, three real problems:
- 44px is the size Apple's guidelines recommend for a tappable thing. Smaller, and people hit the wrong button.
- 16px inputs. On iPhone Safari, tapping a text field with a font smaller than 16px makes the whole page zoom in. Annoying, and it does not zoom back out on its own.
-
touch-action: noneon the drawing layer. Without it, dragging your finger across a photo to erase a background scrolls the page instead. The browser has to be told "this area is mine".
Developer: @media (max-width: 600px) and hope.
Expert: designs for the input device, not the screen size.
Test 8: Is your "stylish" grey text actually readable?
In plain English. Dark themes love soft grey text. It looks calm in a design tool. Then someone opens your site outdoors, or is tired, or is 55, and the secondary text turns into fog.
You do not have to guess. Readability has a number: the contrast ratio. WCAG, the accessibility standard, asks for 4.5 or more for normal text (level AA), and 7 or more is the gold level (AAA). Here is the whole calculation, in 5 lines of JavaScript:
const lin = c => (c /= 255) <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; const lum = hex => { const [r, g, b] = [1, 3, 5].map(i => lin(parseInt(hex.slice(i, i + 2), 16))); return 0.2126 * r + 0.7152 * g + 0.0722 * b; // how bright the eye sees it }; const ratio = (a, b) => { const [x, y] = [lum(a), lum(b)].sort((p, q) => q - p); return (x + .05) / (y + .05); }; ratio('#8b8fa3', '#07070a'); // 6.28 ratio('#a3a9bd', '#07070a'); // 8.59 APIC's first dark theme used #8b8fa3 for muted text. That is already a pass. But a late "dark-mode legibility pass" lifted it anyway, and it started with one line of variables:
html:not([data-theme="light"]) { --text-muted: #a3a9bd; /* was #8b8fa3 */ --line: rgba(160, 255, 220, .20); /* was .14: borders you can actually see */ --btn-text: #d9deec; } | Muted text on | Before | After |
|---|---|---|
page background #07070a | 6.28 | 8.59 |
card #0d0d13 | 6.05 | 8.27 |
raised panel #14141c | 5.72 | 7.82 |
That moves every one of them from "passes" to "gold level", with three variable changes, not 200 selector edits. This is the quiet payoff of Test 1: when your colours live in variables, an accessibility fix is a small diff.
(The bright green of the main buttons is easy: #39ff9d against the page is 15.3.)
Developer: "it looks fine on my monitor."
Expert: measures it, and fixes it where the colour is defined.
Test 9: How much can your HTML do before you write any JavaScript?
In plain English. CSS gets the glory, but a lot of "interactive" features are already built into HTML. Using them means fewer bugs, less code, and a page that works for people using keyboards and screen readers.
1. An accordion in four lines. APIC's FAQ and its advanced tool sections are 20 <details> elements:
<details class="faq-item"> <summary>Is APIC really free?</summary> <p>Yes. APIC is a free online photo editor that runs in your browser…</p> </details> .faq-item summary:focus-visible { outline: 2px solid var(--cyan); outline-offset: 2px; } You get open and close, keyboard support (Tab, Enter, Space) and screen readers announcing "expanded" or "collapsed", for free. CSS only adds a focus outline so keyboard users can see where they are. That is the whole component.
2. Landmarks. The page uses <header>, <nav>, <main>, <section> ×7 and <footer> instead of a pile of <div>s. Screen reader users can jump straight to "main content". Search engines also understand the page's shape.
3. One honest security line. In the <head>:
<meta http-equiv="Content-Security-Policy" content="object-src 'none'; base-uri 'self'"> It does two small things: no old-style plugins (object-src 'none'), and nobody can sneak in a <base> tag that redirects your relative links elsewhere (base-uri 'self'). It is a small policy and I will not pretend it is a full shield. Honest, narrow rules are better than a big one nobody maintains.
4. A page that explains itself to machines. A short block of JSON-LD in the head tells crawlers "this is a free photo-editing app, here is its feature list, here are the questions people ask". Plus Open Graph tags so a pasted link shows a proper preview card instead of a bare URL.
Developer: builds an accordion with divs, an onclick and an isOpen variable.
Expert: uses the element that already exists.
🎁 Bonus: a checkerboard with zero image files
When you open a transparent PNG in an image editor, you see the grey checkerboard behind it. APIC draws it with one declaration:
.has-alpha { background-image: repeating-conic-gradient(#8a8a8a 0% 25%, #5c5c5c 0% 50%); background-size: 18px 18px; } A conic gradient sweeps around a centre point like a clock hand. This one says "grey A for the first quarter turn, grey B for the second quarter, then repeat". Each stop starts at 0%, which is earlier than where the previous one ended, so the browser snaps it up to the previous stop, which gives hard edges instead of a blend. Four quarters make two light squares and two dark ones. Shrink it to an 18px tile, repeat it, and you have a checkerboard.
No PNG to download, no extra request, and it stays sharp on any screen.
😬 Two tests I still fail in APIC's own code
A post called "tests I failed" would be dishonest if it only covered the ones I fixed. I audited the shipping file for th...
