Astro
elements-kit ships a first-class Astro integration: write .tsx components with elements-kit JSX and use them as Astro islands. The server renders them to HTML with elements-kit/server; the client adopts that DOM with elements-kit/hydrate — handlers attach to the existing nodes, nothing is rebuilt.
Setup
import { defineConfig } from "astro/config";import elementsKit from "elements-kit/integrations/astro";
export default defineConfig({ integrations: [elementsKit()],});The integration registers the renderer pair and points Vite’s JSX transform at elements-kit, so island components need no per-file pragma.
Islands
This counter is a live island on this page — server-rendered by the elements-kit renderer, hydrated with client:load, receiving start={5} through Astro’s island props (this very docs site runs the integration):
import { computed, signal } from "elements-kit/signals";
export default function Counter(props: { start?: () => number }) { const count = signal(props.start?.() ?? 0); const double = computed(() => count() * 2);
return ( <div> <button on:click={() => count(count() + 1)}> count is {() => count()} </button> <span>doubled: {double}</span> </div> );}---import Counter from "../components/Counter.tsx";---
<Counter start={5} client:load />All Astro client directives work (client:load, client:idle, client:visible, client:media). For client-rendered-only islands, name the renderer:
<Widget client:only="elements-kit" />How hydration behaves
- The server render is a snapshot — signals read once, effects don’t run. Effects and
on:handlers come alive at hydration. - Server-rendered DOM is adopted, not rebuilt — node identity is preserved, no flicker.
async/promisevalues render on the server, serialize, and seed the client instance — withasync+.run(), the client fetch is skipped entirely.- Props must serialize (Astro’s island contract) and the component must render the same tree on server and client.
Lists
<For> rows are adopted at hydration and reconciled in place afterwards — reordering moves the existing DOM nodes:
- alpha
- beta
- gamma
Async data — seeded, no refetch
async + .run() values are fetched during the server render, serialized into the island’s ek-data, and seeded on the client. The deferred run() is discarded — the fetcher never executes in the browser, which is why this timestamp is the server’s:
Server value, no client refetch: rendered at 2026-07-31T07:11:33.423Z
const fetchedAt = async(() => fetchSomething());
export default function Demo() { fetchedAt.run(); // server: awaited + serialized · client: skipped return <p>{fetchedAt}</p>;}Slots
Islands accept Astro slot content — the integration delivers it as pre-rendered HTML mapped to children (default slot) and named props:
This paragraph is Astro slot content living inside an interactive island — collapse it with the button.
Slotted header<AstroCard client:load> <Fragment slot="header"><strong>Slotted header</strong></Fragment> <p>Default slot content.</p></AstroCard>export default function AstroCard(props: { children?: unknown; // default slot header?: unknown; // named slot}) { return ( <section> <header>{props.header}</header> {props.children} </section> );}Slot HTML is parsed script-inertly and placed as-is — it is Astro-rendered content, not part of the component’s reactive tree.
Server islands (server:defer)
Slots are also how Astro Server Islands deliver their fallback — server:defer works with elements-kit components out of the box:
<AstroCard server:defer> <div slot="fallback">Loading the personalized card…</div></AstroCard>Lazy loading & Await
Code splitting is not a new primitive — it’s async + a dynamic import. <Await> — elements-kit’s Suspense equivalent — shows a fallback while the import is pending. On the server the stream awaits the import — the fallback never server-renders; on hydration the server content stays visible until the chunk lands (no fallback flash):
import { async } from "elements-kit/utilities/async";import { Await } from "elements-kit/await";
const chart = async(() => import("./chart").then((m) => m.default));
export default function Panel() { chart.run(); // server: awaited · hydration: deferred · client: import return ( <Await fallback={() => <em>loading…</em>}>{chart}</Await> );}Calling chart.run() early (e.g. on hover) doubles as preload. To pass props to the code-split component, resolve it into an element factory:
<Await fallback={() => <em>loading…</em>}> {promise(chart.then((C) => () => <C data={data} />))}</Await>Coexisting with other frameworks
Multiple renderers can live in one project. Scope the other framework’s JSX transform so it doesn’t claim elements-kit files — e.g. with React:
integrations: [ elementsKit(), react({ include: ["**/src/react/**"] }),],Custom elements without the integration
A custom element built with elements-kit is just class extends HTMLElement registered via customElements.define. Astro renders HTML natively and custom elements work without any configuration — no integration required. The main integration point is registering your elements client-side and getting typed DOM references in TypeScript.
// shared element — built once, used anywhereimport { defineElement } from "elements-kit/custom-elements";import { reactive } from "elements-kit/signals";
export class XCounter extends HTMLElement { @reactive() count = 0;}
defineElement("x-counter", XCounter);Register elements client-side
Because elements-kit touches the DOM, the module that calls defineElement must run in the browser. Import it from a <script> tag or a client:* island — not from the Astro component frontmatter:
---// x-counter.astro — server-only frontmatter, keep it clean---
<x-counter></x-counter>
<script> // Runs in the browser — safe to import DOM code here import "./x-counter";</script>The environment guard (isBrowser) is only needed for module-level reads of window / document outside of event handlers or lifecycle callbacks.
Source the prop shape from the class
Three type helpers exported from elements-kit/custom-elements read the raw platform surface off the class. All three take the constructor (typeof XCounter); PropertiesOf also accepts an instance type.
| Helper | Shape | Use when |
|---|---|---|
PropertiesOf<typeof XCounter> | Public instance fields, all optional — the HTMLElement surface is dropped; Slot-typed fields accept a Node | Typing property assignments — prop bindings, wrappers, helper functions |
AttributesOf<typeof XCounter> | Every key of static [ATTRIBUTES], valued string | null | Typing the raw HTML attribute surface — setAttribute, server-rendered markup |
EventsOf<typeof XCounter> | The static events map, verbatim — event name to event type | Typing addEventListener, framework event bindings, HTMLElementEventMap augmentations |
Typed document.querySelector and createElement
Add HTMLElementTagNameMap augmentations to src/env.d.ts (Astro’s ambient type file) so document.querySelector is typed across the whole project:
/// <reference types="astro/client" />
import type { XCounter } from "./x-counter";
declare global { interface HTMLElementTagNameMap { "x-counter": XCounter; }}Then anywhere in client-side scripts:
const el = document.querySelector("x-counter"); // XCounter | nullIslands (React, Solid, Vue)
If you’re using framework islands (client:load, client:visible, etc.) and want typed props when you render a custom element inside a React, Solid, or Vue component, add the JSX IntrinsicElements augmentation for that framework alongside the class definition and import it from the island component file: