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. Customization

Customizing components and directives#

Craft splits customization into three layers, and which one you reach for depends on how far the change should travel:

LayerChanges
Root-element hostThe component's own root defaults
A *.style.ts sheetIts appearance
Composable directivesBehaviour, reusable across components

Start with host for one component's defaults, and move to a directive only when the same customization needs to apply somewhere else too.

Customizing the root element#

The component meta host properties define defaults for the component’s root element. The caller can extend or override them:

ts
import {
  bg,
  borderColor,
  borderStyle,
  borderWidth,
  craftStyles,
  cssVars,
  defineStateAxis,
  kind,
  lineWidth,
  p,
  palette,
  radii,
  radius,
  set,
  space,
  when,
} from '@craft-ts/style';

/** A title's ink, set by the card and inherited by whatever sits inside. */
export const cardVars = cssVars('customCard', {
  ink: kind.color(palette.text.strong, { inherits: true }),
});

export const cardActive = defineStateAxis('cardActive', ['true']);

export const cardSheet = craftStyles('docsCustomCard', {
  root: [
    p(space(4)),
    radius(radii.md),
    bg(palette.surface.raised),
    when(cardActive.true, [set(cardVars.ink, palette.accent.info)]),
  ],
  /** Added by a caller: it writes the border, which `root` leaves alone. */
  featured: [
    borderWidth(lineWidth.thick),
    borderStyle.solid,
    borderColor(palette.accent.info),
  ],
});
ts
import { craftComponent, div, h2 } from '@craft-ts/component';
import { cardSheet } from './card.style';

const Card = craftComponent(
  'Card',
  {
    host: {
      class: cardSheet.root,
      attrs: { role: 'article' },
    },
  },
  () => div([h2('A card')]),
);

// `class` merges with the host's; `attrs` would replace the host's `attrs` as a
// whole, so a single attribute is passed as a property.
const FeaturedCard = craftComponent('FeaturedCard', {}, () =>
  Card({ class: cardSheet.featured, 'data-testid': 'featured-card' }),
);

Classes, attributes, styles, and events recognized as host properties are applied to the component root. Other properties remain factory props. A caller's class is added to the host's, so the two classes must not write the same property — featured writes the border, which root leaves alone. Everything else, attrs included, replaces the host's value.

Values can be reactive. The class stays constant; what moves is an attribute the sheet reads as an axis:

ts
Card({
  class: cardSheet.featured,
  'data-cardActive': function* () {
    return String(yield* active());
  },
});

Customizing the appearance#

A component's look lives in a sheet beside it and nowhere else — see Styling a component: the only way. The template binds the sheet's classes:

typescript
import { panel } from './panel.style';

const Panel = craftComponent(
  'Panel',
  {},
  () =>
    div({ class: panel.root }, [
      h2({ class: panel.title }, 'Panel'),
      button('save', { class: panel.action, type: 'button' }, 'Save'),
    ]),
);

A sheet's classes are atomic: they apply where they are bound and nowhere else, so there is no scope to manage and nothing leaks into a child component.

Adding reusable customization with a directive#

A directive transforms a component’s factory and template. It is applied from left to right with .pipe(...):

ts
import { highlight } from './highlight.style';

const Highlight = craftDirective(
  'Highlight',
  {},
  (baseLogic) => baseLogic,
  (baseTemplate) => (context) =>
    baseTemplate(context, { class: highlight.root }),
);

const HighlightedPanel = Panel.pipe(Highlight);

A directive can also add context and public props:

ts
const WithPermission = craftDirective(
  'WithPermission',
  {},
  (baseLogic) => (user: Input<User>) => ({
    ...baseLogic(user),
    canEdit: () => user().permissions.includes('edit'),
  }),
  (baseTemplate) => (context) =>
    context.canEdit() ? baseTemplate(context) : [],
);

const EditablePanel = Panel.pipe(WithPermission);

A directive brings its own sheet and adds its class to the host's, so the same directive can be reused by several components without introducing an HTML wrapper.

Composing providers and exception handlers#

withProviders configures the provider scope of a component before it is invoked. catchTag.exhaustive is a logic boundary: each handler is a generator that can call a service or perform another logic operation. It must not return template children. Use catchNode.exhaustive or matchNode.exhaustive when the exception should produce DOM.

ts
import { abstract, craftException, craftService } from '@craft-ts/core';
import {
  catchTag,
  craftComponent,
  p,
  withProviders,
} from '@craft-ts/component';

const noAccess = craftException({ _tag: 'NO_ACCESS' });
const { RestrictedData, provideRestrictedData } = craftService(
  { name: 'restrictedData', scope: 'abstract' },
  abstract<string | typeof noAccess>(),
);

const MyRestrictedCraftComponent = craftComponent(
  'MyRestrictedCraftComponent',
  {},
  function* () {
    const value = yield* RestrictedData();
    return p(`Private data: ${value}`);
  },
);

const Restricted = MyRestrictedCraftComponent.pipe(
  withProviders([
    provideRestrictedData(() =>
      currentUserCanRead() ? 'available' : noAccess,
    ),
  ]),
  catchTag.exhaustive({
    NO_ACCESS: function* () {
      // yield* ToastService.show(() => 'No access');
    },
  }),
);

Restricted();

Providers are evaluated before the component template. If a provider reads a signal, changing that signal recreates the composed rendering, including the provider scope. The handler generator runs for the exception state. Since catchTag does not render a template, use catchNode or matchNode for a visual fallback.

The component adapter reuses the exhaustive catchTag rules from the core and the composed component carries the exception codes produced by its initializer and providers. The providers also participate in the normal Craft DI graph, so they can satisfy dependencies used by the component and its children. The variadic component .pipe(...) overload is currently kept permissive to avoid excessive TypeScript instantiation depth; runtime dispatch still rejects an unhandled exception code.

Providers configured by component inputs#

Use withComponentProviders when a component-local service needs the component's inputs. Its callback receives the template's typed Input<T> readers and runs once per rendered instance. Each instance owns its provider configuration and DI scope. Updating an input, the template, or a host prop keeps that scope and its services alive.

Before, a provider configured in metadata can only refer to values available outside the component instance:

ts
const Profile = craftComponent(
  'Profile',
  { providers: [provideProfileContext({ profileId: selectedProfileId })] },
  (_inputs: { profileId: Input<string> }) => p(ProfileContext.label),
);

After, pass the component's reader through $provided. The service consumes it as CraftServiceInput<T> inside a reactive primitive:

ts
import {
  craftComputed,
  craftService,
  type CraftServiceInput,
} from '@craft-ts/core';
import {
  craftComponent,
  p,
  withComponentProviders,
  type Input,
} from '@craft-ts/component';

const { ProfileContext, provideProfileContext } = craftService(
  { name: 'ProfileContext', providedIn: 'toProvide' },
  function* (inputs: { $provided: { profileId: CraftServiceInput<string> } }) {
    yield* craftComputed('label', function* () {
      return `Profile: ${yield* inputs.$provided.profileId()}`;
    });
  },
);

const Profile = craftComponent(
  'Profile',
  {},
  (_inputs: { profileId: Input<string> }) => p(ProfileContext.label),
).pipe(
  withComponentProviders(({ profileId }) => [
    provideProfileContext({ profileId }),
  ]),
);

The providers satisfy local dependencies and contribute their own dependencies and exception codes to the component's type contract. Chain another .pipe(...) to add an exception handler while preserving type inference.

The list and its order must stay fixed. Put reactive variation in the values passed to services, such as the reader used by a craftComputed or query's params. Do not conditionally add, remove, or reorder providers. The recommended ESLint preset enforces this with craft-ts/require-fixed-component-provider-list. The callback must be a concise arrow returning an array literal, without spreads or provider-selection branches. Helpers may contain their own implementation logic; the rule checks the declared list only.

withProviders remains available with its existing reactive provider lifecycle described above. withComponentProviders selects a stable component scope; when combining provider operators, their entries retain declaration order and readers carry subsequent value changes.

Use a toProvide service for state or context owned by this component, even if this component is its only consumer. withComponentProviders passes its inputs once into that service's $provided configuration; the component and its children then resolve the same X() instance without repeating those inputs. Use a function service for reusable per-call logic composed by other services, where each X(inputs) call should get a fresh context. See service scopes for the full choice.

Choosing an exception utility#

Craft exposes three complementary utilities. The important distinction is whether the exception is handled in logic or rendered in a template:

  • catchTag.exhaustive handles component initialization exceptions in logic;

  • catchNode.exhaustive creates a template boundary and can insert a fallback before or after its source block;

  • matchNode.exhaustive renders a fallback from an exception value or signal.

catchTag.exhaustive: logic only#

Handlers are generator functions. They can call services and yield other Craft operations, but they cannot return p(...), an element, or any other template children. A DOM fallback belongs to catchNode or matchNode.

ts
const SafeComponent = MyRestrictedCraftComponent.pipe(
  withProviders([
    provideRestrictedData(() =>
      currentUserCanRead() ? 'available' : noAccess,
    ),
  ]),
  catchTag.exhaustive({
    NO_ACCESS: function* (exception) {
      yield* ToastService.show(() => `Access denied: ${exception._tag}`);
    },
  }),
);

catchNode.exhaustive: preserve a source block#

Apply it to a rendered VNode when the source subtree may throw. The source is kept and the fallback is inserted at the requested position. Applying it to a component in .pipe(...) also creates a residual component boundary and removes the handled codes from the component and route contracts.

ts
const view = SourceComponent({}).pipe(
  catchNode.exhaustive(
    {
      UserNotFoundException: () => p('User not found'),
    },
    { position: 'after' },
  ),
);

For a template boundary, the source block remains visible by default. When catchNode is piped onto a component and the exception comes from its composed scope, a function handler keeps the existing component behavior and replaces the source. A handler can keep that source visible by using the object form and setting showSource: true:

ts
const view = SourceComponent({}).pipe(
  catchNode.exhaustive({
    UserNotFoundException: {
      render: () => p('User not found'),
      showSource: true,
      position: 'after',
    },
  }),
);

With showSource: true, the source and fallback are both rendered. Use showSource: false to hide the source explicitly. position can be set on each handler (before or after); the second argument remains available as a default for handlers that do not specify their own position. Existing function handlers keep their previous behavior. If the component fails while it declares what it takes, or one of its providers does, there are no nodes to preserve, so the fallback is rendered alone.

matchNode.exhaustive: render a resource exception#

Use it when a query, mutation, or another primitive exposes an exception as a signal instead of throwing from the template subtree. The block renders no children while the source is empty and switches reactively to the matching handler when an exception appears.

ts
matchNode.exhaustive(() => userQuery.exceptions().loader, '_tag', {
  UserNotFoundException: () => p('User not found'),
  UserConsentMissingException: () => p('Consent is required'),
});

What Craft handles directly#

Craft supports compositions that are not native properties of a standard the host component or directive:

  • a Craft directive can add its own classes to the root of the component using it, without a wrapper;

  • multiple directives can compose their service transforms, template and host classes through .pipe(...);

  • the CSS itself is emitted once, at build time, by the @craft-ts/style plugin: nothing is injected or reference-counted at runtime.

Choosing the right level#

  • host: identity, attributes, classes, or behavior of the root element;

  • a *.style.ts sheet: the component's appearance, its variants as axes, its runtime values as typed variables;

  • craftDirective: behavior or customization reusable across components;

  • the component's own service: component-specific state and dependencies.

How a parent reaches a child#

A parent never styles a child's internals. It has two doors, both visible in the child's contract: a class it passes to the child's host, and a variable declared with { inherits: true } that the child's sheet reads.

ts
import { color, craftStyles, fontWeight } from '@craft-ts/style';
import { cardVars } from './card.style';

/** The title reads the card's variable; it never looks at the card itself. */
export const titleSheet = craftStyles('docsCardTitle', {
  root: [color(cardVars.ink), fontWeight.bold],
});
ts
import { craftComponent, div, h2, type Input } from '@craft-ts/component';
import { state } from '@craft-ts/core';
import { cardSheet } from './card.style';
import { titleSheet } from './card-2.style';

const CardTitle = craftComponent(
  'CardTitle',
  {},
  (props: { readonly text: Input<string> }) =>
    h2({ class: titleSheet.root }, props.text),
);

const Card = craftComponent('Card', {}, function* () {
  const title = yield* state('title', 'Card');
  // The card sets `data-cardActive`; its sheet writes the inherited
  // variable; the title, a separate component, reads it.
  return div({ class: cardSheet.root, 'data-cardActive': 'true' }, [
    CardTitle({ text: title }),
  ]);
});

The card sets data-cardActive, its sheet writes cardVars.ink, and the title, a separate component, reads it. Nothing in the card knows how the title is built.

Names passed to craftComponent and craftDirective must be unique and match their declaration names. The dedicated ESLint rules detect missing or inconsistent names.

See Also#

  • Styling a component

  • Directives and .pipe(...)

  • Content projection

←AccessibilityContent projection→

On this page

  • Customizing the root element
  • Customizing the appearance
  • Adding reusable customization with a directive
  • Composing providers and exception handlers
  • Providers configured by component inputs
  • Choosing an exception utility
  • catchTag.exhaustive: logic only
  • catchNode.exhaustive: preserve a source block
  • matchNode.exhaustive: render a resource exception
  • What Craft handles directly
  • Choosing the right level
  • How a parent reaches a child
  • See Also

@craft-ts · MIT licensed

GitHubnpm

Search