Skip to content

Slots

Slots let consumers inject content into a component’s layout. ElementsKit supports two approaches: native <slot> (browser-managed, Shadow DOM only) and Slot (ElementsKit-managed, works with or without Shadow DOM).

Native slots β€” Shadow DOM

When an element uses a shadow root, the browser projects slotted children into named <slot> placeholders automatically. The children stay in the light DOM β€” they are only visually projected.

class CardElement extends HTMLElement {
connectedCallback() {
const shadow = this.attachShadow({ mode: "open" });
// Three slots: named "header", unnamed default, named "footer"
shadow.innerHTML = `
<article>
<header><slot name="header">Untitled</slot></header>
<main><slot></slot></main>
<footer><slot name="footer"></slot></footer>
</article>
`;
}
}
customElements.define("x-card", CardElement);

Consumer HTML:

<x-card>
<h2 slot="header">My Card</h2>
<p>This goes in the default slot.</p>
<button slot="footer">Close</button>
</x-card>

Consumer JSX (ElementsKit):

<x-card>
<h2 slot="header">My Card</h2>
<p>This goes in the default slot.</p>
<button slot="footer">Close</button>
</x-card>

The standard slot HTML attribute routes each child into the matching named slot. The browser handles projection with no extra JavaScript.

Shadow DOM slot with JSX template

Use JSX instead of innerHTML to build the shadow tree β€” the <slot> elements work the same:

import { attributes, ATTRIBUTES as attr } from "elements-kit/attributes";
import { render } from "elements-kit/render";
@attributes
class CardElement extends HTMLElement {
static [attr] = {
title(this: CardElement, value: string | null) {
this.title = value ?? "";
},
};
#unmount?: () => void;
#template = () => (
<article>
{/* Named slot β€” consumer fills with slot="header" */}
<header>
<slot name="header" />
</header>
<main>
{/* Default slot β€” consumer children with no slot attribute */}
<slot />
</main>
<footer>
<slot name="footer" />
</footer>
</article>
);
connectedCallback() {
const shadow = this.attachShadow({ mode: "open" });
shadow.adoptedStyleSheets = [cardSheet];
this.#unmount = render(shadow, this.#template);
}
disconnectedCallback() {
this.#unmount?.();
this.#unmount = undefined;
}
}

ElementsKit Slot β€” Light DOM

Without Shadow DOM, the browser does not project children. ElementsKit’s Slot primitive fills this gap: a pair of comment markers that reserve a region in the DOM. Content between them can be replaced reactively, with no wrapper element.

import { Slot } from "elements-kit/slot";
const slot = new Slot();
// Mounts the comment markers + optional default content
const section = <section>{slot.get("Loading…")}</section>;
// Later β€” replace content in place (native `append()` content: Node/string/array)
slot.set(<p>Content loaded!</p>);
slot.isMounted(); // true
slot.parent(); // the <section> element

When do I need a Slot?

The rule is about who fills the slot, not the component kind. Anything consumed through elements-kit JSX needs nothing special.

Filled bySlot needed?How
elements-kit JSX (function, class, or custom element)NoSlots are plain properties β€” pass them as props, place {this.foo} / {props.foo} in the template. elements-kit JSX assigns each prop; your render places it. Use @reactive to update on reassignment.
Imperative DOM code (a vanilla script holding a Node and doing el.foo = node)Yes (@slot())@slot() makes the property a live comment-marker region so imperative code can fill and replace content against the mounted DOM β€” no reactive context required.

Other JSX frameworks (React, Vue, Svelte) each have their own children model and don’t hand DOM nodes to props, so they seldom fill slots directly. When they must, it’s through their imperative escape hatch (a ref to the element, then el.foo = node) β€” the same path as vanilla, which is what @slot() serves.

Function component β€” no Slot at all

function Card(props) {
return (
<article>
<header>{props.header}</header>
<main>{props.children}</main>
<footer>{props.actions}</footer>
</article>
);
}
<Card
header={<h2>Title</h2>}
actions={<button>Confirm</button>}
>
Body content
</Card>

{props.children} and any function-typed prop flows through mountChild, which creates a Slot internally and updates in place when the source signal changes. The component doesn’t see the slot β€” it just renders the prop.

Custom element slots for imperative consumers β€” @slot()

If your custom element is consumed through elements-kit JSX, you don’t need @slot() β€” declare plain (or @reactive) properties and place them in your render, exactly like the class-component case above. Reach for @slot() only when imperative DOM code (a vanilla script, or another framework’s ref-then-assign escape hatch) must fill and replace slots against the mounted element.

Here the element is authored with no elements-kit rendering at all β€” plain DOM in connectedCallback. Reading a @slot() property returns its live region; placing it with append() mounts the markers:

import { slot, type SlotContent } from "elements-kit/slot";
class XCard extends HTMLElement {
@slot() header!: SlotContent; // named slot
@slot() body!: SlotContent; // named slot
connectedCallback() {
const article = document.createElement("article");
const head = document.createElement("header");
const main = document.createElement("main");
head.append(this.header); // reading the region places it β€” markers mount here
main.append(this.body);
article.append(head, main);
this.replaceChildren(article);
}
}
customElements.define("x-card", XCard);

Now imperative code can fill and replace a slot at any time β€” the swap lands directly in the live DOM, after mount, with no reactive context:

const el = document.querySelector("x-card")!;
el.header = document.createElement("h2"); // Node
el.header = "plain text"; // native append() content
el.header = "updated"; // replaces the previous content in place
el.header = null; // clears

That post-mount live swap is the only thing @slot() buys you. Consume the same element through elements-kit JSX (<x-card header={<h2/>}>) and you wouldn’t need @slot() at all β€” a plain (or @reactive) field works, because elements-kit JSX assigns props before the element mounts, so the element’s own render reads the final value directly.

Reactive slot content

Pass a signal or () => T as slot content β€” the region updates in place when it changes. Works identically for function components and custom elements:

const title = signal("Initial Title");
<Card header={() => <h2>{title}</h2>}>
Body content
</Card>
// Slot content updates reactively β€” no re-render of surrounding tree
title("Updated Title");

Comparison

Native <slot>ElementsKit Slot
Shadow DOM requiredYesNo
Style encapsulationYesNo (global CSS)
Browser-native projectionYesNo (comment markers)
Reactive content updatesRequires JS re-renderYes β€” slot.set()
No wrapper elementYesYes
Named slotsYes (name attribute)Yes (@slot() properties)
TypeScript named slot propVia IntrinsicElementsThe property’s declared type (PropertiesOf<C>)

Choose native <slot> when you need style encapsulation or are building a reusable web component for external consumers. Choose ElementsKit Slot when you want reactive content swapping in a light DOM component without Shadow DOM overhead.


See also