← Widget gallery

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

Default

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
```
Custom start and label

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 to 0.
  • label — noun rendered next to the count. Defaults to clicks.

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;
}
`
};