Frontend / Checklist
CSS that loads fast, stays flat, and survives a redesign
Thirty-two rules on how a stylesheet is loaded, shrunk, organized, tokenized, made responsive and animated, from the first paint to the print preview. The priorities separate the rules that cost real milliseconds or lock people out - a render-blocking load, pinch zoom disabled, an invisible focus ring - from the ones that mostly protect you from your own next refactor.
32 rules in 7 groups. Adapted from the Front-End Checklist (David Dias, MIT), rewritten and re-verified September 2026.
What this checklist decides
Bad CSS rarely breaks a page outright. It makes the page slow to first paint, impossible to restyle, and hostile to keyboard users - three problems that surface months after launch, when the fix means touching every file. A render-blocking stylesheet delays the largest paint on every load; a specificity war means every new component ships with !important; a removed focus outline fails an accessibility audit on every interactive element at once.
Loading and optimization come first because they are mechanical: a build step fixes them once and forever. The structural rules - specificity, layers, tokens - are cheap on day one and expensive to retrofit, so treat their Medium and Low priorities as "before the second developer joins". Nothing on this page is Critical; the stylesheet cannot leak data or crash the browser, and it should be fixed in the order it costs you.
- Critical blocks the ship
- High fix before launch
- Medium fix this quarter
- Low worth it when cheap
Loading
A stylesheet in the head blocks the first paint until it arrives, so how much of it blocks, and in what order, is the biggest CSS decision on the page.
| Rule | Priority | What to do |
|---|---|---|
| Inline the rules the first screen needs | High | Extract the CSS that styles the above-the-fold content into a <style> block in the head and load everything else without blocking. Do it at build time - beasties (the maintained Critters fork) or Astro's build.inlineStylesheets - and keep the block small, because inline CSS is downloaded again with every page. |
| Load the rest without blocking render | High | Preload the stylesheet and swap it in on load (below), or split sheets by media so only the matching one blocks: media="print" and media="(min-width: 64rem)" are fetched at low priority and never hold up the first paint on a phone. |
| Put stylesheets before scripts | Medium | A <script> that follows a stylesheet waits for that stylesheet, because the script might read computed styles. Head order: charset, viewport, title, preloads, stylesheets, then defer or type="module" scripts. |
The preload swap. The browser fetches the file at high priority without blocking, then the handler turns it into a real stylesheet:
<link rel="preload" href="/css/main.css" as="style"
onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="/css/main.css"></noscript> Gotcha: the swap needs an inline onload handler, and a hashed Content Security Policy only allows inline event handlers with 'unsafe-hashes' plus the handler's hash. If the policy will not permit that, ship one small stylesheet, inline the critical part, and stop there - a single small blocking sheet costs less than a swap that never fires.
Optimization
Three build-step rules. Once they are in the pipeline nobody has to think about them again.
| Rule | Priority | What to do |
|---|---|---|
| Minify every stylesheet you ship | High | Lightning CSS or cssnano in the build. Vite minifies by default (esbuild, or Lightning CSS via css.transformer), the Tailwind CLI takes --minify, and PostCSS pipelines add cssnano as the last plugin. The file you edit is never the file you serve. |
| Ship only the CSS the page can use | High | Tailwind's engine emits only the utilities your source references; scoped styles (CSS Modules, Astro and Svelte <style> blocks) are deleted with the component that owns them. For a hand-written global sheet, open the Coverage tab in DevTools, sort by unused bytes, and delete by hand rather than trusting a purge tool with rules you did not write. |
| Serve subset WOFF2 fonts | Medium | WOFF2 only - no WOFF, TTF or EOT fallbacks, every current browser reads it. Subset to the scripts you use with glyphhanger or pyftsubset and split by unicode-range; a variable font replaces the per-weight files. font-display: swap, preload the one file the first screen needs, and set size-adjust on the fallback so the swap does not shift the layout. |
Gotcha: the Coverage tab marks a rule unused if nothing on the page matched it during the recording - a rule that only fires on :hover, in a modal, or at another viewport width shows red anyway. Treat it as a list of suspects, not a delete list.
Best practices
Nine rules on how the stylesheet is organized. The source's lone Keyboard rule, visible focus indicators, lives in this group rather than in a one-row section of its own, because a focus ring is a stylesheet convention and belongs next to the reset it overrides.
| Rule | Priority | What to do |
|---|---|---|
| Keep styles out of the markup | High | No style="" attributes and no <style> blocks other than the inlined critical CSS. They cannot be cached, they repeat on every page, and style attributes force 'unsafe-inline' into style-src. The one legitimate use is a custom property set from data, such as style="--progress: 40%". |
| Keep specificity low and flat | High | One class per selector, never an id, nesting no deeper than a pseudo-class, and no !important outside a utility layer. Wrap resets in :where() so they carry zero specificity, and let @layer order decide who wins instead of selector length. |
| Make focus visible on everything interactive | High | A :focus-visible outline or ring with at least 3:1 contrast against what surrounds it, offset so it is not hidden by the element's own border. Never outline: none without a replacement in the same rule. Tailwind: focus-visible:ring-2 focus-visible:ring-offset-2. |
| Include a print stylesheet | Medium | An @media print block that hides navigation, footer, cookie banners and ads, sets dark text on white, prints external link targets with a[href^="http"]::after { content: " (" attr(href) ")" }, and keeps tables and figures whole with break-inside: avoid. Check it with the print emulation in the Rendering panel. |
| Pick one naming convention and lint it | Medium | BEM, utility classes, or scoped CSS Modules - one per project, enforced with Stylelint's selector-class-pattern. A codebase that mixes them has no answer to "which file do I edit", and that question is most of the maintenance cost. |
| Start from exactly one reset | Medium | modern-normalize, Tailwind's Preflight, or a short custom reset: box-sizing: border-box everywhere, margins zeroed, img { max-width: 100%; height: auto }, -webkit-text-size-adjust: 100%. Two resets fight each other; a framework's reset plus your own is the usual way that happens. |
| Lint the stylesheets in CI | Medium | Stylelint with stylelint-config-standard (add stylelint-config-tailwindcss for Tailwind's at-rules), run through lint-staged on commit and again in CI. It catches invalid values, duplicate properties, unknown units and the vendor prefixes your build already adds. |
| Order the cascade with @layer | Low | Declare @layer reset, base, components, utilities; once at the top; a later layer beats an earlier one no matter the specificity. Tailwind v4 already works this way (theme, base, components, utilities). Pull third-party CSS into a named layer with @import url(...) layer(vendor); so it cannot outrank yours. |
| Style parents with :has() instead of a class toggled by script | Low | .card:has(img) for a card with media, form:has(:invalid) [type="submit"] to dim a button, label:has(:checked) for a selected option. Supported in every current browser, and it removes a whole category of "add a class in JavaScript" code. |
A focus ring that only appears for keyboard and assistive-technology focus, not on every mouse click:
:focus-visible {
outline: 2px solid var(--color-focus);
outline-offset: 2px;
}
/* Only drop the default where :focus-visible replaces it */
:focus:not(:focus-visible) {
outline: none;
} Gotcha: unlayered CSS beats every layer, always. One stray rule from a component library that is not imported into a layer outranks your entire utilities layer, and no amount of specificity inside a layer can win it back. Import third-party stylesheets into a named layer from day one.
Design tokens
Tokens are what make the redesign a diff to one file instead of a search across all of them.
| Rule | Priority | What to do |
|---|---|---|
| Put every design value in a custom property | High | Colors, the spacing scale, radii, the type scale and shadows as --color-brand-500, --space-4, --radius-md on :root; components reference tokens and never a raw hex value. Tailwind v4 generates them from @theme, and Open Props is a ready-made set if you would rather not design a scale. |
| Do dark mode through the tokens | Medium | Redefine the color tokens under @media (prefers-color-scheme: dark) and again under a [data-theme="dark"] selector for the manual toggle. Set color-scheme: light dark on :root (and <meta name="color-scheme">) so form controls, scrollbars and the default canvas follow; light-dark() collapses the two values into one declaration once the scheme is declared. |
| Build palettes in oklch() | Low | oklch(70% 0.15 250) - lightness, chroma, hue. Equal lightness looks equally light across hues, so a ramp built by stepping L reads as one family, and color-mix(in oklch, ...) produces tints without the muddy midpoints sRGB mixing gives you. Tailwind v4's default palette is already oklch. |
| Register animated tokens with @property | Low | @property --angle { syntax: "<angle>"; inherits: false; initial-value: 0deg; } gives the property a type, so the browser can interpolate it. That is what makes an animated gradient angle, a counting number or a color token transition smoothly instead of snapping between values. |
Tokens in a layer, with the dark values swapped by system preference and by an explicit toggle:
@layer base {
:root {
color-scheme: light dark;
--color-bg: #ffffff;
--color-ink: #111827;
--color-brand: oklch(55% 0.2 260);
--color-focus: oklch(65% 0.2 260);
--space-4: 1rem;
--radius-md: 0.5rem;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--color-bg: #0b1220;
--color-ink: #e5e7eb;
}
}
:root[data-theme="dark"] {
--color-bg: #0b1220;
--color-ink: #e5e7eb;
}
body { background: var(--color-bg); color: var(--color-ink); }
} Gotcha: prefers-color-scheme is a hint, not a setting. The moment you add a manual toggle you own the persistence: store the choice and apply data-theme to <html> in a tiny script that runs before first paint, or every page load flashes the wrong theme for a frame.
Responsive
Six rules, two of them High because they decide whether a phone user can read and zoom the page at all.
| Rule | Priority | What to do |
|---|---|---|
| Never disable pinch zoom | High | No user-scalable=no and no maximum-scale=1 in the viewport meta. It fails WCAG 1.4.4, Lighthouse flags it, and iOS Safari ignores it anyway - so it punishes Android users for nothing. Fix the double-tap zoom you were trying to avoid with touch-action: manipulation on the control. |
| Size in relative units | High | rem for type and spacing, em for things that scale with their component, % and fr for layout, clamp() for fluid type, dvh for full-height sections. px is for borders and hairlines. A user's browser font-size preference only works when the layout is in rem. |
| Let components respond to their container | Medium | container-type: inline-size on the wrapper and @container (min-width: 40rem) in the component, so the same card works in a sidebar and in the main column with one stylesheet. cqi units give fluid sizing relative to the container rather than the viewport. |
| Keep body text at 16px or larger on phones | Medium | font-size: 1rem as the floor for body copy and for every input - iOS Safari zooms into any field whose text is smaller than 16px when it gets focus, and the page never zooms back out. Line-height around 1.5 for running text. |
| Kill horizontal scroll at the source | Medium | Find the element that overflows - 100vw includes the scrollbar, an unbroken URL, a flex or grid child with min-width: auto, a fixed-width image - and fix that: overflow-wrap: anywhere, min-width: 0, minmax(0, 1fr), overflow-x: auto on a table wrapper. overflow-x: hidden on body hides the symptom and breaks position: sticky. |
| No full-screen interstitials on arrival | Medium | A popup that covers the content when a mobile visitor lands is a negative page-experience signal to Google and a bounce for everyone else. A compact dismissible banner is fine; a <dialog> opened by a user action is fine. Legal and cookie notices are the allowed exception, and even those should stay small. |
A container query. The card asks how wide its wrapper is, not how wide the window is:
.card-wrap { container-type: inline-size; }
.card { display: grid; gap: var(--space-4); }
@container (min-width: 40rem) {
.card { grid-template-columns: 12rem minmax(0, 1fr); }
} Gotcha: 100vh on a phone is the height with the browser toolbar hidden, so a full-height hero overflows by the toolbar height until the user scrolls. Use 100dvh, or min-height: 100svh when the section must never be shorter than the visible area.
Layout
The right layout system per job. The CSS layout cheatsheet has every property and eight paste-ready recipes; these are the rules.
| Rule | Priority | What to do |
|---|---|---|
| Use Grid for anything two-dimensional | Medium | Page shells, card grids, forms with aligned labels - anything with rows and columns at once. grid-template-areas for shells, repeat(auto-fit, minmax(min(18rem, 100%), 1fr)) for grids that reflow with no breakpoints, and minmax(0, 1fr) on the content column so a wide table cannot push the layout sideways. |
| Use Flexbox for one axis and let content size the items | Medium | Nav bars, button rows, form rows. gap instead of margins, flex: 1 on the one item that absorbs leftover space, min-width: 0 on any child that holds long text. If you are writing widths on flex children, you wanted Grid. |
| Write logical properties | Medium | margin-inline-start, padding-block, inset-inline-end, inline-size, text-align: start. They mirror on their own under dir="rtl" and vertical writing modes, which is the difference between an RTL launch that is a translation job and one that is a CSS rewrite. Tailwind: ms-4, ps-4, start-0. |
| Align nested content with subgrid | Low | grid-template-rows: subgrid on a card that is itself a grid item, so headings, prices and buttons line up across every card in the row even when the text lengths differ. No other technique does this without fixed heights. |
Gotcha: order and grid placement change the picture, not the DOM. Tab order and screen readers still follow the source, so a layout reordered in CSS sends keyboard focus jumping around the screen. When the visual order matters, reorder the markup.
Performance and animation
What the browser has to recompute per frame decides whether an animation is smooth on a mid-range phone, and reduced motion is not optional.
| Rule | Priority | What to do |
|---|---|---|
| Animate only transform and opacity | High | Those two run on the compositor; width, height, top, left, margin and box-shadow trigger layout or paint on every frame. Use the individual translate, scale and rotate properties, and confirm with Paint flashing in the Rendering panel that nothing repaints. |
| Contain what the browser need not reflow | Medium | contain: content on self-contained widgets so a change inside cannot trigger layout outside. content-visibility: auto with a contain-intrinsic-size on long offscreen sections - comment threads, the tail of a long document - so the browser skips their rendering work until they scroll into range. |
| Use View Transitions for page and state changes | Low | document.startViewTransition() around a DOM update, @view-transition { navigation: auto; } for cross-document navigations on a multi-page site, and view-transition-name on the elements that should morph between states. Progressive by design - a browser without it just navigates. Astro's <ClientRouter /> wraps the same API. |
Reduced motion, both ways round. Opting in per component is cleaner; the blanket rule is the fast retrofit:
/* Opt in: motion only for people who have not asked for less */
@media (prefers-reduced-motion: no-preference) {
.card { transition: translate 200ms ease; }
.card:hover { translate: 0 -4px; }
}
/* Blanket: put this in your LAST layer so it wins without !important */
@layer overrides {
@media (prefers-reduced-motion: reduce) {
*, ::before, ::after {
animation-duration: 0.01ms;
animation-iteration-count: 1;
transition-duration: 0.01ms;
scroll-behavior: auto;
}
}
} Gotcha: will-change promotes an element to its own compositor layer, and every layer costs memory. Sprinkled on many elements or left on permanently it makes scrolling worse, not better. Set it just before the animation starts and remove it after - or leave it out and let the browser decide, which is usually right.
Keep going
The loading and font rules here are half of a Core Web Vitals score; the other half is in the performance checklist, and the High rows from both are folded into the launch checklist. The HTML checklist covers the head that links the stylesheet in the first place.
For the properties themselves, the Flexbox and Grid cheatsheet has the full alignment matrix and the recipes, and the Tailwind cheatsheet maps each of them to a utility. Still choosing how to write CSS at all? The CSS and styling tools guide covers frameworks, component libraries and token systems, and Tailwind vs CSS Modules settles the authoring question with a verdict.