The renderer contract
A slide is HTML, so nothing about it requires React. An island is one piece of another framework living inside a slide, and a renderer is everything the engine knows about that framework: how to put a component into an element, and how to take it out again.
// src/main.tsx — once, where the deck is createdimport { registerLazyRenderer } from "@kadal/deck";
registerLazyRenderer("vue", () => import("@kadal/deck-vue").then((m) => m.vueRenderer));<Island framework="vue" load={() => import("./chart.ts").then((m) => m.Chart)} props={{ data }} client="slide"/><Island>
Section titled “<Island>”| Prop | Meaning |
|---|---|
component |
the component itself, in whatever framework wrote it |
load |
a dynamic import of it instead — the component and (with registerLazyRenderer) its framework stay out of the deck’s first load |
framework |
name the renderer. Otherwise the build’s stamp decides, then owns() |
props |
passed to the component; compared shallowly, so new object identities alone do not remount |
client |
when it comes alive (below) |
fallback |
shown before the island is alive, and instead of it where it never will be (print, previews) |
className, children |
a class on the host; children rendered to HTML and handed over as a slot |
client strategies: slide (the default: alive while its slide is shown, gone when you leave),
load, visible, idle, and none (never: the fallback is the whole story). slide is the one a
deck wants — a deck keeps neighbouring slides mounted so transitions don’t stall, and hydrating all of
them is how a phone runs out of memory on slide three.
Updates, not remounts
Section titled “Updates, not remounts”Step a click and each island is handed new props through the renderer’s update. A chart keeps its
animation and a half-typed form keeps its text, because the component was updated rather than rebuilt.
Every framework does this differently — Vue’s reactive, Svelte’s $state, Solid’s store, Preact’s
diff, Qwik’s signals — behind one optional method.
Writing a renderer
Section titled “Writing a renderer”A renderer is an object with a name and a mount:
import type { DeckRenderer } from "@kadal/deck/renderers/contract";
export const myRenderer: DeckRenderer = { name: "my-framework", mount(host, component, props, context) { // put the component into `host` return { update(next) { /* new props */ }, destroy() { /* take it out */ }, }; }, // optional: owns(component) — recognise a component by inspection; return false when unsure // optional: ssr(component, props) — HTML for surfaces that never hydrate (print, thumbnails)};registerRenderer(renderer) adds one eagerly; registerLazyRenderer(name, () => import(…)) adds one
by name, fetched the first time an island needs it. A renderer package must import
@kadal/deck/renderers/contract — types, a registry and a symbol — never the main entry, which would
drag React into a deck that never asked for it.
How the engine picks a renderer, in order: the framework the author named, then the stamp the
build put on the component (Symbol.for("deck.renderer")), then owns(). Recognition is the build’s
job, not the engine’s, because most frameworks’ production output is indistinguishable from a plain
function.
The frameworks
Section titled “The frameworks”React and custom elements ship with the engine. Each of the others is a package:
| Framework | Package | Notes |
|---|---|---|
| Web components | built in | a tag name or a class |
| Vue | @kadal/deck-vue |
options objects are recognised; functional ones say so |
| Svelte | @kadal/deck-svelte |
a Vite plugin stamps compiled components |
| Solid | @kadal/deck-solid |
*.solid.tsx + a pragma |
| Preact | @kadal/deck-preact |
no plugin at all |
| Qwik | @kadal/deck-qwik |
component$() is mandatory |
| Angular | @kadal/deck-angular |
zoneless, JIT, no build plugin |
| Fresh | @kadal/deck-fresh |
Preact under a Fresh name |
| Rust / WebAssembly | @kadal/deck-wasm |
an ABI, not a framework |
| Python | @kadal/deck-python |
CPython via Pyodide |
The showcase deck runs all eleven on one slide, each fetched on arrival — none of them is in the deck’s first load.
One rule for every package
Section titled “One rule for every package”Each renderer package has a ./build entry that imports nothing but the contract’s symbol. Import
build helpers (deckSolidFiles, deckSveltePlugin, …) in vite.config.ts from there: a Vite config
runs in Node, and importing the renderer itself would load the framework in Node — which some
frameworks (Angular) cannot survive.