Slots
Slots let a component leave holes for its parent to fill: a panel's body, a card's header, the way a list renders each row. The component decides where each slot goes and what shows when the parent leaves it empty, and the parent decides what goes in. Each framework has its own way to pass content into a component, and Unframework writes each one: children and render props on React, Solid and Qwik, <slot> on Vue and Astro, snippets on Svelte and ng-content with templates on Angular.
Declaring slots
A component declares its slots with defineSlots, bound to a const, and renders each one as a child:
import { defineSlots } from "unframework";
import type { Element } from "unframework";
export default function Notice({ label }: { label: string }) {
const slots = defineSlots<{ default?(): Element; action?(): Element }>();
return (
<div role="note" aria-label={label} class="notice">
<p>{slots.default?.() ?? <em>Nothing to report.</em>}</p>
{slots.action?.() ?? <span class="muted">No action</span>}
</div>
);
}
Each member of the type is optional, since a parent may leave any slot empty, and a function type that returns Element. The default slot is named default, and takes what the parent writes as children. Every other slot is named. React, Solid, Qwik and Astro pass slots as props, so a slot is named apart from every prop and event (UF2029).
{slots.default?.()} renders a slot, and what follows ?? is its fallback, which renders when the parent passes nothing for that slot. Here a parent fills Notice's default slot and its action slot, and leaves both empty in a second Notice:
import Notice from "./Notice.uf.tsx";
export default function Board({ build }: { build: string }) {
return (
<div>
<Notice label="Build">
{{
default: () => <>Build {build} failed.</>,
action: () => <a href="#logs">See the logs</a>,
}}
</Notice>
<Notice label="Deploy" />
</div>
);
}
import type { CSSProperties, ReactNode } from "react";
export interface NoticeSlots {
children?: ReactNode;
action?: ReactNode;
}
export interface NoticeAttributes {
className?: string;
style?: CSSProperties;
}
export default function Notice({
label,
children,
action,
className,
style,
}: { label: string } & NoticeSlots & NoticeAttributes) {
return (
<div role="note" aria-label={label} className={cx("notice", className)} style={style}>
<p>{children ?? <em>Nothing to report.</em>}</p>
{action ?? <span className="muted">No action</span>}
</div>
);
}
/** Joins class names, and the keys of an object's truthy entries, into one `className`. */
function cx(...parts: unknown[]): string {
const names: string[] = [];
for (const part of parts) {
if (typeof part === "string") {
if (part) names.push(part);
} else if (part && typeof part === "object") {
for (const [key, on] of Object.entries(part)) {
if (on) names.push(key);
}
}
}
return names.join(" ");
}
import Notice from "./Notice";
import type { CSSProperties } from "react";
export interface BoardAttributes {
className?: string;
style?: CSSProperties;
}
export default function Board({ build, className, style }: { build: string } & BoardAttributes) {
return (
<div className={className} style={style}>
<Notice label="Build" action={<a href="#logs">See the logs</a>}>
Build {build} failed.
</Notice>
<Notice label="Deploy" />
</div>
);
}
- React and Solid take the default slot as
childrenand a named one as a prop of its name. Solid resolves the children once, withchildren(). - Vue writes
<slot>and<slot name="action">, with the fallback inside, and the parent fills them with<template #action>. - Svelte takes each slot as a snippet prop,
childrenfor the default one, and renders it with{@render}under{#if}for the fallback. The parent writes{#snippet action()}. - Angular projects the default slot with
<ng-content>, its fallback inside. Each named slot is a directive that the child's file exports,ng-template[ufNoticeAction]: the child reads the parent's template withcontentChildand renders it withngTemplateOutletunder@if, and the parent writes<ng-template ufNoticeAction>and lists the directive inimports(named-slot, emulated). - Qwik projects the default slot with
<Slot>, and passes a named one as a QRL render prop,action$, because its<Slot>takes no props and has no way to tell whether it was filled (named-slot, emulated). - Astro writes
<slot>and<slot name="action">, and the parent fills a named one with<Fragment slot="action">.
Fallback renders when the parent passes no slot. A fill that renders nothing is outside the contract: Vue shows the fallback for it, and the other targets render nothing. Leave a fill out where it would be empty.
Filling slots
A parent fills the default slot with its children. To fill named slots, it passes a slot object as the component's only child, with each slot as an arrow function that returns JSX, default included:
import Card from "./Card.uf.tsx";
export default function Post({ author }: { author: string }) {
return (
<Card>
{{
header: () => <h2>Release notes</h2>,
default: () => <p>Slots arrive in M3.</p>,
footer: () => <small>By {author}</small>,
}}
</Card>
);
}
A member of the slot object that isn't an arrow function returning JSX, or a parent's own slot passed on, is UF3040. A key the child doesn't declare, or children for a child with no default slot, is UF3038, with a fix to the closest slot's name.
A fill can't have a root element that belongs only inside a given parent, such as a <summary>, a <figcaption>, a <dt> or a <dd> (UF3003). The compiler doesn't record where a child places each slot, so it can't prove that the fill lands in a <details>, a <figure> or a <dl>. Write that parent element in the fill too, or let the child write the element and pass it only the content.
Scoped slots
A scoped slot hands its fill some values, so the parent decides how to render what the child holds. Declare the slot with a parameter, and pass its props where you render it:
import { defineSlots } from "unframework";
import type { Element } from "unframework";
export default function List({ items, label }: { items: string[]; label: string }) {
const slots = defineSlots<{ item?(props: { item: string; index: number }): Element }>();
return (
<ul aria-label={label}>
{items.map((item, index) => (
<li key={item}>{slots.item?.({ item, index })}</li>
))}
</ul>
);
}
import List from "./List.uf.tsx";
export default function Ranking({ names }: { names: string[] }) {
return (
<div>
<List items={names} label="Podium">
{{
item: ({ item, index }) => (
<span>
{index + 1}. <b>{item}</b>
</span>
),
}}
</List>
<List items={names} label="Initials">
{{ item: (entry) => <i>{entry.item.slice(0, 1)}</i> }}
</List>
</div>
);
}
A fill takes the slot's props as its one parameter, destructured, ({ item, index }), or by a name, (entry). The fill renders once for every item the child renders it for:
import type { CSSProperties, ReactNode } from "react";
export interface ListSlots {
renderItem?: (props: { item: string; index: number }) => ReactNode;
}
export interface ListAttributes {
className?: string;
style?: CSSProperties;
}
export default function List({
items,
label,
renderItem,
className,
style,
}: { items: string[]; label: string } & ListSlots & ListAttributes) {
return (
<ul aria-label={label} className={className} style={style}>
{items.map((item, index) => (
<li key={item}>{renderItem?.({ item, index })}</li>
))}
</ul>
);
}
import List from "./List";
import type { CSSProperties } from "react";
export interface RankingAttributes {
className?: string;
style?: CSSProperties;
}
export default function Ranking({
names,
className,
style,
}: { names: string[] } & RankingAttributes) {
return (
<div className={className} style={style}>
<List
items={names}
label="Podium"
renderItem={({ item, index }) => (
<span>
{index + 1}. <b>{item}</b>
</span>
)}
/>
<List
items={names}
label="Initials"
renderItem={(entry) => <i>{entry.item.slice(0, 1)}</i>}
/>
</div>
);
}
- React passes a scoped slot as a render prop named
renderand the slot's name,renderItem, because oxlint's React rules reject a render prop named like the slot. Solid and Astro keep the slot's name,item, and Qwik writesitem$, a QRL (scoped-slot, emulated on Angular, Astro and Qwik). - Vue binds the props on the
<slot>, and Svelte types its snippet,Snippet<[{ item: string; index: number }]>. - Angular types the template's
let-variables with a staticngTemplateContextGuardon the slot's directive, so a consumer that reads a prop the slot doesn't have fails to compile.
The default slot may be scoped too, filled through the slot object: {{ default: ({ item }) => <b>{item}!</b> }}. React names its render prop renderDefault. A slot's props may have any names, name and data-id included, though a template language could read them as the slot's own name or as an attribute: Vue binds them as one object, v-bind="{ name: person.name, 'data-id': person.id, index }", and the other targets pass an object too.
A slot rendered inside a .map renders its fill once for each item, scoped or not, with one exception: Qwik projects a default slot once, so a default slot rendered in a list, or twice, shows its content once or not at all there, and no diagnostic reports it yet. A named slot, a QRL render prop on Qwik, renders for every item:
{steps.map((step) => (
<li key={step}>
{slots.marker?.()}
{step}
</li>
))}
Testing for a slot
slots.title as a condition tests whether the parent filled the slot, so a component can leave out a wrapper that would be empty:
<section class="card" aria-label={label}>
{slots.title ? <header class="title">{slots.title?.()}</header> : null}
{slots.default ? <div class="body">{slots.default?.()}</div> : <p>Empty card</p>}
</section>
React tests title != null, Vue slots.title, Svelte {#if title}, Angular its content query, this.titleTemplate(), Solid <Show when={title()}>, Qwik title$ and Astro Astro.slots.has("title").
Testing the default slot is unsupported on Angular and Qwik (default-slot-presence, an error): Angular has no way to tell whether <ng-content> received content, and Qwik's <Slot> has none either. A named slot works on every target, so test a named slot where you need to know.
A slot is rendered, tested or forwarded, and nothing else: storing it, calling it in setup code or passing slots itself is UF3041.
Forwarding slots
A component may pass its own slots on to a child, in the child's slot object:
import { defineSlots } from "unframework";
import type { Element } from "unframework";
export default function Box() {
const slots = defineSlots<{ title?(): Element; default?(): Element }>();
return (
<div class="box">
<h3>{slots.title?.() ?? "Untitled"}</h3>
{slots.default?.() ?? <p>No content</p>}
</div>
);
}
import { defineSlots } from "unframework";
import type { Element } from "unframework";
import Box from "./Box.uf.tsx";
export default function Frame({ label }: { label: string }) {
const slots = defineSlots<{ title?(): Element; default?(): Element }>();
return (
<section class="frame" aria-label={label}>
<Box>{{ title: slots.title, default: slots.default }}</Box>
</section>
);
}
Forwarding keeps whether the slot was filled: where the outer parent fills nothing, the inner child shows its own fallback, "Untitled" here. React, Solid and Svelte pass the slot props on, and Qwik its title$. Vue fills the child's slot with its own <slot> under v-if="slots.title". Angular declares the child's template again under @if, around its own (slot-forwarding, emulated). Astro writes <slot name="title" slot="title" />, under a test of which slots it received.
Forwarding the default slot keeps its presence too, so it is unsupported on Angular and Qwik, as testing it is: Frame above is an error on both. Forward named slots where those targets matter.
Not yet
- A fill whose root is a
<summary>, a<figcaption>, a<dt>, a<dd>or another element tied to its parent stays UF3003 until a component's API records where it places each slot, with the typed APIs of M5. - Testing or forwarding the default slot is unsupported on Angular and Qwik.
- Qwik projects a default slot once: rendered in a list or twice, its content shows once or not at all, with no diagnostic yet. Use a named slot there.
The decisions behind this page are ADR-0054, ADR-0055 and ADR-0056.
Child components
A component imports another from its .uf.tsx file and renders it as a tag, with props and listeners. Every framework imports the child's own output, so each output tree checks and runs as it is.
Models
defineModel declares a value a parent can bind two ways with v-model, and v-model on a form control binds its state to a ref. Each framework gets its own two-way binding, with Vue's modifiers emulated where it has none.