Menu
akaSTYLE

Library · Foundations

Tokens, scale & motion

The measurable half. Every number the system uses, in one place, with the reasoning attached, because a scale you can't justify is a scale you'll abandon. Drop the block at the bottom into a new repo and the primitives land correctly.

Color

Ground and ink

Two tokens carry almost everything: a ground and an ink. OKLCH throughout, so a dark-mode flip is a lightness change rather than a re-pick, and the values below are the ones actually in globals.css, light then dark.

tokenswatchvalue
--background0.955 0.002 106 / 0.09 0 0
--foreground0.122 0.001 0 / 0.985 0 0
--muted-foreground0.36 0.001 0 / 0.708 0 0
--border0.88 0.003 106 / white 10%
--select0.58 0.13 250 / 0.707 0.108 152.216
--status-warn0.58 0.13 75 / 0.72 0.13 75
--status-danger0.55 0.2 25 / 0.62 0.2 25
--ink-on-artcopy over artwork, both themes1 0 0 / 1 0 0
--ink-link0.4 0.08 152.2 / 0.707 0.108 152.216

One accent, and it is not --primary. --select is what a selected tab, selected text and a focus ring take, and it is the only place a hue is allowed to lead. It changes hue across themes rather than lightness, because the blue that reads as “chosen” on paper goes muddy on the dark ground. --primary is near-black ink in light and the green in dark; it is a shadcn token the primitives inherited, not the system's accent.

The opacity ladder, and its floor

Ink steps down, and that is the whole hierarchy: full ink for the terms a reader will look for again, then the reading step, then the quiet layer. Each step is a modifier on the token, text-foreground/85, and the config renders it as a color-mix against the page. The mix is sRGB rather than OKLCH because the tokens are achromatic with an explicit hue of 0, and an OKLCH mix would interpolate it.

The ladder has a floor, and the floor is law 09: no text is mixed below the point where it reads at 4.5:1 on the page in both themes. For the foreground that is text-foreground/60. For the muted ink, which starts closer to the ground, it is text-muted-foreground/75, and ink on art holds the same 75. The check computes both from the tokens in globals.css, so a token that moves takes its floor with it, and the sweep reads every page back through axe in both themes to prove what rendered.

Two steps are classes as well, because an essay names its ink once rather than carrying a number through every paragraph:

.aka-ink-body  { color: color-mix(in srgb, var(--foreground) 82%, transparent) }
.aka-ink-quiet { color: color-mix(in srgb, var(--foreground) 62%, transparent) }

The slot is recent. For a long time the theme colours were bare var() values with nowhere to put an alpha, every modifier compiled to nothing, and the whole quiet layer rendered as full ink. The config now answers a modifier with the mix above. The light muted ink was darkened a step so the ladder reads the same on both grounds, and the floor followed once the quiet layer was measured rather than eyeballed: at 70 it read at 4.2:1.

Surfaces

The ground under a mark

A project plate is one colour: the plate ground with a percentage of the project's own hue mixed into it. Both halves are tokens, and both differ per theme for reasons that are not symmetry.

tokenswatchvalue
--stamp-ground0.92 0 0 / 0.2 0 0
--plate-mixHow much of the project's hue reaches the plate10% / 7%
--surface0.945 0.004 106 / 0.145 0 0

Dark is the reference. Light is not the same number: a hue over a light ground shows up more readily than the same hue over a dark one, so light takes more mix to read as the same character. And --stamp-ground steps down from the page in light while it steps up in dark. It used to sit lighter than the background, which made every plate in light mode effectively invisible.

The mix is sRGB, not OKLCH. The ground is achromatic but carries an explicit hue, and an OKLCH mix interpolates that channel: a green, a blue and a violet all came out pink. It is also why the plate ground is neutral rather than warm.

Palettes that belong to one drawing

When artwork has a value per theme, the palette goes in custom properties and the browser picks. Reading the theme in JavaScript would make a static drawing a client component, which is the whole cost being avoided. Two live examples: --bp-*, which carries Blockpad's dark and light icon masters, and --pixel-face-*, which re-homes the five face accents the circleheads handoff supplies as literal hex.

The rule that keeps this honest: the source value stays written down where the design put it, and the token is where it is read from. So dark restates the handoff's hex exactly and only light diverges.

The one exception to Tailwind

An artefact ported in from elsewhere keeps its own stylesheet, scoped to a class, the way app/trickle.css keeps the kit's keyframes and bodylog-v1/v1.css keeps the circleheads token set under .bl1. Rewriting a hundred and fifty custom properties as utilities is a redesign, not a port, and the point of keeping an artefact is that it is the version something was decided from.

Scoped, always, so none of it reaches the site. And a ported theme hangs off its own attribute rather than the site's .dark, because the artefact's theme and the page's are not the same state.

Space

The rhythm

A 4px base, but only six steps are ever used. Sections breathe at 64, cards at 20, and related things sit 8–12 apart. Constraint is the point: fewer choices, faster decisions, consistent result.

tokenswatchvalue
gap-2
Icon to label, chip to chip
8px
gap-3
Buttons in a row, list items
12px
mt-4
Inside a card, between blocks
16px
p-5
Card padding, the default
20px
mt-10
Header to first section
40px
mt-16
Between sections
64px

Shape

Radius & rule

Radius scales with the surface: the bigger the box, the softer the corner. One border weight everywhere: 1px at token colour. Depth comes from surface lightness, not from a glow. Nothing the system itself draws carries a shadow, and the ones you will find in the repo are inside product mockups drawing someone else's interface.

Three steps are derived from --radius: 0.625rem in the Tailwind config, so moving one number moves the set. Only xl and the default are Tailwind's own.

rounded4pxcode chips
rounded-sm6pxtags, sm buttons
rounded-md8pxinputs
rounded-lg10pxbuttons, media
rounded-xl12pxcards, panels
rounded-fulldots, toggles, avatars

Type

The ramp

One family, four weights, and a ramp that leans light. Display sizes get extralight with negative tracking; small text gets medium with positive tracking. The inversion is deliberate: it is what makes small type read as a label rather than shrunken body copy.

text-1010px

Ship the smallest true thing.

text-1111px

Ship the smallest true thing.

text-1212px

Ship the smallest true thing.

text-1313px

Ship the smallest true thing.

text-1414px

Ship the smallest true thing.

text-1515px

Ship the smallest true thing.

text-1717px

Ship the smallest true thing.

text-2020px

Ship the smallest true thing.

text-displayfluid

Ship the smallest true thing.

The scale is closed. These nine are the sizes the config names, and a size is one of them or it is not on the site: an arbitrary pixel size is a violation the check reports.

Motion

Timings & the rule

One rule governs everything, including the brand engines: energy moves space, never brightness: scale, position, displacement. No strobe, no flash, no opacity pulsing. It keeps long loops watchable and the whole system safe for photosensitive viewers.

tokenswatchvalue
150msHover, focus: color onlytransition-colors
200msTab content swapanimate-in fade-in duration-200
300msImage hover scaletransition-transform duration-300
450msScroll revealopacity + translateY(16px)
700msHero entrancemotion-safe:animate-in fade-in slide-in-from-bottom-2
120msStagger stepanimationDelay: step * 120ms

Always gated. Entrances use motion-safe: and canvas loops check prefers-reduced-motion before starting. Reduced motion renders one representative frame, never a frozen blank.

Layout

Widths & breakpoints

tokenswatchvalue
max-w-2xlWrite-ups: one column of prose672px
max-w-3xlReference pages, galleries768px
max-w-siteLanding, project index1180px
site-insetThe universal gutter, widening by breakpointpx-5 → px-16
sm:One column → two640px
md:Stacked hero → side by side768px
lg:Two columns → three1024px

No device sniffing. Layout responds to width; touch-specific behavior responds to pointer: coarse and hover. There is no isMobile anywhere in any repo running this system.

Transfer

The whole system, in one block

Two files, because this is Tailwind v3 and the mapping from token to utility lives in a config. It used to be printed here as v4 (@import "tailwindcss", @theme inline, no config file), which does not parse on v3 and is exactly the mistake a generator makes when it guesses the version.

/* globals.css */
:root {
  --background:       oklch(0.955 0.002 106);
  --foreground:       oklch(0.122 0.001 0);
  --muted-foreground: oklch(0.36  0.001 0);
  --border:           oklch(0.88  0.003 106);
  --select:           oklch(0.58  0.13  250);  /* the one accent */
  --radius:           0.625rem;
}

.dark {
  --background:       oklch(0.09  0 0);
  --foreground:       oklch(0.985 0 0);
  --muted-foreground: oklch(0.708 0 0);
  --border:           oklch(1 0 0 / 10%);
  --select:           oklch(0.707 0.108 152.216);
}

@layer components {
  .max-w-site { max-width: 1180px; }
  .site-inset { @apply px-5 sm:px-6 md:px-12 lg:px-16; }
  /* the two reading inks, named so an essay carries a name rather than a number */
  .aka-ink-body  { color: color-mix(in srgb, var(--foreground) 82%, transparent); }
  .aka-ink-quiet { color: color-mix(in srgb, var(--foreground) 62%, transparent); }
}
// tailwind.config.cjs
module.exports = {
  darkMode: ['class'],
  // every hover: compiles inside @media (hover: hover), so a tap on a
  // touch screen cannot latch a hover state that never releases
  future: { hoverOnlyWhenSupported: true },
  content: ['./app/**/*.{ts,tsx}', './components/**/*.{ts,tsx}'],
  theme: {
    extend: {
      maxWidth: { site: '1180px' },
      colors: {
        background: 'var(--background)',
        foreground: 'var(--foreground)',
        border:     'var(--border)',
        muted: { DEFAULT: 'var(--muted)', foreground: 'var(--muted-foreground)' },
      },
      borderRadius: {
        lg: 'var(--radius)',
        md: 'calc(var(--radius) - 2px)',
        sm: 'calc(var(--radius) - 4px)',
      },
    },
  },
  plugins: [require('tailwindcss-animate')],
}

With the block above plus primitives and the brand engine, a new repo starts with the same design language on day one, which is the entire point of writing this down.