Interactive islands
Overview
Root.js pages are static HTML by default. To add interactivity, Root.js uses an islands architecture built on custom elements: only the interactive parts of a page load JavaScript.
It works like this:
-
The page is rendered from TSX to HTML.
-
Root.js scans the HTML for custom elements.
-
For each one with a matching file in
elements/**/<tag>.ts, Root.js adds that file to the page.
Create a custom element
You can write custom elements in plain TypeScript, or with a framework that compiles to them (e.g. Preact, Svelte or Vue). This example uses plain TypeScript.
Create a file named after the element's tag in elements/:
// @/elements/root-counter.ts
declare module 'preact' {
namespace JSX {
interface IntrinsicElements {
'root-counter': preact.JSX.HTMLAttributes;
}
}
}
class RootCounter extends HTMLElement {
value = 0;
connectedCallback() {
const button = this.querySelector('button');
const valueEl = this.querySelector('.value');
if (button && valueEl) {
button.addEventListener('click', () => {
this.value += 1;
valueEl.textContent = String(this.value);
});
}
}
}
if (!customElements.get('root-counter')) {
customElements.define('root-counter', RootCounter);
}
Then use the element in any page. There's nothing to import: Root.js detects it and adds the script.
// @/routes/index.tsx
export default function Page() {
return (
<root-counter>
<button>Count</button>
<div className="value">0</div>
</root-counter>
);
}
Hydrate Preact components
Root.js renders TSX with Preact on the server, but doesn't send Preact to the browser. To make a Preact component interactive, wrap it in a custom element that hydrates it on the client.
First, create the element that does the hydrating:
// @/elements/root-island.tsx
import {hydrate} from 'preact';
declare module 'preact' {
namespace JSX {
interface IntrinsicElements {
'root-island': preact.JSX.HTMLAttributes & {
component: string;
props?: string;
};
}
}
}
const islands: Record<string, any> = {};
const islandsModules = import.meta.glob('/islands/**/*.tsx');
Object.entries(islandsModules).forEach(([moduleId, loader]) => {
const componentName = moduleId.split('/')[2];
islands[componentName] = loader;
});
class RootIsland extends HTMLElement {
connectedCallback() {
const componentName = this.getAttribute('component');
if (!componentName) {
return;
}
const propsAttr = this.getAttribute('props');
const props = propsAttr ? JSON.parse(propsAttr) : {};
this.rehydrate(componentName, props);
}
async rehydrate(componentName: string, props: any) {
const loader = islands[componentName];
if (loader) {
const module = await loader();
const Island = module[componentName];
if (Island && Island.Component) {
hydrate(<Island.Component {...props} />, this);
}
}
}
}
if (!window.customElements.get('root-island')) {
window.customElements.define('root-island', RootIsland);
}
Then add components to an islands/ folder, wrapped in <root-island>:
// @/islands/Counter.tsx
import {useState} from 'preact/hooks';
export function Counter(props) {
return (
<root-island component="Counter" props={JSON.stringify(props)}>
<Counter.Component {...props} />
</root-island>
);
}
Counter.Component = (props) => {
const [value, setValue] = useState(0);
function incr() {
setValue((current) => current + 1);
}
return (
<div className="counter">
<button onClick={() => incr()}>Count</button>
<div>{value}</div>
</div>
);
};
Use the component like any other. It's rendered on the server, then hydrated in the browser.
// @/routes/index.tsx
import {Counter} from '@/islands/Counter';
export default function Page() {
return <Counter />;
}