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. Content projection

Content projection#

Projection is a rendering context, not a category of component. The same craftComponent can be rendered directly or supplied into a compatible logical slot — its definition doesn't change either way.

Use it when a component composes content it doesn't own: a card with a caller-supplied body, a toolbar filled with actions, a dialog with its buttons. Not when the child is fixed — just render it.

Both forms go through one primitive:

ts
renderContent(value);

It accepts either deferred DOM content (RenderableContent) or a component unit exposing a logical contract. There is no runtime registry like contentChildren, and no special projection component.

The common case — free DOM content#

ContentSlot describes optional or free-form DOM content. RequiredContent adds a structural contract that TypeScript checks.

typescript
import {
  content,
  craftComponent,
  div,
  renderContent,
  section,
  type ContentSlot,
  type RequiredContent,
} from '@craft-ts/component';

type CardInput = {
  readonly header?: ContentSlot;
  readonly body: RequiredContent<{
    readonly selector: {
      readonly tag: 'div';
      readonly 'data-slot': 'body';
    };
  }>;
};

const Card = craftComponent('Card', {}, ({ header, body }: CardInput) =>
  section([
    header ? renderContent('header', header) : 'Default title',
    renderContent('body', body),
  ]),
);

Card({
  header: content(() => div('Title supplied by the caller')),
  body: content(() => div({ 'data-slot': 'body' }, 'Card content')),
});

The selector is analysed statically. This is rejected, because it does not contain div[data-slot="body"]:

ts
Card({
  // @ts-expect-error the content does not satisfy the slot's DOM contract.
  body: content(() => div({ 'data-slot': 'footer' })),
});

The contract names an attribute, not a class. A class comes from a sheet and is a list of atoms — not a name a selector can require — while a data-* attribute is a stable, declared part of the markup.

Content can be built from arrays, conditions, loops and templates — the analysis looks for the selector in every rendered branch:

ts
const body = content(() => [
  showIntro() ? div({ 'data-slot': 'body' }, 'Introduction') : undefined,
  forNode(rows(), { track: (row) => row.id }, (row) =>
    div({ 'data-slot': 'body' }, row.label),
  ),
  renderTemplate(cardRowTemplate, { $implicit: selectedRow() }),
]);

Card({ body });

The constraint creates no wrapper and adds no runtime validation. DOM contracts and logical contracts are independent:

RequiredContent<Requirement>  → the shape of the DOM supplied
ProjectionOf<Component>       → the logical capabilities of a component

Logical projection by contract#

A component becomes projectable when it declares the contract it answers in its meta, with projection<Contract>().

ts
import {
  button,
  craftComponent,
  projection,
  renderContent,
  type ContentSlot,
  type ProjectionContractOf,
  type ProjectionOf,
} from '@craft-ts/component';

type ToolbarActionContract = {
  readonly kind: 'toolbar-action';
  readonly trigger: () => void;
  readonly disabled: () => boolean;
};

// The contract a parent may read is declared in the meta; the component's own
// function stays what it always is — inputs in, nodes out.
const ToolbarAction = craftComponent(
  'ToolbarAction',
  { projection: projection<ToolbarActionContract>() },
  function* (input: {
    readonly key: string;
    readonly content: ContentSlot;
    readonly trigger: () => void;
    readonly disabled?: () => boolean;
  }) {
    return button(
      'action',
      {
        type: 'button',
        disabled: input.disabled ?? (() => false),
        click: input.trigger,
      },
      renderContent(input.content),
    );
  },
);

type ExtractedContract = ProjectionContractOf<typeof ToolbarAction>;
type ToolbarActionUnit = ProjectionOf<typeof ToolbarAction>;

ProjectionContractOf<Component> extracts that contract. ProjectionOf<Component> adds the stable key the renderer expects. For generic consumers, ProjectionSlot<Contract> directly describes a collection of compatible units.

The marker is type-only: projection<Contract>() carries a contract and nothing else. Projection therefore depends on neither the component's name nor a runtime registry — the host reads the contract from the type.

Explicit collections, order and stable keys#

The consuming component receives a typed collection explicitly. Each unit must supply a stable key, which forNode uses to reuse, move or remove the right projection.

ts
import {
  craftComponent,
  div,
  forNode,
  renderContent,
  type ProjectionOf,
} from '@craft-ts/component';

const Toolbar = craftComponent(
  'Toolbar',
  {},
  ({
    actions,
  }: {
    readonly actions: readonly ProjectionOf<typeof ToolbarAction>[];
  }) =>
    div(
      { role: 'toolbar' },
      forNode(actions, { track: (action) => action.key }, (action) =>
        renderContent(action),
      ),
    ),
);

Toolbar({
  actions: [
    ToolbarAction({ key: 'save', content: () => 'Save', trigger: save }),
    ToolbarAction({ key: 'cancel', content: () => 'Cancel', trigger: close }),
  ],
});

The same ToolbarAction stays usable on its own:

ts
const Page = craftComponent('Page', {}, () => [
  ToolbarAction({
    key: 'standalone',
    content: () => 'Direct action',
    trigger: save,
  }),
  Toolbar({
    actions: [
      ToolbarAction({
        key: 'projected',
        content: () => 'Projected action',
        trigger: save,
      }),
    ],
  }),
]);

Styling projected content#

The component styles its own frame around the slot; the content is styled by whoever writes it, with their own sheet. What the frame offers its content travels the way everything crosses a component boundary in @craft-ts/style: inherited properties — color, fonts — and variables declared with { inherits: true } that the content's sheet chooses to read.

ts
import {
  color,
  craftStyles,
  cssVars,
  display,
  kind,
  p,
  palette,
  space,
} from '@craft-ts/style';

/** What the card offers its content: an inherited accent it may read. */
export const styledCardVars = cssVars('styledCard', {
  accent: kind.color(palette.accent.info, { inherits: true }),
});

export const styledCard = craftStyles('styledCard', {
  // The frame around the slot. `color` inherits into the content.
  body: [display.block, p(space(4)), color(palette.text.strong)],
});

/** The caller's own sheet, for the content it supplies. */
export const noteSheet = craftStyles('styledCardNote', {
  root: [color(styledCardVars.accent)],
});
ts
import {
  content,
  craftComponent,
  div,
  p,
  renderContent,
  type ContentSlot,
} from '@craft-ts/component';
import { noteSheet, styledCard } from './styledcard.style';

const StyledCard = craftComponent(
  'StyledCard',
  {},
  (props: { readonly body: ContentSlot }) =>
    div({ class: styledCard.body }, renderContent('body', props.body)),
);

const Page = craftComponent('Page', {}, () =>
  StyledCard({
    body: content(() => p({ class: noteSheet.root }, 'Styled by its caller')),
  }),
);

Nothing reaches into the content: a caller that does not read styledCardVars.accent is unaffected by it, and a nested Craft component keeps its own classes.

Pitfalls#

Forgetting the stable key. Without it the renderer cannot tell one projected unit from another across updates, and reuse breaks.

Expecting a plain component to satisfy a contract slot. It stays perfectly usable as a direct child, but the slot rejects it:

ts
const PlainCard = craftComponent(
  'PlainCard',
  {},
  () => 'Card with no contract',
);

Toolbar({
  actions: [
    // @ts-expect-error PlainCard does not expose ToolbarActionContract.
    PlainCard({}),
  ],
});

An incomplete contract is rejected where it is declared:

ts
const invalidContract = {
  kind: 'toolbar-action',
  // @ts-expect-error trigger and disabled are required.
} satisfies ToolbarActionContract;
Combining optional content and contractual actions — a dialog

A component can mix optional DOM content with several logical slots in one explicit collection:

ts
const Dialog = craftComponent(
  'Dialog',
  {},
  ({
    body,
    actions,
  }: {
    readonly body?: ContentSlot;
    readonly actions: readonly ProjectionOf<typeof ToolbarAction>[];
  }) =>
    section({ role: 'dialog' }, [
      body ? renderContent(body) : [],
      footer(
        forNode(actions, { track: (action) => action.key }, (action) =>
          renderContent(action),
        ),
      ),
    ]),
);

Dialog({
  body: content(() =>
    div(['Delete the account', 'This action cannot be undone.']),
  ),
  actions: [
    ToolbarAction({
      key: 'cancel',
      content: () => 'Cancel',
      trigger: closeDialog,
    }),
    ToolbarAction({
      key: 'delete',
      content: () => 'Delete',
      trigger: deleteAccount,
    }),
  ],
});

closeDialog and deleteAccount are captured by the caller's closures. Projection preserves the lexical context and the injector of wherever the unit or the content was declared.

Conditions, reactivity and cleanup

Projections are ordinary Craft nodes, so they can sit inside conditions and templates while keeping their identity by key within a collection. Here visible is a callable reactive value supplied by the caller:

ts
const OptionalToolbar = craftComponent(
  'OptionalToolbar',
  {},
  ({
    visible,
    actions,
  }: {
    readonly visible: () => boolean;
    readonly actions: readonly ProjectionOf<typeof ToolbarAction>[];
  }) =>
    visible()
      ? forNode(actions, { track: (action) => action.key }, (action) =>
          renderContent(action),
        )
      : [],
);

On update the renderer adds, removes and moves projections by key. On teardown the projected content, its effects and its styles are cleaned up with the rest of the tree.

API summary#

  • content(renderer, options?) — create deferred DOM content

  • renderContent(value) and renderContent(slotName, value) — render it

  • RenderableContent, ContentSlot — free-form slots

  • RequiredContent<Requirement> — static DOM contracts

  • ProjectionContractOf<Component> — extract a logical contract

  • ProjectionOf<Component>, ProjectionSlot<Contract> — type projectable collections

The older fragment and slot primitives are no longer part of the public API.

See Also#

  • Customization

  • Styling a component

  • Directives and .pipe(...)

←CustomizationStyling a component→

On this page

  • The common case — free DOM content
  • Logical projection by contract
  • Explicit collections, order and stable keys
  • Styling projected content
  • Pitfalls
  • API summary
  • See Also

@craft-ts · MIT licensed

GitHubnpm

Search