CanonCSS
GitHub
Menu

Open source · MIT · v0.6.0

Your design system,
written down where a machine can check it.

A closed styling vocabulary plus a linter that fails the build when you leave it. Humans remember conventions. Agents do not, and there are more of them every month.
35.7kb raw, 6.2kb gzipped, zero JavaScript, zero build step, zero dependencies.

See the vocabularyOpen the playground

The difference

Fifteen classes, or one role and one variant

Markup should say what something is, not carry every styling decision needed to build it. Both of these are real output: the first is what a competent Tailwind house-style prompt produced for a call to action, the second is the same button in Canon.

<a class="inline-flex items-center justify-center rounded-lg
   bg-indigo-600 px-4 py-2 text-sm font-semibold text-white
   hover:bg-indigo-700 focus:outline-none focus-visible:ring-2
   focus-visible:ring-indigo-600 focus-visible:ring-offset-2">
<a data-component="button" data-variant="primary">

The hover state, the focus ring, the radius, the weight and the padding are the button's job. The colour moved to a theme file, where changing it changes every button at once.

Measured

Across ten pages of the same two specs

Styling vocabulary
286 Tailwind classes63 Canon attribute pairs
Decisions per page
715 Tailwind104 Canon
Structural reproduction
91%Generate one spec five times and compare the results to each other.

Regenerate any of these yourself with npm run census and npm run repro. Every figure on this site comes out of a script, not a slide.

The part that lasts

A prompt is not a specification

A strict house style written into a prompt produces consistent output. We measured that, and it is in the research below. But the prompt governs one generation. It is not in the repository, the next agent may not receive it, and nothing checks the diff.

Canon puts the same rules in the codebase. The vocabulary is the markup, and canon-lint exits 1 when something leaves it. That is the difference between a convention and a specification: one is remembered, the other is enforced.

What drift actually looks like

Six clean-context agents were asked to give the cards rounder corners. All six wrote border-radius: var(--radius-lg), which is the value the card already had. All six reported the job done.

Nothing rendered differently and the diff looked like work. That is rule R9, and those six files are now its regression test. A reviewer would have approved every one of them.

The hard part of a real codebase

Change the identity without rewriting the interface

Generating a page once is the easy case. The expensive one is changing an application you already have: a rebrand, an acquisition, a second product on the same system, or a year of small edits by people and agents who never met.

Because the markup says what something is and never how it looks, the identity lives in one file of token overrides. Measured on two deliberately opposite brands: each overrode 49 tokens, only two landed on the same value, and the markup stayed 96% structurally identical.

A utility framework cannot make that trade, because there the brand and the markup are the same artefact. Changing the look means editing every element.

Where Canon loses

A control group says a closed vocabulary is not the only fix. A strict Tailwind prompt with a written-down house style reproduced as well as Canon on one spec and beat it on another. Canon's case is that it is that written vocabulary, already done, mechanically checked, and a third the size.

The whole study, including the conditions where it comes second, is on the research page.

Checked, not promised

The loop closes

A closed vocabulary is only worth something if a machine can tell when you leave it. Canon ships the tools that do.

canon-lint
Eleven rules, zero dependencies, exit 1 on violation. It also reads your theme and your escape-hatch CSS, and it catches the quiet failures: a misspelled token that overrides nothing, a rule that restates a default, a layer name that silently switches the linter off.
TypeScript
The vocabulary ships as declarations generated from the same table the linter enforces, so data-layout="stak" stops compiling and the editor suggests "stack". One line in your tsconfig.

Theming

This page is the demo

Canon's default look is warm paper, a Charter serif and vermilion. Nothing you are looking at is warm, serif or vermilion. The markup of this site never changed: a theme file of token overrides did, and the whole framework retargeted to match the logo.

That is the claim a utility framework cannot make, because there the brand and the markup are one artefact. Measured on two deliberately opposite brands: each overrode 49 tokens, only two landed on the same value, and the markup stayed 96% structurally identical.

:root {
  --color-brand: #131313;
  --color-accent: #0b8074;
  --radius-md: 0;
  --font-display: ui-monospace, monospace;
  --shadow-sm: 2px 2px 0 rgb(19 19 19 / 0.08);
}

Five of the fifty-five lines that make this site look like itself.

Install

One file, no build step

<link rel="stylesheet" href="canon.css">

Or through npm, where it is one import in your root layout and nothing else:

npm install canoncss
import 'canoncss/dist/canon.css'

You can also take the code and keep it. npx -p canoncss canon-init copies the stylesheet, a starter theme and an AGENTS.md into your repo, after which you can uninstall the package and nothing breaks. A closed vocabulary should not also be a lock-in.

The rules

All of it, in five lines

  1. Only defined tokens. If a value has no token, it does not exist.
  2. No inline styles, no extra CSS.
  3. An element gets data-layout or data-component, never both.
  4. data-slot only as a direct child of its parent.
  5. Anything the vocabulary does not cover goes in @layer canon.app, built from tokens. The linter counts those rules, because that number is what Canon is missing for your product.

Seventeen components. Seven layouts.

That is the entire surface, and it is meant to stay small.

Browse the componentsGet the prompt