Skip to main content
@craft-ts
LearnLearn with EffectAI agentsGuideReference
@craft-ts/core@craft-ts/component@craft-ts/dev-tools@craft-ts/effect@craft-ts/style@craft-ts/style-testing@craft-ts/i18n@craft-ts/i18n-effect@craft-ts/mcp
ExamplesMigrationEffect compatibility & maturityAdopting CraftTS progressivelyPress kitRoadmapBacklog
Beta
  • Learn
  • Learn with Effect
  • AI agents
  • Guide
  • Reference
  • Packages
  • Resources
  • Guide overview
  • Create a project
i.Core concepts
  • The mental model
  • Which primitive should I use?
  • Anatomy of a primitive
  • Generators and yield*
  • Insertions
  • Typed insertion pipes
  • Exceptions as values
ii.Managing state
  • Local state
  • State machines
  • query
  • Mutations
  • queryParams
  • asyncProcess
  • Selecting
  • Reacting to mutations
  • Collections
  • Collection utilities
  • Persistence
  • GlobalPersisterHandler
  • Pagination placeholders
  • Schema validation
iii.Recommended approaches
  • Inject at the point of use
iv.Structuring the app
  • craftService
  • Service scopes
  • Shaping the public API
  • Abstract services
  • App start
  • Lazy services
  • Server functions
  • craftRegisterFor
  • Target wrapper
v.Routing & type-safe DI
  • Setup
  • CLI automation
  • ESLint rules
  • Route providers
  • Route guards
  • Route exception handling
  • Non-blocking navigation
  • Global error component
  • Route load errors
  • Scaling routes
vi.Components & templates
  • Components
  • Activating the style system
  • Typed styles
  • Defining a design system
  • Tokens and variables
  • Global foundation and fonts
  • Pseudo-elements and animations
  • Axes and the matrix
  • Context obligations
  • Testing visual states
  • Text contrast
  • Visual attestation
  • Template obligations
  • Typed CSS variables
  • Fine-grained reactivity
  • Progressive rendering
  • Directives and .pipe(...)
  • settledValue & pendingNode
  • Accessibility
  • Customization
  • Content projection
  • Styling a component
  • Template migrator
vii.Forms
  • Overview
  • Validators
  • Submitting
  • Nested forms
  • Exception handling
  • Complete examples
viii.Testing
  • Testing services
  • Testing components
  • Type-level tests
  • Browser boundaries
  • Page stability
  • Architecture rules
    • Overview
    • Declarative baseline
    • Unique identities
    • HTTP endpoint ownership
    • Computed purity
    • Dependency cycles
    • Mutation reactions
    • Route DI proofs
    • Route component files
    • Path boundaries
    • Exclusive links
    • Persisted identities
    • insertSelect keys
    • craftEffect and network
    • craftEffect and imperative sync
    • Interactive element names
    • Metric thresholds
    • Server-state loaders
    • Primitive loader requirements
  • Extensible architecture graph
  • Graph insights
  • Craft graph vs Nx
ix.Reactivity utilities
  • craftComputed
  • craftEffect
  • craftMethod
  • source$
  • on$
  • fromEventToSource$
  • sourceFromEvent
  • afterRecomputation
x.Internationalisation
  • Type-safe i18n
  • The catalogue
  • Tokens
  • The runtime
  • With Effect
xi.Deployment (experimental)
  • Overview
  • Manifest reference
  • Diagnostics
  • Providers
  • Alchemy provider
xii.Going further
  • SSR and hydration
  • Effect integration
  • Program operators
  • Pattern matching
  • Observability
  • AI agents
  • Temporal runtime
  1. Components & templates
  2. Defining a design system

Defining your design system#

The other pages in this section spend bp, scheme, palette, v, space and unit as if they were already in scope. They are not built in — except scheme, which is. This page is where the rest come from.

Everything here goes in one sheet, conventionally foundation.style.ts. A *.style.ts may import vocabulary and nothing else — the style-file-boundary rule enforces it — which is exactly what makes it safe for the build plugin to import the file in Node: there is no application code in it to run.

ts
import {
  at,
  axisPoint,
  craftStyles,
  cssVars,
  darkOf,
  defineAxis,
  defineBreakpoints,
  defineContainer,
  definePalette,
  defineStateAxis,
  kind,
  onlyVarsOfKind,
  scheme,
  seal,
  set,
  space,
  unit,
  when,
} from '@craft-ts/style';

The palette#

definePalette takes a group-of-tokens shape where every token carries both of its values at once:

ts
export const palette = definePalette({
  surface: {
    page: { light: '#fbfbfd', dark: '#0b0d11' },
    raised: { light: '#ffffff', dark: '#151922' },
  },
  text: {
    strong: { light: '#111318', dark: '#f2f4f8' },
    muted: { light: '#5b6472', dark: '#98a2b3' },
  },
  accent: {
    neutral: { light: '#4a5568', dark: '#a6b0c0' },
    danger: { light: '#a11b1b', dark: '#ff6b6b' },
  },
});

@craft-ts/style already exports a palette built the same way — the same four groups, with neutral defaults. Spend it as-is to get moving, and call definePalette when you want your own colours; the pages that follow use the name palette for whichever one is in scope.

A token is not a colour string; it is a pair plus a role, and the role comes from the group it sits in (surface, text, border, accent). darkOf(token) is how a sheet reaches the other side of the pair, and the role travels with it — the dark side of a surface is still a surface.

Components never read the palette directly. They read a theme variable, and the theme is the single place that decides what a variable holds in light and in dark. That indirection is what makes dark mode one rule instead of one rule per component.

Axes#

A component may only vary along an axis you declared. There are four ways to declare one, and the choice is about what drives the variation.

defineBreakpoints — the viewport#

ts
export const bp = defineBreakpoints({
  sm: at.minInlineSize(unit.rem(30)),
  md: at.minInlineSize(unit.rem(48)),
  lg: at.minInlineSize(unit.rem(64)),
});

Breakpoints are an ordered axis: two points can be compared, which is what lets the matrix reduce by interval instead of by product, and what makes a rule that can never apply detectable. above(...) and below(...) turn a point into an explicit bound.

defineStateAxis — an attribute you set#

ts
/** Drives `data-tone` on the element that carries it. */
export const tone = defineStateAxis('tone', [
  'neutral',
  'danger',
] as const);

/** Drives `data-size`. */
export const size = defineStateAxis('size', ['sm', 'md', 'lg'] as const);

Each point carries the driver that reaches it — here data-tone='danger' — so a scenario the matrix enumerates is a scenario a test can actually produce. The attribute-value form, rather than one attribute per state, is what makes the states mutually exclusive by construction: an element cannot be two of them at once, so the matrix does not have to be told.

defineAxis — a state axis with a write constraint#

ts
// This axis may only ever write colours. A `<color>`-only axis cannot move a
// box, so it crosses additively with the axes that do — and the constraint is
// checked at the `when` call site, not by reading the emitted CSS afterwards.
export const brand = defineAxis('brand', ['acme', 'globex'] as const, {
  ...onlyVarsOfKind(kind.color),
});

onlyVarsOfKind(kind.color) says this axis may write <color> custom properties and nothing else. An axis that can only write colours cannot move a box, so it crosses additively with the axes that do rather than multiplying them. The constraint is checked where it is cheap — at the when call site — instead of by reading the emitted CSS afterwards.

defineStateAxis is defineAxis without the options object; it stays a separate name because the unconstrained case is the common one and reads better without them.

defineContainer — the size of a box, not of the window#

ts
// Closed at the element that declares the container: nobody above it can change
// how wide the box is, so the axis must not travel past it.
export const card = defineContainer(
  { name: 'card', type: 'inline-size' },
  { narrow: at.minInlineSize(unit.rem(20)), wide: at.minInlineSize(unit.rem(40)) },
);

A container axis answers "how wide is my box", which nobody above the container can change. So it is closed at the element that declares the container: the matrix prunes it there rather than letting every ancestor inherit scenarios it has no way to affect.

The standard axes#

scheme, motion, forcedColors, contrast, scrollState and descendant ship with @craft-ts/style and need no declaration — they are driven by the user agent or by the element's own state, not by an attribute you own. scheme is the one the theme below uses.

interaction (hover, focus, active, disabled) reads the element's own pseudo-classes. Three more read an ARIA attribute instead of a data-* one, so the look and what assistive technology announces cannot disagree:

AxisOpens onTypical element
ariaCurrent.page[aria-current='page']the router's active link
ariaCurrent.true[aria-current='true']the current item of a list
ariaPressed.pressed[aria-pressed='true']a toggle button that is on
ariaInvalid.true[aria-invalid='true']a field whose value was refused

Set the attribute for accessibility; the sheet follows it. A second data-* attribute for the same state would be one more thing to keep in sync.

axisPoint — the escape hatch#

axisPoint(axis, point, open, driver, extra) builds a point by hand, for a selector none of the four constructors produce. You give up nothing type-side, but you take on the part the constructors were doing for you: the driver must really reach the open selector, and nothing checks that for you.

The theme#

cssVars(prefix, specs) declares the typed custom properties. Each is registered through @property, so the browser validates it: assigning a length where a colour belongs paints nothing rather than painting wrong.

ts
// `inherits: true` belongs to theme variables and to nothing else: they are set
// once on a wrapper and read by everything below. The default, `false`, bounds
// invalidation to the element that both sets and reads the variable.
const themed = { inherits: true } as const;

export const theme = cssVars('ds', {
  surface: kind.color(palette.surface.page, themed),
  ink: kind.color(palette.text.strong, themed),
  inkMuted: kind.color(palette.text.muted, themed),
  accent: kind.color(palette.accent.neutral, themed),
  // Absolute, because `@property` requires a computationally independent
  // initial value: `1rem` makes the browser drop the registration, silently.
  gutter: kind.length(unit.px(16), themed),
});

export const dsTheme = craftStyles('dsTheme', {
  root: [
    set(theme.surface, palette.surface.page),
    set(theme.ink, palette.text.strong),
    set(theme.gutter, space(4)),
    when(bp.md, [set(theme.gutter, space(6))]),
    // Dark mode is one rule, here — not one rule per component.
    when(scheme.dark, [
      set(theme.surface, darkOf(palette.surface.page)),
      set(theme.ink, darkOf(palette.text.strong)),
    ]),
  ],
});

Two things on that block are worth stopping on.

inherits: true belongs to theme variables and to nothing else. The default is false, and that is the right default for a variable an element sets on itself and reads on itself — it bounds invalidation to that element. A theme variable is the opposite case: set once on a wrapper, read by everything below. A non-inheriting theme hands every descendant the initial value instead, which looks exactly like dark mode not working, with no error anywhere.

kind.length(unit.px(16)), not unit.rem(1). @property requires a computationally independent initial value; a relative one makes the browser drop the registration entirely, and silently. The theme writes the rem value in the rule below.

One `cssVars` for the theme and for a component

The same cssVars declares a design system's theme and a single component's styling API — per-instance variants, inheritance, forwarding, runtime values. See Typed CSS variables.

seal — closing a tree#

seal(node) is the only place a context obligation becomes an error. Up to that point an unanswered requirement keeps travelling, because an ancestor still has the right to answer it. seal is you saying: from here up, nobody else will.

Put it at the root of a route, not around the component that raised the requirement — that is the whole point of letting obligations travel.

Next#

  • Tokens and typed variables — spending what you just declared.

  • Axes and the visual matrix — when, and the matrix the axes above generate.

  • Context obligations — requires, provides, and seal.

←Typed stylesTokens and variables→

On this page

  • The palette
  • Axes
  • defineBreakpoints — the viewport
  • defineStateAxis — an attribute you set
  • defineAxis — a state axis with a write constraint
  • defineContainer — the size of a box, not of the window
  • The standard axes
  • axisPoint — the escape hatch
  • The theme
  • seal — closing a tree
  • Next

@craft-ts · MIT licensed

GitHubnpm

Search