Components

Slots

A component declares its slots with defineSlots and renders them as children, with fallback content, props for a scoped slot, a presence test and forwarding. A parent fills them with children and a slot object.

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:

Notice.uf.tsx
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:

Board.uf.tsx
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>
  );
}
Notice.tsx
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(" ");
}
Board.tsx
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 children and a named one as a prop of its name. Solid resolves the children once, with children().
  • 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, children for 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 with contentChild and renders it with ngTemplateOutlet under @if, and the parent writes <ng-template ufNoticeAction> and lists the directive in imports (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:

Post.uf.tsx
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:

List.uf.tsx
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>
  );
}
Ranking.uf.tsx
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:

List.tsx
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>
  );
}
Ranking.tsx
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 render and 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 writes item$, 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 static ngTemplateContextGuard on 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.uf.tsx
{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:

Card.uf.tsx
<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:

Box.uf.tsx
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>
  );
}
Frame.uf.tsx
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.

Copyright © 2026