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. Styling a component

Styling a component: the only way#

A component is styled through @craft-ts/style, and through nothing else. Its visual rules live in a *.style.ts sheet beside it; the template binds the sheet's classes, sets data-* attributes for its variants, and writes typed variables for what changes at runtime. There is no CSS string on the meta, no .css import, no class assembled at render time and no raw style.

That is not a preference. A class built in the browser, or a rule shipped as a string, is a visual state nothing recorded: the visual matrix enumerates what the sheets declare, the static contrast check measures what the sheets write, and anything outside them is invisible to both. ESLint and the architecture suite therefore refuse every other route, and the one real exception is written down, with its reason, for someone to decide on.

The shape#

The sheet declares the classes, the axis a variant moves along, and the variables a template may write:

ts
import {
  bg,
  blockSize,
  color,
  craftStyles,
  cssVars,
  defineStateAxis,
  fontWeight,
  inlineSize,
  kind,
  p,
  palette,
  radii,
  radius,
  set,
  space,
  unit,
  when,
} from '@craft-ts/style';

/** A variant is an axis: the template sets `data-cardTone`. */
export const cardTone = defineStateAxis('cardTone', ['danger']);

/** A value that changes at runtime is a typed variable, set with `assign`. */
export const cardVars = cssVars('card', {
  progress: kind.lengthPercentage(unit.pct(0)),
  accent: kind.color(palette.accent.info),
});

export const card = craftStyles('docsCard', {
  root: [
    p(space(4)),
    radius(radii.md),
    bg(palette.surface.raised),
    color(palette.text.strong),
    when(cardTone.danger, [set(cardVars.accent, palette.accent.danger)]),
  ],
  title: [fontWeight.bold],
  bar: [
    inlineSize(cardVars.progress),
    blockSize(space(1)),
    bg(cardVars.accent),
  ],
});

The component imports it and binds one constant class per element:

ts
import { craftComponent, div, h2 } from '@craft-ts/component';
import { state } from '@craft-ts/core';
import { assign, unit } from '@craft-ts/style';
import { card, cardVars } from './card.style';

const UploadCard = craftComponent('UploadCard', {}, function* () {
  const progress = yield* state('progress', 40);
  // The variant's point, or null for none: the attribute is then removed.
  const tone = yield* state('tone', 'danger' as 'danger' | null);
  // One constant class per element; the variant is an attribute.
  return div({ class: card.root, 'data-cardTone': tone }, [
    h2({ class: card.title }, 'Upload'),
    div({
      class: card.bar,
      // The only thing `style:` accepts: a typed variable, written by assign.
      style: function* () {
        return assign(cardVars.progress, unit.pct(yield* progress()));
      },
    }),
  ]);
});
  • class is always a sheet key (card.root), an array of them, or a typed input carrying one. Never a string, a template literal or a conditional.

  • The variant is an attribute. data-cardTone is on the element, the sheet reads it through when(cardTone.danger, …), and a null removes it.

  • style accepts assign(variable, value) and nothing else — one call, several spread into an object, or a function returning them.

Where each thing goes#

You wantWrite
the component's own lookcraftStyles('name', { root: [...] }) in name.style.ts
a variant (tone, size, selected)defineStateAxis(...), then when(axis.point, [...]); the template sets data-*
a state the platform already announcesariaCurrent, ariaPressed, ariaInvalid, interaction.hover / .focus / .disabled
a value known only at runtimecssVars(...) in the sheet, assign(...) in the template
a child that follows its parent's statea variable declared with { inherits: true }, set by the parent, read by the child
page defaults (body, links, the theme)craftGlobalStyles
a web fontdefineFont
::before, @keyframes, transitionspseudo.*, keyframes, animate

The reset and the good defaults — focus ring, reduced motion, colour scheme — come from @craft-ts/style itself. An app has no styles.css to write.

What refuses the other routes#

Per file, in craftRules.configs.recommended (details):

  • no-raw-class — a class that does not trace back to a sheet imported from a *.style module;

  • no-inline-style — a style that is not assign(...);

  • no-component-css — any .css import other than virtual:craft-style.css;

  • style-file-boundary — a sheet importing anything but style vocabulary;

  • no-raw-css-value, no-free-has — a raw value or a hand-written :has() inside a sheet.

Across the application, in the base architecture rules (details):

  • style-only-design-system — an element whose class reaches no sheet the build emits;

  • no-global-stylesheet — an entry file importing a .css, or index.html linking a stylesheet;

  • style-obligations-discharged, no-dangling-css-vars — a requires nobody provides, a variable read and never declared.

The meta carries no CSS: styles, stylesUrl and cssVars do not exist on it. The only route left outside a sheet is the reasoned bypass below.

The one real exception#

Content you do not author — HTML rendered from markdown, a third-party widget that ships its own stylesheet — cannot be styled through a sheet. It is the only case, and it takes an explicit, reasoned bypass:

ts
// eslint-disable-next-line craft-ts/no-component-css -- vendor date picker ships its stylesheet
import 'vendor-date-picker/dist/picker.css';

no-forbidden-eslint-disable refuses the directive without its reason. On the architecture side, the bypass is a waiver in architecture/waivers.ts:

ts
{
  rule: 'no-global-stylesheet',
  target: 'file:src/main.ts',
  reason: 'The vendor date picker ships its stylesheet.',
}

A waiver names one target, not a rule wholesale, and one that no longer waives anything fails the check. Both kinds of bypass appear in the Bypasses view of Review Attest, one subject per directive or waiver, to be accepted or rejected like any other evidence.

See Also#

  • @craft-ts/style — the design system, from tokens to the matrix

  • Axes and the visual matrix

  • Customization — host properties and caller overrides

←Content projectionTemplate migrator→

On this page

  • The shape
  • Where each thing goes
  • What refuses the other routes
  • The one real exception
  • See Also

@craft-ts · MIT licensed

GitHubnpm

Search