Scoped slots let a component expose data up to the caller. The component defines what data is available; the caller decides how to render it.
This is the same pattern as Svelte'slet:directive or Vue's scoped slots.
Basic usage
A component provides data withlet:name=expr on {% slot %}:
{# components/user-list.html #}
{% def user_list(users) %}
<ul>
{% for user in users %}
<li>{% slot let:item=user %}{{ item }}{% end %}</li>
{% end %}
</ul>
{% end %}
The caller receives that data with let:name on {% call %}:
{% from "components/user-list.html" import user_list %}
{% call(let:item) user_list(users=people) %}
<strong>{{ item.name }}</strong> — {{ item.email }}
{% end %}
The component iterates the data. The caller controls the markup. Neither is coupled to the other.
How it works
| Side | Syntax | Purpose |
|---|---|---|
| Component (def) | {% slot let:name=expr %} |
Push data to the caller |
| Caller (call) | {% call(let:name) fn() %} |
Receive the data |
Thelet:bindings flow up from the slot to the caller's block. Inside the
{% call %}...{% end %}body, the bound names are available as local variables.
Multiple bindings
A slot can expose more than one value:
{% def data_table(rows) %}
<table>
{% for row in rows %}
<tr>{% slot let:item=row, let:index=loop.index0 %}{% end %}</tr>
{% end %}
</table>
{% end %}
{% call(let:item, let:index) data_table(rows=data) %}
<td>{{ index }}</td>
<td>{{ item.name }}</td>
<td>{{ item.value }}</td>
{% end %}
Expressions in bindings
Thelet:value can be any expression -- attribute access, filters, function
calls:
{% slot let:label=item.name | upper, let:active=item.is_active %}
{{ label }}
{% end %}
Default content
When the slot haslet:bindings and a body, that body is the default
content -- rendered when no caller provides a block:
{% def card(user) %}
<div class="card">
{% slot let:item=user %}
{# Default: simple name display #}
<span>{{ item.name }}</span>
{% end %}
</div>
{% end %}
Called without a block, the default renders:
{{ card(user=alice) }}
{# <div class="card"><span>Alice</span></div> #}
Called with a block, the caller takes over:
{% call(let:item) card(user=alice) %}
<img src="{{ item.avatar }}"> <b>{{ item.name }}</b>
{% end %}
Named slots
Scoped slots work with named slots too:
{% def layout(page) %}
<header>{% slot header %}<h1>{{ page.title }}</h1>{% end %}</header>
<main>{% slot body let:data=page.content %}{{ data }}{% end %}</main>
{% end %}
{% call(let:data) layout(page=p) %}
{% slot header %}<h1 class="fancy">{{ p.title }}</h1>{% end %}
<div class="prose">{{ data | markdown }}</div>
{% end %}
With provide / consume
Scoped slots andprovide/consumesolve different problems and coexist
cleanly:
| Pattern | Direction | Use case |
|---|---|---|
Scoped slots (let:) |
Child to parent (up) | Expose iteration data, computed values |
provide/consume |
Parent to child (down) | Theme, config, implicit context |
They can be used together:
{% def themed_list(items) %}
{% provide theme = "dark" %}
<ul>
{% for item in items %}
<li>{% slot let:item=item %}{{ item }}{% end %}</li>
{% end %}
</ul>
{% endprovide %}
{% end %}
{% call(let:item) themed_list(items=data) %}
<span class="{{ consume("theme") }}">{{ item.name }}</span>
{% end %}
Real-world example: sortable table
{# components/sortable-table.html #}
{% def sortable_table(rows, columns) %}
<table>
<thead>
<tr>
{% for col in columns %}
<th>{{ col.label }}</th>
{% end %}
</tr>
</thead>
<tbody>
{% for row in rows %}
<tr>{% slot let:row=row, let:cols=columns %}
{% for col in cols %}
<td>{{ row[col.key] }}</td>
{% end %}
{% end %}</tr>
{% end %}
</tbody>
</table>
{% end %}
Default rendering works out of the box. Callers override when they need custom cell rendering:
{% call(let:row, let:cols) sortable_table(rows=users, columns=cols) %}
<td>{{ row.name }}</td>
<td>{{ row.email }}</td>
<td>
{% if row.active %}
<span class="badge badge-green">Active</span>
{% else %}
<span class="badge badge-gray">Inactive</span>
{% end %}
</td>
{% end %}