Preact Counter
A counter whose interactive layer is a Preact component, mounted into server-rendered markup on hydrate.
hydration · third-party ui · dynamic import · params
Live example
In a .page.html file or a renderHTML() string:
<widget-preact-counter></widget-preact-counter>
In a .page.md file or a renderMarkdown() string:
```widget:preact-counter
```
In a .page.html file or a renderHTML() string:
<widget-preact-counter start="12" label="taps"></widget-preact-counter>
In a .page.md file or a renderMarkdown() string:
```widget:preact-counter
{"start":"12","label":"taps"}
```
How it works
renderHTML() writes a disabled button holding the start value — a real, readable placeholder rather than an empty mount point. hydrate() then import()s Preact and preact/hooks on demand, clears the placeholder and renders a stateful Counter into the shadow root.
The dynamic import matters: nothing about Preact is fetched for a visitor who never hydrates the widget. Under /md/ the widget collapses to a single italic line and no third-party code is involved at all.
This is the pattern for wrapping any component library. emroute owns the route, the SSR output and the element lifecycle; the library owns whatever happens inside hydrate().
Parameters
start— initial count, as a string. Defaults to0.label— noun rendered next to the count. Defaults toclicks.
Requirements
The compiled module imports preact and preact/hooks as bare specifiers. Your import map (or bundler) has to resolve them — this guide maps both to esm.sh. Everything else the widget needs is already inside the file.
Source
import { WidgetComponent } from "@emkodev/emroute";
interface PreactCounterParams {
start?: string;
label?: string;
}
class PreactCounterWidget extends WidgetComponent<PreactCounterParams, null> {
override readonly name = "preact-counter";
override renderHTML({ params }: this["RenderArgs"]): string {
const start = Number(params.start ?? 0);
const label = params.label ?? "clicks";
return `<div class="mount">
<button type="button" disabled>
<span class="count">${start}</span>
<span class="label">${label}</span>
</button>
<small class="note">SSR placeholder — Preact takes over on hydrate</small>
</div>`;
}
override renderMarkdown({ params }: this["RenderArgs"]): string {
const start = Number(params.start ?? 0);
const label = params.label ?? "clicks";
return `*Interactive Preact counter starting at ${start} ${label}*`;
}
override async hydrate({ params }: this["RenderArgs"]): Promise<void> {
const [{ h, render }, { useState }] = await Promise.all([
import("preact"),
import("preact/hooks"),
]);
const start = Number(params.start ?? 0);
const label = (params.label ?? "clicks") as string;
const Counter = () => {
const [n, setN] = useState(start);
return h(
"button",
{ type: "button", onClick: () => setN(n + 1) },
h("span", { className: "count" }, n),
" ",
h("span", { className: "label" }, label),
);
};
const mount = this.element?.shadowRoot?.querySelector(".mount");
if (mount) {
mount.innerHTML = "";
render(h(Counter, null), mount as Element);
}
}
}
export default new PreactCounterWidget();
:host {
display: inline-block;
}
.mount {
display: inline-flex;
flex-direction: column;
align-items: flex-start;
gap: 0.4rem;
}
button {
font-family: inherit;
font-size: 0.95rem;
font-weight: 600;
background: var(--brand, #4a6cf7);
color: #fff;
border: none;
padding: 0.6rem 1rem;
border-radius: 8px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.4rem;
transition:
transform 0.15s,
background 0.2s;
}
button:hover:not(:disabled) {
transform: translateY(-1px);
background: var(--brand-light, #6b88ff);
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.count {
font-variant-numeric: tabular-nums;
font-weight: 800;
font-size: 1.05rem;
}
.label {
font-weight: 500;
opacity: 0.92;
}
.note {
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.5);
font-style: italic;
}
Compiled module
preact-counter.widget.js — 2.3 kB. Component, companion files and styles in one ES module, byte-for-byte what emroute's own runtime builds when it transpiles the TypeScript above.
Save it as widgets/preact-counter/preact-counter.widget.js. The runtime picks it up on its next scan and <widget-preact-counter> starts resolving — the inlined __files export carries the styles, so there is nothing else to copy.
It imports @emkodev/emroute, preact and preact/hooks. Resolve those through your import map or bundler; the module needs nothing else.
View preact-counter.widget.js
import { WidgetComponent } from "@emkodev/emroute";
class PreactCounterWidget extends WidgetComponent {
name = "preact-counter";
renderHTML({ params }) {
const start = Number(params.start ?? 0);
const label = params.label ?? "clicks";
return `<div class="mount">
<button type="button" disabled>
<span class="count">${start}</span>
<span class="label">${label}</span>
</button>
<small class="note">SSR placeholder \u2014 Preact takes over on hydrate</small>
</div>`;
}
renderMarkdown({ params }) {
const start = Number(params.start ?? 0);
const label = params.label ?? "clicks";
return `*Interactive Preact counter starting at ${start} ${label}*`;
}
async hydrate({ params }) {
const [{ h, render }, { useState }] = await Promise.all([
import("preact"),
import("preact/hooks")
]);
const start = Number(params.start ?? 0);
const label = params.label ?? "clicks";
const Counter = () => {
const [n, setN] = useState(start);
return h(
"button",
{ type: "button", onClick: () => setN(n + 1) },
h("span", { className: "count" }, n),
" ",
h("span", { className: "label" }, label)
);
};
const mount = this.element?.shadowRoot?.querySelector(".mount");
if (mount) {
mount.innerHTML = "";
render(h(Counter, null), mount);
}
}
}
var stdin_default = new PreactCounterWidget();
export {
stdin_default as default
};
export const __files = {
css: `:host {
display: inline-block;
}
.mount {
display: inline-flex;
flex-direction: column;
align-items: flex-start;
gap: 0.4rem;
}
button {
font-family: inherit;
font-size: 0.95rem;
font-weight: 600;
background: var(--brand, #4a6cf7);
color: #fff;
border: none;
padding: 0.6rem 1rem;
border-radius: 8px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.4rem;
transition:
transform 0.15s,
background 0.2s;
}
button:hover:not(:disabled) {
transform: translateY(-1px);
background: var(--brand-light, #6b88ff);
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.count {
font-variant-numeric: tabular-nums;
font-weight: 800;
font-size: 1.05rem;
}
.label {
font-weight: 500;
opacity: 0.92;
}
.note {
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.5);
font-style: italic;
}
`
};