Components

July 17, 2026 · View on GitHub

Kida's component model gives you the composition patterns of React or Svelte — typed props, named slots, context propagation, error boundaries — without a build step or JavaScript runtime. Everything compiles to Python AST and renders server-side.

This guide covers the blessed patterns for writing, organizing, and consuming components.

App-Owned Authoring Contract

This section is the canonical, framework-neutral contract for deciding what becomes a Kida component in an application. A component is an interface, not a reward for making a template shorter. It should own at least one meaningful boundary: values, replaceable content, accessibility or behavior policy, provided context, reuse, or a stable product concept.

Extract a component when the interface matters

Extraction is usually worthwhile when one or more of these statements is true:

  • the same concept is rendered from more than one caller;
  • props and defaults express rules that callers should not repeat;
  • named slots define intentional, replaceable regions;
  • a scoped slot lets the component own iteration while the caller owns item markup;
  • the component consistently enforces an accessible relationship or server-side state;
  • provide/consume connects a deliberate family of components without unrelated intermediates forwarding configuration;
  • the name represents a stable product concept even when it currently has one caller.

For example, a destructive-action form earns a boundary because it owns product language, form behavior, and the relationship between its explanation and control:

{# contract-example: destructive-action #}
{% def delete_account_form(action: str, account_name: str, disabled: bool = false) %}
<form class="delete-account" method="post" action="{{ action }}">
  <p id="delete-account-help">
    Delete {{ account_name }} permanently.
    {% slot consequences %}
  </p>
  <button type="submit" aria-describedby="delete-account-help"
    {% if disabled %}disabled{% end %}>
    Delete account
  </button>
</form>
{% end %}

The props carry values and policy; the named slot is the caller's explicit seam for product-specific consequences.

Keep one-off composition inline

Line count alone is not an extraction signal. A large layout or route page can remain healthy when it uniquely arranges sections and does not hide a reusable interface. Keep markup inline when a proposed component merely renames one call, forwards the caller's entire context, or mirrors another component's props and slots without adding policy.

This wrapper adds indirection but no contract:

{% def app_button(
  label: str,
  variant: str = "primary",
  disabled: bool = false,
) %}
  {{ button(label=label, variant=variant, disabled=disabled) }}
{% end %}

Prefer the direct call until app_button owns a real application decision:

{{ button(label="Save", variant="primary", disabled=is_saving) }}

A single caller is not proof that a component is wasteful. Accessibility primitives, adapter boundaries, test seams, and stable product concepts may still justify one. Name the owned decision in review; if there is none, keep the markup with its caller.

Choose the narrowest composition seam

NeedKida contractGuidance
Caller supplies a valueTyped propPrefer explicit props for ordinary data dependencies.
Component owns a common policyDefaulted propKeep the default visible in the signature.
Caller replaces a regionNamed or default slotName slots after their role, not their markup.
Component owns iteration; caller owns each itemScoped slotExpose only the item facts the caller needs.
A component family shares ambient configurationprovide / consumeUse for a deliberate subtree, not to avoid every prop.
Caller selects a component from another fileExplicit importImport the exact definition from a stable template path.

A scoped slot keeps the loop and empty-state policy inside the component while letting the caller render product-specific rows:

{# components/result-list.html #}
{# contract-example: result-list-definition #}
{% def result_list(results: list) %}
<ul class="result-list">
  {% for result in results %}
    <li>{% slot row let:item=result %}{{ item.title }}{% end %}</li>
  {% else %}
    <li>No results</li>
  {% end %}
</ul>
{% end %}
{# contract-example: result-list-call #}
{% from "components/result-list.html" import result_list %}

{% call result_list(results=search_results) %}
  {% slot row let:item %}
    <a href="{{ item.url }}">{{ item.title }}</a>
  {% end %}
{% end %}

Component source and imports

Component source belongs to the application or to an explicitly selected package; it is never discovered through an ambient working directory or an implied framework folder name. Applications and adapters configure loader roots and their order. Templates use explicit, stable import paths such as components/result-list.html, and each definition has one clear source owner.

Framework adapters may establish conventions such as layouts/, pages/, or private partials, but those names do not acquire meaning in Kida core. When an adapter combines framework and application roots, it owns that configuration and duplicate-name policy. The application still owns its component interfaces and source files.

Use the supported inspection surfaces rather than a parallel manifest:

kida check templates/ --validate-calls
kida components templates/ --json

kida check validates known calls and props. Component metadata preserves definition names, parameters, slots, template paths, and source lines for human, agent, and adapter tooling.

CSS ownership

Kida components emit ordinary class names. The application owns the stylesheet, semantic custom properties, layers, override policy, and visual identity:

@layer components {
  .delete-account {
    color: var(--app-danger-text);
    border-color: var(--app-danger-border);
  }
}

CSS may be global, layered, adjacent to a component template, or routed through the shipped push/stack mechanism. Kida does not make selectors unique, prevent style leakage, define a token schema, persist a theme, or require a frontend build step. Those remain application choices.

Responsibility boundaries

OwnerOwnsDoes not become
Kida coreTyped props, slots, imports, scoped state, metadata, and diagnosticsA router, theme system, CSS framework, catalog, or asset pipeline
Framework adapterExplicit loader roots, layout/page roles, response boundaries, and framework conventionsA source of framework semantics inside Kida syntax
ApplicationComponent boundaries, product patterns, route composition, CSS, tokens, themes, and assetsAn implicit global component package

Humans and coding agents should apply the same loop: identify the owned decision, choose the narrowest props/slots/context interface, import it explicitly, then run kida check and inspect metadata. Extraction remains an architectural choice; diagnostics provide evidence, not a universal size limit.

For a complete runnable application of this contract, see [[docs/tutorials/local-components|Build App-Owned Local Components]].

Anatomy of a Component

A Kida component is a {% def %} with typed parameters and slots:

{% def card(title: str, variant: str = "default") %}
<article class="card card--{{ variant }}">
  <header class="card__header">
    <h3>{{ title }}</h3>
    {% slot header_actions %}
  </header>
  <div class="card__body">
    {% slot %}
  </div>
  {% if has_slot("footer") %}
  <footer class="card__footer">
    {% slot footer %}
  </footer>
  {% end %}
</article>
{% end %}

This single definition gives you:

  • Typed propstitle: str is validated by kida check --validate-calls
  • Default valuesvariant falls back to "default"
  • Named slotsheader_actions and footer for targeted content injection
  • Default slot{% slot %} captures the primary body content
  • Conditional renderinghas_slot("footer") only renders the footer wrapper when content is provided

Using Components

Inline Call (No Slots)

For simple components that don't need body content:

{{ card("Settings") }}

Call Block (With Slots)

Use {% call %} to pass slot content:

{% call card("Settings", variant="elevated") %}
  {% slot header_actions %}
    <button class="btn btn--icon">Save</button>
  {% end %}

  <form>
    <label>Theme</label>
    <select name="theme">
      <option>Light</option>
      <option>Dark</option>
    </select>
  </form>

  {% slot footer %}
    <button type="submit">Apply</button>
  {% end %}
{% end %}
  • Bare content inside {% call %} fills the default slot
  • {% slot name %}...{% end %} inside {% call %} fills a named slot
  • Unfilled slots produce no output

Component Organization

One Component Per File

For shared components, define one primary component per file:

templates/
  components/
    card.html
    button.html
    modal.html
    nav/
      breadcrumbs.html
      sidebar.html
{# components/button.html #}
{% def button(label: str, variant: str = "primary", disabled: bool = false) %}
<button class="btn btn--{{ variant }}" {% if disabled %}disabled{% end %}>
  {{ label }}
</button>
{% end %}

Import and use:

{% from "components/button.html" import button %}
{% from "components/card.html" import card %}

{% call card("User Profile") %}
  {{ button("Edit", variant="secondary") }}
{% end %}

Grouped Components

When small components are logically related, group them in one file:

{# components/forms.html #}
{% def text_input(name: str, label: str, value: str = "", required: bool = false) %}
<div class="form-field">
  <label for="{{ name }}">{{ label }}</label>
  <input type="text" id="{{ name }}" name="{{ name }}" value="{{ value }}"
    {% if required %}required{% end %}>
</div>
{% end %}

{% def select_input(name: str, label: str, options: list) %}
<div class="form-field">
  <label for="{{ name }}">{{ label }}</label>
  <select id="{{ name }}" name="{{ name }}">
    {% for opt in options %}
    <option value="{{ opt.value }}">{{ opt.label }}</option>
    {% end %}
  </select>
</div>
{% end %}

{% def checkbox(name: str, label: str, checked: bool = false) %}
<label class="checkbox">
  <input type="checkbox" name="{{ name }}" {% if checked %}checked{% end %}>
  {{ label }}
</label>
{% end %}

Import selectively:

{% from "components/forms.html" import text_input, select_input %}

Discovering Components

Use the CLI to list all components across your project:

kida components templates/

# Filter by name
kida components templates/ --filter card

# Machine-readable output
kida components templates/ --json

Or use the Python API:

template = env.get_template("components/card.html")
for name, meta in template.def_metadata().items():
    params = ", ".join(
        f"{p.name}: {p.annotation}" if p.annotation else p.name
        for p in meta.params
    )
    print(f"  def {name}({params})")
    if meta.slots:
        print(f"    slots: {', '.join(meta.slots)}")
    if meta.depends_on:
        print(f"    reads: {', '.join(sorted(meta.depends_on))}")

Typed Props

Type annotations on parameters serve as documentation and enable static validation:

{% def badge(label: str, count: int, variant: str | None = none) %}
<span class="badge {% if variant %}badge--{{ variant }}{% end %}">
  {{ label }}: {{ count }}
</span>
{% end %}

Supported Types

AnnotationValidates Literals
strString literals
intInteger literals
floatFloat and integer literals
booltrue / false
Nonenone literal
str | NoneUnion types (PEP 604 style)
list, dict, custom typesAccepted as documentation; not validated statically

Validation

Enable compile-time checking with validate_calls=True on the Environment or via the CLI:

# Catches: unknown params, missing required params, type mismatches on literals
kida check templates/ --validate-calls
pages/dashboard.html:8: K-CMP-001: Call to 'card' - unknown params: titl
components/card.html:14: K-CMP-002: type: badge() param 'count' expects int, got str ("five")

Variable arguments are skipped — only literal values can be type-checked statically.

Validation also follows literal component imports:

{% from "components/card.html" import card %}

{{ card(titl="Settings") }}

Kida reads card's metadata from components/card.html, validates the call in the importing template, and reports the error at the call site. Dynamic imports are skipped because the target cannot be known at check time.

Slot Patterns

Default Slot Only

For components with a single content area:

{% def panel(title: str) %}
<section class="panel">
  <h2>{{ title }}</h2>
  {% slot %}
</section>
{% end %}

Named Slots for Multi-Region Layout

When a component has distinct content regions:

{% def page_layout(title: str) %}
<div class="layout">
  <header>
    <h1>{{ title }}</h1>
    {% slot toolbar %}
  </header>
  <aside>{% slot sidebar %}</aside>
  <main>{% slot %}</main>
  <footer>{% slot footer %}</footer>
</div>
{% end %}

Conditional Slots

Use has_slot() to adapt markup based on whether content was provided:

{% def alert(message: str, variant: str = "info") %}
<div class="alert alert--{{ variant }}" role="alert">
  <p>{{ message }}</p>
  {% if has_slot() %}
  <div class="alert__actions">
    {% slot %}
  </div>
  {% end %}
</div>
{% end %}

Scoped Slots

Components can pass data back to the caller with let: bindings:

{% def data_table(items: list) %}
<table>
  {% for item in items %}
  <tr>
    {% slot row let:item=item let:index=loop.index %}
      <td>{{ item }}</td>
    {% end %}
  </tr>
  {% end %}
</table>
{% end %}

{% call data_table(users) %}
  {% slot row %}
    <td>{{ item.name }}</td>
    <td>{{ item.email }}</td>
  {% end %}
{% end %}

See [[docs/advanced/scoped-slots|Scoped Slots]] for the full guide.

Slot Forwarding

When wrapping a component in another component, use {% yield %} to forward slots:

{% def selection_bar() %}<nav>{{ caller() }}</nav>{% end %}

{% def resource_index() %}
  {% call selection_bar() %}
    {% yield selection %}
  {% end %}
{% end %}

{% call resource_index() %}
  {% slot selection %}Badges{% end %}
  Cards
{% end %}

{% yield name %} renders the enclosing def's caller slot, even inside a nested {% call %} block.

Context Propagation

The Prop Drilling Problem

Without context propagation, deeply nested components need every intermediate layer to pass props through:

{# Every layer must forward "theme" — fragile and verbose #}
{% def page_shell(theme: str) %}
  {{ sidebar(theme=theme) }}
  {{ content_area(theme=theme) }}
{% end %}

Provide / Consume

Use {% provide %} to push state and consume() to read it anywhere in the subtree:

{# Layout sets theme once #}
{% provide theme = "dark" %}
  {% call page_shell() %}
    {% slot sidebar %}
      {{ sidebar_nav() }}
    {% end %}
    Main content here.
  {% end %}
{% end %}

{# sidebar_nav.html — reads theme without it being passed as a prop #}
{% def sidebar_nav() %}
<nav class="sidebar sidebar--{{ consume('theme') }}">
  ...
</nav>
{% end %}
  • consume("key") returns the nearest ancestor's provided value
  • Nested {% provide %} blocks shadow outer values (like CSS custom properties)
  • Works across slot boundaries, includes, and imported components

See [[docs/usage/provide-consume|Provide / Consume]] for the full guide.

Error Boundaries

Wrap component calls in {% try %}...{% fallback %} to prevent one broken component from crashing the entire page:

{% try %}
  {% call user_card(user) %}
    {{ render_activity_feed(user.id) }}
  {% end %}
{% fallback error %}
  <div class="card card--error">
    <p>Could not load user card.</p>
  </div>
{% end %}

When an error occurs inside the {% try %} block, Kida discards its partial output and renders the {% fallback %} block instead. The error variable contains the exception.

This is especially useful for:

  • Components that depend on external data (API calls, database queries)
  • Third-party components you don't control
  • Graceful degradation in production

See [[docs/usage/error-boundaries|Error Boundaries]] for the full guide.

Component Styles with Push/Stack

Use content stacks to co-locate CSS with the component that needs it:

{# components/tooltip.html #}
{% def tooltip(text: str) %}
{% push "styles" %}
<style>
  .tooltip { position: relative; display: inline-block; }
  .tooltip__text {
    visibility: hidden; position: absolute; z-index: 1;
    background: #333; color: #fff; padding: 4px 8px;
    border-radius: 4px; font-size: 0.875rem;
  }
  .tooltip:hover .tooltip__text { visibility: visible; }
</style>
{% end %}
<span class="tooltip">
  {% slot %}
  <span class="tooltip__text">{{ text }}</span>
</span>
{% end %}
{# base.html #}
<head>
  <link rel="stylesheet" href="/css/main.css">
  {% stack "styles" %}
</head>

The {% push "styles" %} block inside the component sends its CSS to the {% stack "styles" %} in the base layout. Styles are only included when the component is actually used. See [[docs/advanced/content-stacks|Content Stacks]] for details.

Introspection API

Kida provides programmatic access to component metadata for framework authors and tooling:

template = env.get_template("components/card.html")

# List all defs
print(template.list_defs())  # ["card"]

# Full metadata
meta = template.def_metadata()
card = meta["card"]
print(card.params)          # (DefParamInfo(name='title', annotation='str', ...), ...)
print(card.slots)           # ('header_actions', 'footer')
print(card.has_default_slot)  # True
print(card.depends_on)      # frozenset()

Block-Level Metadata

For templates using {% block %} inheritance:

meta = template.template_metadata()
for name, block in meta.blocks.items():
    if block.is_cacheable():
        print(f"{name}: cache_scope={block.cache_scope}")

See [[docs/advanced/analysis|Static Analysis]] for the full analysis API.

Quick Reference

PatternSyntaxUse When
Define component{% def name(props) %}...{% end %}Always
Inline call{{ name(args) }}No body content needed
Call with slots{% call name(args) %}...{% end %}Passing body/slot content
Default slot (def){% slot %}Single content area
Named slot (def){% slot name %}Multiple content regions
Fill slot (call){% slot name %}...{% end %}Providing named content
Conditional slothas_slot() / has_slot("name")Adapt markup to slot presence
Scoped slot{% slot let:x=expr %}Pass data back to caller
Forward slot{% yield name %}Wrap component in another
Context push{% provide key = val %}Avoid prop drilling
Context readconsume("key")Read ancestor-provided value
Error boundary{% try %}...{% fallback %}...{% end %}Isolate failures
Co-located styles{% push "styles" %}...{% end %}Component-scoped CSS
Type annotationparam: strSelf-documenting + validation

See Also

  • [[docs/syntax/functions|Functions]] — Full {% def %} syntax reference
  • [[docs/advanced/scoped-slots|Scoped Slots]] — Data-up pattern with let: bindings
  • [[docs/usage/provide-consume|Provide / Consume]] — Context propagation
  • [[docs/usage/error-boundaries|Error Boundaries]] — {% try %}/{% fallback %}
  • [[docs/advanced/content-stacks|Content Stacks]] — {% push %}/{% stack %}
  • [[docs/tutorials/component-comparison|Kida Components vs Jinja2 Macros]] — Side-by-side comparison