Skip to content

UX Strategy

Design system audit: check your live site against your own rules

Generic checks can't tell you whether your site still follows your own colors, type and spacing. How to audit the shipped site against your system.

Your UX Agent teamProduct
16 min read

A design system audit of your live site checks whether the pages you ship still use the colours, type, spacing and radii your system declares. You read the values the browser renders on one page of each template, at a phone and a desktop width, map each value to a token, and write down every value that maps to none.

Those leftovers are the drift: a fourth shade of blue, a 15px paragraph, a card with corners nobody chose. Each one you keep becomes a rule a browser can check on the next release.

This is one part of the AI coding agent fix loop: an agent keeps to a system best when its rules are written down and checked.

Two kinds of design system audit

The term covers two jobs that are easy to mix up:

  • Auditing the system. Are the tokens, components and documentation complete, named consistently and in agreement with each other? This is a common meaning in design-system guides.
  • Auditing the site against the system. Does what ships follow the system? This post covers this one.

The second is narrower than a UI audit, which goes component by component and state by state. Our post on running a UI audit of your interface covers that inventory. Here the unit is the value: every colour, font size, weight, line height, radius and spacing value the browser applied, and whether it came from a token.

Three sources of truth

A design system usually exists in three forms:

  • Declared: the tokens and the written rules. Tokens may live in CSS custom properties, a theme file, or a JSON file in the W3C Design Tokens Community Group format, whose first stable version, 2025.10, was published in October 2025 as a Community Group report, not a W3C Standard.
  • Drawn: the components and styles in your design files.
  • Rendered: what the browser applied on each page, after every stylesheet, override, inline style and script.

Only the rendered form reaches your visitors. A token file can be perfect while a checkout template hard-codes its own blue, a CMS editor pastes styled text, and a third-party widget brings its own fonts. So the audit starts from the rendered side and works back to the declared one, not the other way round.

Where the declared and drawn forms disagree with each other, settle that first, as a decision about the system. Comparing the site with two baselines at once gives you findings nobody can act on.

What a generic audit cannot judge

An accessibility checker knows WCAG’s thresholds. It does not know your tokens. Three examples from the same imaginary site, whose action colour is #0B57D0:

  • An off-brand colour that passes. A promotion banner’s button uses #1A73E8. Against white it measures 4.505:1, just over the 4.5:1 that 1.4.3 Contrast (Minimum) asks of normal text, so a contrast check reports nothing. It is still a second blue nobody chose.
  • A failing colour with a system answer. The brand accent, #F28C28, is 2.45:1 on white and fails as text. A contrast tool that suggests a fix offers the nearest colour that passes. Your system already has a darker accent, #A85300 at 5.38:1, for exactly this case. Only your rules know the right fix is that token.
  • A value that breaks no rule but yours. Article paragraphs at 15px, and card corners of 6px where the system has 4px and 8px. WCAG sets no minimum font size, so neither is an accessibility failure. Both are drift.

Contrast tools that suggest a fix, ours included, do not know your tokens: our contrast fix guide names the nearest colour that passes, so the colour it names need not be one of them. The audit in this post adds the missing input, your own values.

Step 1: extract rendered values

Pick one representative page of each template, as you would for any sampled audit, and read every rendered value from the browser. getComputedStyle() returns the values the browser resolved for an element, after every rule that applies to it.

This Node script uses Playwright to open each page at 390 and 1280 pixels wide and count the values it finds. It reads text colour and type only from elements that contain text of their own, skips elements with no layout box and the default transparent background, and prints one row per value with an example element and the pages where it occurs:

rendered-values.mjs
// node rendered-values.mjs https://example.com/ https://example.com/products/chair// Lists the colours, type, radii and spacing the browser renders on each page,// at a phone and a desktop width, with how often each value occurs.import { chromium } from "playwright";​const WIDTHS = [390, 1280];const pages = process.argv.slice(2);​function collect() {  const found = new Map();  const add = (property, value, element) => {    const key = `${property} ${value}`;    const entry = found.get(key) ?? { property, value, count: 0, example: "" };    entry.count += 1;    if (!entry.example) {      const classes = [...element.classList].slice(0, 2).map((name) => `.${name}`).join("");      entry.example = element.tagName.toLowerCase() + classes;    }    found.set(key, entry);  };  for (const element of document.querySelectorAll("body, body *")) {    if (element.getClientRects().length === 0) continue; // not rendered    const style = getComputedStyle(element);    const hasText = [...element.childNodes].some((node) => node.nodeType === Node.TEXT_NODE && node.textContent.trim());    if (hasText) {      for (const property of ["color", "font-family", "font-size", "font-weight", "line-height"]) {        add(property, style.getPropertyValue(property), element);      }    }    const background = style.getPropertyValue("background-color");    if (background !== "rgba(0, 0, 0, 0)" && background !== "transparent") add("background-color", background, element);    const radius = style.getPropertyValue("border-radius");    if (radius !== "0px") add("border-radius", radius, element);    for (const property of ["padding-top", "padding-right", "padding-bottom", "padding-left", "row-gap", "column-gap"]) {      const value = style.getPropertyValue(property);      if (value !== "0px" && value !== "normal") add("spacing", value, element);    }  }  return [...found.values()];}​const browser = await chromium.launch();const totals = new Map();for (const url of pages) {  for (const width of WIDTHS) {    const page = await browser.newPage({ viewport: { width, height: 900 } });    await page.goto(url, { waitUntil: "load" });    for (const entry of await page.evaluate(collect)) {      const key = `${entry.property} ${entry.value}`;      const total = totals.get(key) ?? { ...entry, count: 0, where: new Set() };      total.count += entry.count;      total.where.add(`${new URL(url).pathname} at ${width}px`);      totals.set(key, total);    }    await page.close();  }}await browser.close();​// One row per value: rare values at the bottom of each property are the ones to look at.const rows = [...totals.values()].sort((a, b) => a.property.localeCompare(b.property) || b.count - a.count);console.log("property,value,count,example,where");for (const row of rows) {  console.log([row.property, `"${row.value}"`, row.count, row.example, `"${[...row.where].join("; ")}"`].join(","));}

A few rows of its output, from two test pages we planted drift in:

rendered-values.csv (excerpt)
property,value,count,example,wherebackground-color,"rgb(11, 87, 208)",4,button.button--primary,"/ at 390px; / at 1280px; /products/chair at 390px; /products/chair at 1280px"background-color,"rgb(26, 115, 232)",4,button.button--primary,"/ at 390px; / at 1280px; /products/chair at 390px; /products/chair at 1280px"border-radius,"6px",4,div.card,"/ at 390px; / at 1280px; /products/chair at 390px; /products/chair at 1280px"font-size,"16px",12,p,"/ at 390px; / at 1280px; /products/chair at 390px; /products/chair at 1280px"font-size,"13.3333px",8,button.button--primary,"/ at 390px; / at 1280px; /products/chair at 390px; /products/chair at 1280px"font-size,"15px",4,p,"/ at 390px; / at 1280px; /products/chair at 390px; /products/chair at 1280px"

Four things to know about what it reports:

  • It is the default state only. Hover, focus, open menus and error states need an interaction first. Add those pages and states by hand, or extend the script to focus and hover elements before it reads them.
  • Browser defaults show up. The 13.3333px row is real: in Chromium, a button with no font rules of its own reports the browser’s control font, not your body type. Form controls do not inherit font unless your stylesheet tells them to.
  • The example is one element. The script names the first element it met with each value. Search the page in your browser’s developer tools for the rest.
  • It reads the page’s own elements only. It does not enter shadow roots or iframes, where chat, consent and review widgets often live, and it leaves out border and shadow colours and margins. Add properties if you need them.

Step 2: map values to tokens

Rendered values come back in the form the browser serialises them, so a token written as #0B57D0 shows up as rgb(11, 87, 208), and a size in rem as pixels. The simplest way to compare like with like is to let the browser convert your tokens too. Run this in the console on any page that loads your tokens:

Browser console: resolve a token the way the browser renders it
function rendered(property, value) {  const probe = document.createElement("div");  probe.style.setProperty(property, value);  document.body.append(probe);  const result = getComputedStyle(probe).getPropertyValue(property);  probe.remove();  return result;}​rendered("background-color", "var(--color-action)"); // "rgb(11, 87, 208)"rendered("font-size", "0.9375rem");                  // "15px"

The probe resolves a token as a child of <body> would see it, and rem against the page’s root font size. Check a token scoped to a theme or a component inside an element that carries that scope. A token that is not defined returns no error, only a fallback: rgba(0, 0, 0, 0) for a background, 0px for a radius, and the body’s own value for inherited properties such as color and font-size. So confirm the token exists first: getComputedStyle(document.documentElement).getPropertyValue("--color-action") is empty when it does not.

One exception: a colour written in oklch(), oklab(), lab(), lch() or color(), or produced by color-mix() or relative colour syntax, comes back in a form other than rgb(), even when its inputs were hex. If some tokens are hex and some pages render those forms, compare the colours by value in one colour space, not as text.

Then sort every row of the extraction into one of these, using its count and its pages:

  • It is a token. Nothing to do.
  • It is arithmetic on a token. A line height of 22.5px is 15px × 1.5: the line height is fine, the 15px it multiplies is the drift. Fix the cause and the derived value follows.
  • A rare off-system value. A value on one element of one template is a one-off. Replace it with the nearest token, or ask why it exists.
  • A common off-system value. A value used across templates is either a component that bypasses your tokens or a value your system is missing. Decide which before anyone fixes a page.

Written up, the test page’s drift reads like this:

  • Background rgb(26, 115, 232), on promotion buttons on two templates. Nearest token: the action colour, rgb(11, 87, 208). A hard-coded override in the promotion styles; replace it with the token.
  • Font size 13.3333px, on every button. Nearest token: the 14px label size. Buttons inherit no font; add font: inherit to the button base style, then apply the label size.
  • Font size 15px, on article paragraphs. Nearest token: 16px body. A one-off in the article styles.
  • Radius 6px, on cards. The system has 4px and 8px. Ask the design system owner which one cards use.

Step 3: check the rendered pairs

Your documentation may say every colour pair passes. The rendered site may combine tokens in ways the documentation never listed: muted text on a tinted panel, a link colour inside a dark footer. While you have the rendered values, check the combinations that actually occur:

  • Text against its background (1.4.3): at least 4.5:1, or 3:1 for large text. Our guide to measuring colour contrast covers large text and the exemptions.
  • What identifies a control and its state (1.4.11): the visual information someone needs to identify a field, a checkbox or a selected tab needs 3:1 against the colours next to it. Inactive controls are exempt, and so are controls left in the browser’s default appearance.
  • Focus styles: keyboard focus must be visible on the controls you reach with the keyboard (2.4.7). The browser’s default indicator meets that as long as your styles do not remove or hide it. If your system has a focus token, also check that focused controls render it: that part is your rule, not WCAG’s.

A failing pair in the rendered site is often a token used outside the context it was made for. The fix belongs in the system, as a rule about which tokens may meet, so it holds on every page:

Before: the accent used as text, 2.45:1 on white
.promo-banner a {  color: var(--color-accent); /* #F28C28 */}
After: the system's text variant of the accent, 5.38:1 on white
.promo-banner a {  color: var(--color-accent-text); /* #A85300 */}

The second version passes because the token was chosen for text on light surfaces, and it stays on-brand because it is part of the system, not a colour picked to clear a threshold.

Step 4: keep the answers as rules

Each decision from step 2 can become a rule a browser checks: a selector, a property, and the value it may take or the minimum it must reach.

  • .button--primary has background-color rgb(11, 87, 208).
  • .article-body p has a font-size of at least 16px.
  • .card has a border-radius of 8px.

Run them on one page of each template, at both widths, in your test suite. Our UI audit post has a Playwright file for rules in this form, with the cases where it can pass by mistake. If a coding agent writes your UI code, the same rules belong in the file it reads before every task: see how to write a design manual a coding agent can follow.

Step 5: stop drift where it starts

Drift usually comes from a few places. Each has a check that catches it before it ships:

  • Hard-coded values in component styles. Stylelint’s built-in color-no-hex rule flags hex colours; add color-named set to "never" and a function-disallowed-list for rgb, hsl, their alpha forms and any other colour function you allow only in tokens, such as oklch and color-mix. Switch them off for your token file only, with an overrides entry.
  • Arbitrary values in utility classes. Classes such as bg-[#1A73E8] or text-[15px] are out of a CSS linter’s reach. A search for -[ in your templates finds most of them.
  • Content from the CMS. Rich-text fields that keep pasted inline styles bring their own fonts and colours. Strip inline styles on save, or limit the editor to your own formats.
  • Third-party widgets. Chat, consent and review widgets bring their own styles. Decide whether they are in scope, and theme them with your tokens where they allow it.
  • Code written fast, by people or agents. Run the rules from step 4 in CI, beside the accessibility checks you run during development, so a typed-in value fails before merge.

Then repeat the extraction after each release that touches shared styles, and before a redesign. The rows that changed since last time are the place to look first.

Design system audit checklist

  1. Decide which form of the system wins when the token file and the design files disagree.
  2. List one page per template, plus any page whose content differs from the rest of its template.
  3. Extract the rendered values at a phone and a desktop width.
  4. Add hover, focus, open and error states by hand for the components that have them.
  5. Resolve your tokens through the browser so they compare with rendered values as text, and compare modern colour syntaxes by value.
  6. Sort every value: token, arithmetic on a token, rare off-system value, common off-system value.
  7. Check the colour pairs the pages actually render, against 1.4.3 and 1.4.11.
  8. Write each decision as a rule with a selector, a property and an allowed value or a minimum.
  9. Fix at the token or component, not the page.
  10. Run the rules in CI and re-extract after releases that touch shared styles.

Frequently asked questions

What is a design system audit?

The term covers two jobs. One reviews the system itself: whether its tokens, components and documentation are complete and agree with each other. The other checks the live site against the system: whether the colours, type, spacing and radii the browser renders still come from your tokens. This post covers the second.

How do you find design drift on a live site?

Read the computed styles of every rendered element on one page per template, at a phone and a desktop width, and count each value. Resolve your tokens through the browser so they compare as text, then map every rendered value to a token. Values that map to none are the drift; how often and where they occur tells you whether each is a one-off or a gap in the system.

Can a generic accessibility audit find design system drift?

Only where the drift also breaks a WCAG threshold. A stray blue with at least 4.5:1 contrast, 15px body text or a 6px radius passes a generic accessibility check. Finding them needs your own values as the baseline.

What is the difference between a design system audit and a UI audit?

A UI audit inventories the interface component by component and state by state against the design system. A design system audit of the live site works value by value: every colour, size and spacing value the browser applied, and whether a token produced it.

How often should you audit the live site against your design system?

Re-extract the rendered values after each release that touches shared styles, and before a redesign. Between audits, run the rules you wrote from the last one in CI, so the same drift fails before it ships.