A real-world dashboard card component built in both engines. Same HTML output, very different authoring experience.
The component has:
- Typed props (title, subtitle, variant)
- A header actions slot
- A default body slot
- An optional footer
- Co-located CSS
- Theming via context (not prop drilling)
The Jinja2 Version
Component Definition
{# components/card.html (Jinja2) #}
{# No type annotations — params are a guessing game #}
{% macro card(title, subtitle="", variant="default") %}
<article class="card card--{{ variant }}">
<header class="card__header">
<div>
<h3>{{ title }}</h3>
{% if subtitle %}<p class="card__subtitle">{{ subtitle }}</p>{% endif %}
</div>
{# No named slots — caller() is all-or-nothing #}
</header>
<div class="card__body">
{{ caller() }}
</div>
</article>
{% endmacro %}
Problems:
- No type annotations on parameters
caller()gives you one blob of content — no way to target header actions vs body vs footer separately- No
has_slot()— can't conditionally render a footer wrapper
Usage
{# pages/dashboard.html (Jinja2) #}
{% from "components/card.html" import card %}
{# Need to pass theme through every layer #}
{% call card("Settings", subtitle="Manage preferences", variant="elevated") %}
<form>
<label>Theme</label>
{# How do we read the current theme? It must be passed as a variable #}
<select name="theme">
<option {% if theme == "light" %}selected{% endif %}>Light</option>
<option {% if theme == "dark" %}selected{% endif %}>Dark</option>
</select>
</form>
{% endcall %}
Scoping Trap
Jinja2's{% set %}leaks out of blocks. This causes subtle bugs:
{# Jinja2 — count leaks across iterations #}
{% set count = 0 %}
{% for section in sections %}
{% set count = count + 1 %}
{# count is now modified in the OUTER scope #}
{% endfor %}
{# count == len(sections), not 0 — the set leaked #}
To work around this, Jinja2 requires the namespace()pattern:
{# Jinja2 — namespace workaround #}
{% set ns = namespace(count=0) %}
{% for section in sections %}
{% set ns.count = ns.count + 1 %}
{% endfor %}
{{ ns.count }}
Co-located Styles
Jinja2 has no content stacks. You must manage CSS separately:
{# base.html (Jinja2) #}
<head>
<link rel="stylesheet" href="/css/main.css">
{# No way for components to inject styles here #}
{% block extra_styles %}{% endblock %}
</head>
{# Every page that uses card must manually add its CSS #}
{% block extra_styles %}
<link rel="stylesheet" href="/css/card.css">
{% endblock %}
Styles are disconnected from the component that needs them.
Context / Theming
Jinja2 has noprovide/consume. To theme nested components, you must pass the value through every layer:
{# Jinja2 — prop drilling #}
{% macro page_shell(theme) %}
{{ sidebar(theme=theme) }}
<main>{{ caller() }}</main>
{% endmacro %}
{% macro sidebar(theme) %}
<nav class="sidebar sidebar--{{ theme }}">
{{ sidebar_item("Home", "/", theme=theme) }}
{{ sidebar_item("Settings", "/settings", theme=theme) }}
</nav>
{% endmacro %}
{% macro sidebar_item(label, url, theme) %}
<a class="sidebar__item sidebar__item--{{ theme }}" href="{{ url }}">
{{ label }}
</a>
{% endmacro %}
Three layers deep, and every one must forward theme.
Error Handling
Jinja2 has no error boundaries. One broken component crashes the entire page:
{# Jinja2 — if user.profile is None, the whole page 500s #}
{% call card(user.profile.display_name) %}
{{ render_activity(user.id) }}
{% endcall %}
The Kida Version
Component Definition
{# components/card.html (Kida) #}
{% def card(title: str, subtitle: str | None = none, variant: str = "default") %}
{% push "styles" %}
<style>
.card { border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
.card--elevated { box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
.card__header { padding: 16px; display: flex; justify-content: space-between; }
.card__body { padding: 0 16px 16px; }
.card__footer { padding: 12px 16px; border-top: 1px solid var(--border); }
</style>
{% end %}
<article class="card card--{{ variant }}">
<header class="card__header">
<div>
<h3>{{ title }}</h3>
{% if subtitle %}<p class="card__subtitle">{{ subtitle }}</p>{% end %}
</div>
{% slot header_actions %}
</header>
<div class="card__body">
{% slot %}
</div>
{% if has_slot("footer") %}
<footer class="card__footer">
{% slot footer %}
</footer>
{% end %}
</article>
{% end %}
What's different:
| Feature | Jinja2 | Kida |
|---|---|---|
| Type annotations | No | title: str, subtitle: str | None |
| Named slots | No (caller()only) |
{% slot header_actions %}, {% slot footer %} |
| Conditional slot wrapper | No | has_slot("footer") |
| Co-located styles | No | {% push "styles" %} |
| Validated at check time | No | kida check --validate-calls |
Usage
{# pages/dashboard.html (Kida) #}
{% from "components/card.html" import card %}
{% provide theme = "dark" %}
{% call card("Settings", subtitle="Manage preferences", variant="elevated") %}
{% slot header_actions %}
<button class="btn btn--icon" aria-label="Save">Save</button>
{% end %}
<form>
<label>Theme</label>
<select name="theme">
<option {% if consume("theme") == "light" %}selected{% end %}>Light</option>
<option {% if consume("theme") == "dark" %}selected{% end %}>Dark</option>
</select>
</form>
{% slot footer %}
<button type="submit">Apply</button>
{% end %}
{% end %}
{% end %}
- Header actions, body, and footer are separate slots — targeted, not jumbled
- Theme comes from
consume("theme"), not a prop passed through every layer - Styles are co-located in the component and rendered in
<head>via{% stack "styles" %}
Scoping
Kida's{% set %} is block-scoped. No leaking, no namespace()workaround:
{# Kida — set is block-scoped, use let for template-wide #}
{% let count = 0 %}
{% for section in sections %}
{% set count = count + 1 %}
{# This count is local to the for loop — outer count is still 0 #}
{% end %}
{# count == 0 — no leaking #}
{# To modify outer scope, use export #}
{% let total = 0 %}
{% for section in sections %}
{% export total = total + 1 %}
{% end %}
{# total == len(sections) — explicit, intentional #}
Context / Theming
No prop drilling. Set the theme once, read it anywhere:
{% def page_shell() %}
{{ sidebar_nav() }}
<main>{% slot %}</main>
{% end %}
{% def sidebar_nav() %}
<nav class="sidebar sidebar--{{ consume('theme') }}">
{{ sidebar_item("Home", "/") }}
{{ sidebar_item("Settings", "/settings") }}
</nav>
{% end %}
{% def sidebar_item(label: str, url: str) %}
<a class="sidebar__item sidebar__item--{{ consume('theme') }}" href="{{ url }}">
{{ label }}
</a>
{% end %}
Zero mentions of themein any function signature. Each component reads what it needs from context.
Error Boundaries
Wrap risky components so failures degrade gracefully:
{% try %}
{% call card(user.profile.display_name) %}
{{ render_activity(user.id) }}
{% end %}
{% fallback error %}
<div class="card card--error" role="alert">
<p>Could not load this card.</p>
</div>
{% end %}
The rest of the page renders normally even if this card fails.
Static Validation
$ kida check templates/ --validate-calls
templates/pages/dashboard.html:8: Call to 'card' — unknown params: titl
templates/pages/settings.html:14: type: badge() param 'count' expects int, got str ("five")
Jinja2 has no equivalent — parameter errors surface at runtime.
Summary
| Capability | Jinja2 | Kida |
|---|---|---|
| Type annotations on params | No | param: str | None |
| Compile-time call validation | No | kida check --validate-calls |
| Named slots | No | {% slot name %} |
| Conditional slot rendering | No | has_slot("name") |
| Scoped slot data (data up) | No | let:item=expr |
| Block-scoped variables | No (setleaks) |
{% set %}is block-scoped |
| Explicit outer-scope mutation | namespace()hack |
{% export %} |
| Context propagation | Prop drilling only | {% provide %} / consume() |
| Error boundaries | No | {% try %}...{% fallback %} |
| Co-located component styles | No | {% push %} / {% stack %} |
| Component discovery CLI | No | kida components |
| Component introspection API | No | template.def_metadata() |
| Pattern matching | if/elifchains |
{% match %} / {% case %} |
| Content stacks | No | {% push %} / {% stack %} |
Both engines produce the same HTML. The difference is in how you get there — and what happens when things go wrong.
See Also
- Components Guide — Full patterns reference
- Coming from Jinja2 — Quick cheat sheet
- Migration Guide — Step-by-step migration
- Feature Comparison — Full feature matrix