← Back to the roundtable

Front End Design

The six decisions

Front end work is mostly a small set of choices made early, in a hurry, that everything afterward has to live with. Each panel below takes one of them, with the claim behind it and what it costs when it is skipped. Nothing here needs a framework; the shapes are the same in Angular, React, Vue or a page of plain HTML.

1. Design Approach to Vertical Slices

Ask which end you would be guessing at, the screen or the rules behind it. Every feature is built on two surfaces, the screen the user touches and the rules that sit behind it, and whatever you do not know yet is hiding on one of the two. Start where your confidence is lowest, because that is where the blocker is, and a blocker found late costs a rewrite instead of a decision.

This is a decision about one feature, not an architecture for the whole app. It answers which end of one screen or one flow to touch first, this week. How the application is structured is settled once, early, and revisited rarely, which is a different argument from this one.

Front end first: build the screen

Start here when the screen is what you cannot picture, whether that is the flow, the states, the wording or what the user sees first. A rough screen on a real route settles that faster than another meeting, and it makes the missing pieces obvious.

What it catches is a flow that needs data nobody stored, a state nobody designed, or a second page hiding inside the first. The cost is a screen that stays a sketch, with parts of it moving again once the real rules arrive.

Back end first: prove the rules

Start here when the rules are what you cannot picture, whether that is the data, the contract, who may see a row or whether the query holds at the size of the real data. A query or a stubbed call settles it without designing anything around it.

What it catches is a rule that cannot be enforced, a field nobody collects, or an integration that will not return what the screen needs. The cost is an interface left unpolished while the rules are being proven.

When both ends are unknown, start where a wrong answer costs more. A rule that was guessed wrong costs every screen that gets built on it; a layout that was guessed wrong costs a redraw. Probe the expensive unknown first, while the other end is still cheap to throw away.

Finding the blocker early is the whole point, because it is what lets you pivot. A blocker found in the first week is a decision, so you can reshape the flow, cut the scope, change the approach or move the work to somebody else. The same blocker found after the rest of the feature depends on it is a rewrite, and by then nobody can afford to change direction.

The takeaway Start where you are least confident. If you would be guessing at the screen, build a rough one on a real route. If you would be guessing at the rules, prove them with a query or a stub. Find the blocker while it is still a decision, pivot while the pivot is still cheap, and throw the probe away.

2. One idea per page & one call to action

A page exists so somebody can answer a question or make a decision. Give it two of those and one gets answered badly, because the design has no opinion about which matters. The test costs one sentence, and that sentence is a verb and one object.

One idea

  • “Find an invoice.” A list, a search box, a row per invoice.
  • “Pay this invoice.” One invoice, one amount, one button.
  • “Find an invoice, pay it, change the address and invite a colleague” is three screens in a trench coat. Every extra job on the page steals attention from the first.
    Two people in one long coat and hat, pretending to be a single tall person.

One idea does not mean one element. A dashboard of twelve numbers still holds a single idea, which is the question is anything wrong right now?

One call to action

  • Primary. Filled, and the only one on the screen, because it is the thing the page is for.
  • Secondary. Outline or plain, for the exits that keep the work (“Save draft”, “Preview”).
  • Tertiary. A link, for history and side roads.
  • Destructive. Not beside the primary by accident. Behind a confirmation, an overflow menu, or its own panel that names the object, such as “Delete invoice INV-1042”.
  • The label is a verb and an object. “Create account”, “Send invoice”. Never “Submit” or “OK”.

Example

Before, five buttons of equal weight, so the page has said “decide for yourself”.
After, one primary action and two ways to keep working, with everything else one click deeper.

Delete now lives in More actions, with the invoice number in its label and a confirmation before it does anything.

The call to action survives every state. It is the same element, saying something true about the moment.

The takeaway One screen, one decision. If two buttons look equally important, the user is being asked to make an interface decision that the design never made.

3. Component architecture

A component is a name for one job and a contract, props in, events out. Angular's syntax draws that contract for you, with [props] coming in and (events) going out. A component name should describe the job it performs. If you cannot name that job in a sentence, the component is doing too much and should be split.

One feature, five levels. Each level is a component with a contract, and each one is built from the level below it. Every component is shown as its three parts in order, the HTML, the JavaScript and the CSS, because the contract only makes sense with all three in view. The code is Angular and abbreviated on purpose, so it shows the shape rather than the plumbing.

The same screen with each component outlined, one inside the next, from the shell down to the pill.
The five components in the interface, each outline sitting inside the next The app shell holds the invoices page, which holds the invoice list, which holds three invoice rows, and the middle row holds the status pill. Each component is outlined and named. 5 app-shell Casting Cards Invoices Customers 4 invoices-page 3 invoice-list Recent invoices INV-1039 Ashgrove Studio $420.00 PAID 2 invoice-row INV-1042 Northwind Ltd $1,240.00 status-pill 1 OVERDUE INV-1048 Bellweather Co $86.50 DRAFT

1 Primitive: status-pill

It takes one prop, status, and emits nothing. One word and one color, and no knowledge of invoices.

HTML

<!-- status-pill.component.html: the value that came in is the word and the color -->
<span class="pill" [attr.data-status]="status">{{ status | titlecase }}</span>

JavaScript

// status-pill.component.ts: one input, four allowed words, nothing else
import { Component, Input } from '@angular/core';
import { TitleCasePipe } from '@angular/common';

@Component({
  selector: 'status-pill',
  standalone: true,
  imports: [TitleCasePipe],
  templateUrl: './status-pill.component.html',
  styleUrls: ['./status-pill.component.css'],
})
export class StatusPillComponent {
  @Input({ required: true }) status!: 'draft' | 'sent' | 'paid' | 'overdue';
}

CSS

/* status-pill.component.css: the shape once, then one color per status */
.pill {
  display: inline-block;
  padding: 2px 8px;
  border: 1px solid currentColor;
  border-radius: 999px;
  font: 700 10.5px/1.6 ui-monospace, Consolas, monospace;
  letter-spacing: .08em;
  text-transform: uppercase;
}
.pill[data-status="draft"]   { color: #6b5a45; }
.pill[data-status="sent"]    { color: #1a6b64; }
.pill[data-status="paid"]    { color: #2f7d3a; }
.pill[data-status="overdue"] { color: #bf3d24; }

2 Feature: invoice-row

It takes an invoice and emits open, with the number in its detail. The pill is handed this row's status, and the row never formats it itself.

HTML

<!-- invoice-row.component.html: the pill gets the status from this invoice -->
<button type="button" class="invoice-row" (click)="open.emit(invoice.number)">
  <span>{{ invoice.number }}</span>
  <span>{{ invoice.customer }}</span>
  <span>{{ invoice.amount | currency }}</span>
  <status-pill [status]="invoice.status"></status-pill>
</button>

JavaScript

// invoice-row.component.ts: one invoice in, one number out, no formatting of its own
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { CurrencyPipe } from '@angular/common';
import { StatusPillComponent } from '../status-pill/status-pill.component';
import type { Invoice } from '../invoice.model';

@Component({
  selector: 'invoice-row',
  standalone: true,
  imports: [CurrencyPipe, StatusPillComponent],
  templateUrl: './invoice-row.component.html',
  styleUrls: ['./invoice-row.component.css'],
})
export class InvoiceRowComponent {
  @Input({ required: true }) invoice!: Invoice;
  @Output() open = new EventEmitter<string>();
}

CSS

/* invoice-row.component.css: the inside of one row, and no outer margin */
.invoice-row {
  display: grid;
  grid-template-columns: auto 1fr auto auto;
  gap: 12px;
  align-items: center;
  width: 100%;
  padding: 8px 10px;
  color: inherit;
  text-align: left;
  background: none;
  border: 0;
  border-bottom: 1px solid var(--rule, #dcc9a8);
  cursor: pointer;
}
.invoice-row:hover { background: var(--amber-tint, rgba(201, 122, 16, .12)); }
.invoice-row:focus-visible { outline: 3px solid var(--seal, #bf3d24); outline-offset: -3px; }

3 New feature on top of it: invoice-list

It takes a label and the invoices to show, and emits selected, translated from each row's open.

HTML

<!-- invoice-list.component.html: one row per invoice, and the event passed up -->
<section>
  <h3>{{ label }}</h3>
  <invoice-row *ngFor="let invoice of invoices"
               [invoice]="invoice"
               (open)="selected.emit($event)"></invoice-row>
</section>

JavaScript

// invoice-list.component.ts: it arranges what it is handed and translates one event
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { NgFor } from '@angular/common';
import { InvoiceRowComponent } from '../invoice-row/invoice-row.component';
import type { Invoice } from '../invoice.model';

@Component({
  selector: 'invoice-list',
  standalone: true,
  imports: [NgFor, InvoiceRowComponent],
  templateUrl: './invoice-list.component.html',
  styleUrls: ['./invoice-list.component.css'],
})
export class InvoiceListComponent {
  @Input({ required: true }) label!: string;
  @Input({ required: true }) invoices!: Invoice[];
  @Output() selected = new EventEmitter<string>();
}

CSS

/* invoice-list.component.css: the space between rows belongs here, not to the row */
:host { display: block; }
h3 {
  margin: 0 0 6px;
  font: 700 11px/1.4 ui-monospace, Consolas, monospace;
  letter-spacing: .1em;
  text-transform: uppercase;
  color: #6b5a45;
}
invoice-row { display: block; }
invoice-row + invoice-row { margin-top: 2px; }

4 Page: invoices-page

It takes state and emits nothing of its own. It reacts to a selected invoice by changing the route, and it is the only level that knows an API exists. Nearly all of its code is state and branches, which is why it has almost no CSS.

HTML

<!-- invoices-page.component.html: one branch per state -->
<ng-container [ngSwitch]="state.status">
  <p *ngSwitchCase="'loading'" role="status">Loading invoices…</p>
  <p *ngSwitchCase="'empty'">No invoices yet.</p>
  <p *ngSwitchCase="'error'" role="alert">We could not load invoices.</p>
  <invoice-list *ngSwitchCase="'ready'"
                label="Recent invoices"
                [invoices]="state.invoices"
                (selected)="open($event)"></invoice-list>
</ng-container>

<!-- open(number) is the only place that touches the router -->

JavaScript

// invoices-page.component.ts: it owns the state, the request and the route
import { Component, inject, OnInit } from '@angular/core';
import { NgSwitch, NgSwitchCase } from '@angular/common';
import { Router } from '@angular/router';
import { InvoiceListComponent } from '../invoice-list/invoice-list.component';
import { InvoicesApi } from '../invoices.api';
import type { Invoice } from '../invoice.model';

type PageState =
  | { status: 'loading' }
  | { status: 'ready'; invoices: Invoice[] }
  | { status: 'empty' }
  | { status: 'error' };

@Component({
  selector: 'invoices-page',
  standalone: true,
  imports: [NgSwitch, NgSwitchCase, InvoiceListComponent],
  templateUrl: './invoices-page.component.html',
  styleUrls: ['./invoices-page.component.css'],
})
export class InvoicesPageComponent implements OnInit {
  private api = inject(InvoicesApi);
  private router = inject(Router);

  state: PageState = { status: 'loading' };

  async ngOnInit(): Promise<void> {
    try {
      const invoices = await this.api.list();
      this.state = invoices.length
        ? { status: 'ready', invoices }
        : { status: 'empty' };
    } catch {
      this.state = { status: 'error' };
    }
  }

  open(number: string): void {
    this.router.navigate(['/invoices', number]);
  }
}

CSS

/* invoices-page.component.css: no design of its own, only the space the branches sit in */
:host { display: block; padding: 16px; }
p { margin: 0; font: 14px/1.5 Georgia, "Palatino Linotype", serif; color: #6b5a45; }

5 Shell: app-shell

It takes one prop, title, and emits nothing. It provides the skip link, the header, the nav and the outlet, and it never knows which page it is holding.

HTML

<!-- app-shell.component.html: the frame every page sits in -->
<a class="skip-link" href="#main">Skip to content</a>
<header><h1>{{ title }}</h1></header>
<nav aria-label="Main">
  <a routerLink="/invoices" routerLinkActive="active">Invoices</a>
  <a routerLink="/customers">Customers</a>
</nav>
<main id="main">
  <router-outlet></router-outlet>
</main>

JavaScript

// app-shell.component.ts: layout and navigation, and it never loads data
import { Component, Input } from '@angular/core';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';

@Component({
  selector: 'app-shell',
  standalone: true,
  imports: [RouterLink, RouterLinkActive, RouterOutlet],
  templateUrl: './app-shell.component.html',
  styleUrls: ['./app-shell.component.css'],
})
export class AppShellComponent {
  @Input() title = 'Gather';
}

CSS

/* app-shell.component.css: the frame, the skip link and the current-page mark */
.skip-link {
  position: absolute;
  left: -9999px;
}
.skip-link:focus {
  left: 8px;
  top: 8px;
  padding: 8px 12px;
  background: #fffaf0;
  border: 2px solid #2a2118;
  border-radius: 8px;
}
header, nav, main { padding: 0 16px; }
nav { display: flex; gap: 10px; }
nav a.active { border-bottom: 3px solid #c97a10; }
Five nested components, from the shell to the primitive The app shell holds the invoices page, which holds the invoice list, which holds one invoice row, which holds the status pill. Each box is labeled with the job it owns. app-shell Header and navigation; the route decides which page loads. invoices-page Loads, owns the state, picks a branch; only this layer knows the API. invoice-list Holds the records to display; one invoice-row per record. invoice-row One record as a table row, showing its data. status-pill One record's status, as a word and a color the user can read.

The takeaway One job, one name, one contract. If you cannot say what a component is for in a sentence, it is not a component yet; it is a screen that happens to be in a file.

4. State management

State is the memory an application holds while it runs, such as the invoices it fetched, the filter a user typed, or the row they selected. Managing it means deciding who owns each piece, who may read it, and when it is thrown away. Left alone, that memory gets copied, goes stale, and outlives the screen that created it.

The five components in the panel above are also a memory map. The shell holds none of it. The page owns the data and the request that fetched it. The list arranges what it is given. The row and the pill hold nothing at all.

Where each piece of memory belongs

  • The URL holds anything a user would expect to survive a refresh or a shared link, which is the search text, the filters, the sort, the page number and the selected tab.Example: /invoices?q=north&status=overdue&sort=amount&page=2
  • The server cache holds the data itself, keyed by the request that produced it, with a rule for when it goes stale.Example: the rows from /api/invoices?status=overdue, and the time they arrived
  • The component holds what dies with it, such as this menu being open or this row being selected.Example: menu open, row expanded, an unsaved draft in a field
  • Derived values are not stored. A total, a count or a disabled flag is a calculation, not a second copy of the data; two pieces of state that must agree will eventually disagree.Example: total, count, canSubmit and isEmpty are all computed on read
  • Lift state only as far as the nearest common ancestor that needs it, then stop. A prop threaded through three components that never read it is state living in the wrong place.Example: the selected invoice lives on the page that shows both list and detail; a sidebar toggle stays in the shell
Memory locations, from the browser to the server Left to right, the URL in the address bar, component state, a shared app service, derived values that are never stored, and the server cache across the network. BROWSER SERVER HTTP URL q=north sort, page tab Component menu open row expanded draft App service selection session Derived total count computed, not stored Cache rows fetched at

What one owner per piece buys you

  • One source of truth. The page holds the invoices and the list receives them. Nothing else keeps a copy, so two screens cannot show different answers to the same question.
  • Leaves stay stateless. The row and the pill have nothing to reset. They render what they are handed, which is why they can move to another screen without a rewrite and be tested by passing one value.
  • Memory dies with its owner. When the page is destroyed, its data, timers and open subscriptions go with it. Coming back to the screen starts clean instead of showing last week's rows.
  • Shared memory lives in a service. A selection, a session or a filter that two features need is held once and injected, instead of being threaded through the shell and the list on the way down.
  • Derived values are computed, not stored. A total, a count or a disabled flag is calculated from state at render time. A second copy of it is a second thing that can be wrong.
  • The URL keeps what should survive. Search, sort, page number and the open record belong in the route, so a refresh, the back button and a shared link all land on the same screen.
One copy, passed down the component tree The page owns one copy of the invoices and passes it to the list. The list passes one invoice to the row, and the row passes the status to the pill. invoices-page owns the data 1 copy invoices invoice-list gets the invoices passes each one on invoice invoice-row gets one invoice passes the status on status status-pill gets one status stores nothing one copy at the top, then only the slice the next level needs

When the memory goes away

  • Unsubscribe with the component. A request, a timer or a subscription that outlives its screen keeps the old data and the old callback alive. Release it when the component is destroyed.
  • Clear what belongs to a session on sign-out. A cache that survives a logout shows the next person the last person's rows.
  • Drop what you can recompute. If a value can be calculated from state, storing it means remembering to update it.
  • Do not copy server data into local state just to render it. The cache is already the copy; a second one drifts the moment the first is refreshed.
What leaves memory, and when A trash zone with three moments. On unmount the request, subscription and timer go. On sign-out the cache, session and selection go. Values that are never stored, such as totals and counts, are recomputed on every read. TRASH On unmount request subscription timer On sign-out cache session selection Never stored total count recomputed on every read

The server call walkthrough follows one value through one request. A loading flag switches on when the request starts, is held while the request is in flight, and is dropped when the response arrives. A screen does the same thing with many values at once, which is why the rule in this panel applies at both sizes. Every value has one owner and a clear moment when it is released.

The takeaway One owner per piece of memory, and a clear moment when it is released. If two places can change the same value, neither of them is the source of truth.

5. Applying CSS with AI

CSS is not one stylesheet. It is several layers of rules, and every layer applies to the same element. The browser reads them in a fixed order and the last matching rule wins, which is why a style you wrote can quietly lose to one you never looked at.

Two questions come out of that. Which rule wins when two of them disagree, and where a rule belongs so the winner stays obvious. The first is the cascade, the second is the whole job, and the first has to come first. When a model writes the CSS, it answers both on your behalf unless the prompt answers them first.

First, the layers

Rules are read bottom to top. When two bands match the same element, the higher band wins.
The four layers of CSS that can reach one element, weakest at the bottom Browser defaults sit at the bottom, then your stylesheets, which hold both global and component rules, then an inline style, then an important rule at the top. A higher band overrides a lower one, and an arrow shows that direction. 1 Browser defaults what an element looks like with no CSS at all 2 Your stylesheets global and component rules, both in this band 3 Inline style a style attribute written on the element itself 4 An important rule wins over everything else, so keep it for emergencies stronger weaker

Before anything is painted, the browser collects every rule that matches an element and sorts them. Origin comes first, so a browser default loses to your stylesheet and your stylesheet loses to an inline style. Inside your own stylesheets, specificity decides, where .card .btn beats .btn. When two selectors are equally specific, the rule read last wins, which means file order.

Here is what that looks like when a global rule and a component rule want the same padding.

Same element, three rules. The global rule wins because it names two classes, not because it is global. Swap the load order and the answer changes. Equal specificity means the file read last wins, and the build decides that order, not your intent.
Three rules matching one button, and the most specific rule winning A browser default, a component rule with one class and a global rule with two classes all set padding on the same button. The global rule wins on specificity, and the button is drawn with its padding. button browser default padding: 1px 6px element selector .btn component stylesheet padding: 8px 14px one class .card .btn global stylesheet padding: 12px 18px two classes WHAT THE USER SEES SEND INVOICE padding: 12px 18px from the global rule

Then, where a rule belongs

Global CSS

Tokens

Named values for the design decisions that must be identical everywhere, so a color or a spacing step is written once and read by every screen.

Why: a token is the only place a value can change for everyone, so anything that has to agree across screens has to live here.

Example: --space-3: 12px in tokens.css, then padding: var(--space-3) in the component

Reset and base

The starting point every element begins from before any component touches it, replacing the browser's own defaults with the ones the app has chosen.

Why: every component inherits from this layer, so a decision made here is one you never have to repeat inside a component.

Example: box-sizing: border-box on every element, and a body font every component inherits

Layout

The rules that decide where content sits on every page, so pages agree about width, gutters and rhythm instead of each inventing its own.

Why: it sets the space every component is placed inside, and no component can fix a page level layout from the inside.

Example: one page shell with a max width, side gutters and a single column stack

A few utilities

Small single-purpose classes that do one thing wherever they are used, named for the effect rather than for a place.

Why: every screen shares them, so a vague name or a broad rule here is hard to change later without touching the whole app.

Example: .visually-hidden, used by any component that needs to hide text but keep it for a screen reader

A global selector should name a decision (--space-3), never a thing (#sidebar .card).


Scoped CSS

Internals

The styling that only makes sense inside one component, such as the padding inside a card, the gap between a label and its field, or the hover of a row.

Why: nothing outside the component can see these rules, so changing them cannot break another screen.

Example: .card { padding: 12px; } in InvoiceCard.module.css

Anything that would be wrong elsewhere

A rule that only holds for this one component, which is the whole test. Would this rule be wrong on another screen?

Why: if the answer is yes it was never global, it was global with better luck, and the next screen to reuse it will pay for the guess.

Example: a two column split that only the invoice detail screen uses

Placement is the parent's job

The space between two components belongs to the component that arranges them, not to the children it places.

Why: a component carrying its own outer margin can only ever be placed one way, which is exactly what a reusable component cannot afford.

Example: .list { display: grid; gap: 8px; } on the list, instead of a margin on each row

What still crosses a scoped boundary

Custom properties inherit

A custom property set on an ancestor reaches every descendant, inside a scoped component or not.

Text styles inherit

Font, color, line height and direction pass down from the page whether the component asks for them or not.

Layout leaks outward

An absolutely positioned element, a stacking context or a fixed height can change where elements outside the component sit.

Global element selectors

A rule for a tag such as button or ul applies inside a scoped component too.

Only Shadow DOM is a real wall

It blocks outside rules completely, except custom properties, which pass through by design.

Escape hatches

:global(…) in CSS Modules and ::ng-deep in Angular let a rule out of the scope, so each one needs a comment.

Rule by rule

A color, size, radius or font

A global token, as a custom property.

What to tell the model: use the token that already exists, never a new hex value or pixel size inside a component.

Example: color: var(--ink) instead of color: #2a2118

Page width, gutters, section rhythm

Global layout styles.

What to tell the model: change the layout layer, not the outer margins of each component.

Example: --page-max: 1080px in the layout layer, read by the shell

How a component looks inside

That component's scoped styles.

What to tell the model: put the rule in the component's own stylesheet, and add no global selector for it.

Example: .card { padding: var(--space-3); } in InvoiceCard.module.css

The space between two components

The parent that places them.

What to tell the model: put the gap on the list that arranges the rows, not on the row.

Example: .list { display: grid; gap: var(--space-2); } on the list, no margin on the row

What every button looks like

One shared button component, not five stylesheets.

What to tell the model: reuse the shared button, and do not restyle buttons screen by screen.

Example: one shared app-button, used by every screen

One deliberate exception

:global(…) with a comment saying why.

What to tell the model: say why in a comment, and expect a question if there are more than a few.

Example: /* why: third party markup */ :global(.flatpickr-calendar) { … }

The takeaway Layers decide who wins. Ownership decides who should. Read the cascade first, then place each rule once, in the layer that owns the decision. A rule that would be wrong on another page was never scoped, it was global with better luck.

6. Accessibility first

Accessibility is a constraint at design time, a task at build time, and a rewrite after release. The fix is the same in all three cases, but the cost depends on when it is implemented.

Where the same fix lands, and what it costs there
When it is caughtWhat it isWhat it costs
Designchoosing a control that already works with a keyboard and a screen reader, so nothing has to be added laterminutes, and the design gets simpler because there is less to build
Buildadding labels, focus order, error text and a missing state to a control that is already drawnhours, and it fights the styling, because the control was not chosen for this
After releaserefactoring finished code that is already public, which puts behavior users depend on at risk of new bugsthe clients' trust, and the time spent fixing a regression they can see

Five questions to ask while the screen is still a sketch. None of them needs a tool, and each one can remove an option before it becomes code.

Errors that people can act on

A real label, tied to the field

What it is: a visible name for the field input, written in the markup and connected to the input it belongs to. What to do: use <label for="email">, never a placeholder standing in for the label.

The message belongs to the field

What it is: the error text is attached to the control it describes, so a screen reader reads it when focus reaches that field. What to do: point aria-describedby at the message, set aria-invalid="true", and move focus to the first field that needs fixing.

Say what to do

What it is: get specific with what the user needs to do to continue their work vs a generic "error" message. What to do: write “Use at least 12 characters” instead of “Invalid password”.

Never color alone

What it is: red text or a red border carries no message for a color-blind reader or a greyscale print. What to do: add an icon or the word “Error” beside the color.

Keep what was typed

What it is: a failed submit that clears the form makes the user type everything a second time. What to do: re-render the form with the values still in it, and add only the validation messages.

Announce what changed by itself

What it is: a change that happens without a click is silent unless the page announces it. What to do: mark “Saved” with role="status" and a failure with role="alert", and never ship a spinner with no text.

44×44 CSS pixels

What it is: the smallest size for anything a finger has to hit, with space between targets. What to do: size the targets in the layout, not in a later audit.

Contrast

What it is: how easy it is to tell text apart from the color behind it. When the two are close in lightness, the text fades into the background, first for people with low vision, then for anyone reading a phone in bright sunlight. Text has to be clearly darker or lighter than its background, and a focus ring has to stand out from whatever it sits on. What to do: use the color tokens instead of picking colors by eye. The palette has already been checked, so a token pair passes without you measuring anything. If you have to add a color, put it through a contrast checker, which does the math for you.

Reduced motion

What it is: animation can make some people ill, and the operating system can ask the page for less of it. What to do: drop every transition when the system asks for reduced motion. It is one rule in the stylesheet.

The takeaway Accessibility first is not “add ARIA later”. It is a set of constraints that removes options. If a design cannot be made accessible without a rewrite, change the design now. It is cheaper, and it usually makes the screen better for everyone who was not in the room when it was designed.

Sources