zudo-doc
GitHub repository

Type to search...

to open search from anywhere

Custom Components

Created Jul 13, 2026Updated Oct 11, 2026Takeshi Takatsudo

Add presentational MDX components, overrides, and experimental interactive islands.

Two ways to add a component

Import a component in a single MDX file, or register a reusable component through mdxExtras. Both use zfb's zudo-react descriptions; presentational components render server HTML without client JavaScript.

Per-file imports

import { MyBadge } from "@/components/my-badge";

<MyBadge>New</MyBadge>

A bare MDX import renders on the server. It does not activate state or event handlers by itself.

Global registration: mdxExtras

Create a typed component and register it in a host bindings module:

src/components/my-badge.tsx
/** @jsxRuntime automatic */
/** @jsxImportSource @takazudo/zfb/zudo-react */
import type { Child } from "@takazudo/zfb/zudo-react";

export function MyBadge({ children }: { children?: Child }) {
  return (
    <span class="rounded-full bg-surface px-hsp-sm py-vsp-2xs text-caption text-fg">
      {children}
    </span>
  );
}
src/chrome-bindings.tsx
import { defineChromeBindings } from "@takazudo/zudo-doc/chrome-bindings";
import { MyBadge } from "./components/my-badge";

export const chromeBindings = defineChromeBindings({
  mdxExtras: { MyBadge },
});
zfb.config.ts
import { defineConfig } from "@takazudo/zfb/config";
import { zudoDoc } from "@takazudo/zudo-doc/config";

export default defineConfig(
  zudoDoc({
    siteName: "My Docs",
    chromeBindingsModule: "./src/chrome-bindings.tsx",
  }),
);

Generated routes consume virtual:zudo-doc-chrome-bindings. Omitted slots retain package defaults, and mdxExtras overrides same-named built-in components. Each entry keeps its own typed props, including required ones such as (props: { html: string }), because the MDX call site supplies them; no adapter is needed. Avoid requiring props your MDX usage does not supply. See Host Chrome Bindings.

<MyBadge>New</MyBadge>

Overriding built-in components

The extras map is applied after the built-in MDX map. It can replace admonitions (Note, Tip, Info, Warning, Danger, Important, Caution), helpers (Details, HtmlPreview, CodeGroup, Tabs, TabItem, Island, Asset, AssetCode), and HTML overrides such as p or table. Preserve the authored syntax and prop contract. Prefer a new named component when a global override is unnecessary. See Asset Viewer for manifest-backed assets.

JSX and strict props

The shared tsconfig uses jsxImportSource: "@takazudo/zfb/zudo-react". Import Child, Component, and Description from that public entry; import the JSX namespace from @takazudo/zfb/zudo-react/jsx-runtime. Use class, not className. Native props use explicit DOM spellings and units: lengths in a style object need strings such as "12px", rather than unitless numbers. Native <ol start={3}> and leading newlines in <pre> use the current native contract.

Interactive islands (experimental)

The module opts into client execution with "use client". A server boundary statically imports one fixed client target and supplies serializable props; component functions and signals cannot cross that transport.

src/components/counter.tsx
/** @jsxRuntime automatic */
/** @jsxImportSource @takazudo/zfb/zudo-react */
"use client";

import { computed, getScope, signal } from "@takazudo/zfb/zudo-react";

export default function Counter() {
  const scope = getScope();
  const count = signal(0);
  const label = computed(() => `Count: ${count.value}`);

  scope.onActivate(() => {
    // Browser-only setup belongs here. Return cleanup when needed.
  });

  return (
    <button
      type="button"
      class="rounded-full bg-surface px-hsp-sm py-vsp-2xs text-caption text-fg"
      on:click={() => { count.value += 1; }}
    >
      {label}
    </button>
  );
}
src/components/counter-island.tsx
/** @jsxRuntime automatic */
/** @jsxImportSource @takazudo/zfb/zudo-react */
import { Island } from "@takazudo/zfb";
import Counter from "./counter";

export function CounterIsland() {
  return <>{Island({ when: "visible", children: <Counter /> })}</>;
}
src/chrome-bindings.tsx
import { defineChromeBindings } from "@takazudo/zudo-doc/chrome-bindings";
import { CounterIsland } from "./components/counter-island";

export const chromeBindings = defineChromeBindings({
  mdxExtras: { CounterIsland },
});

Use <CounterIsland /> in MDX. The generated virtual binding path alone cannot guarantee scanner reachability for a custom island: keep the host boundary statically reachable from every applicable document route, with relative imports adjusted for locale/version routes. Do not assign displayName or pass a pre-rendered child as the island target. Verify the real built island marker and browser activation after changing the route graph.

For this custom island only, replace the existing virtual:zudo-doc-chrome-bindings import in every applicable route stub with a direct static import. Keep the rest of the route, including createChrome(routeCtx, chromeBindings), unchanged. The import section for the default and locale document routes becomes:

pages/docs/[[...slug]].tsx (import section)
import { chromeBindings } from "../../src/chrome-bindings";
pages/[locale]/docs/[[...slug]].tsx (import section)
import { chromeBindings } from "../../../src/chrome-bindings";

Versioned stubs also need a direct static import. Adjust its relative path for each directory depth (pages/v/[version]/docs/ uses ../../../../src/chrome-bindings; a locale/version combination adds another level). Package-injected routes need a project-owned route seam that makes the same static boundary reachable. This advanced route customization is needed for discovery of a custom client island; ordinary presentational mdxExtras keeps the generated virtual import.

zudo-react runs component setup once per scope. Pass a signal or computed signal directly to JSX for reactive output; {count.value} would read only the setup-time snapshot. scope.onActivate owns browser setup and can return cleanup. scope.effect owns reactive side effects. Setup helpers receive scope and signals explicitly rather than emulating React hooks. Use scope.abortSignal for fetches, and check it again after an awaited import or request before mutating the DOM.

Forms and events

The five model adapters are text inputs, checkbox inputs, radio inputs, textarea, and single-select. Text/textarea/select use modelValue with a writable string signal; checkbox uses modelChecked with a boolean signal; radio uses modelValue with a string-or-null signal and an explicit value. Unsupported input kinds and multiple select need explicit native handlers. Do not mix a model adapter with competing value/checked handlers.

src/components/name-field.tsx
/** @jsxRuntime automatic */
/** @jsxImportSource @takazudo/zfb/zudo-react */
"use client";

import { signal } from "@takazudo/zfb/zudo-react";

export function NameField() {
  const name = signal("");
  return <input type="text" modelValue={name} aria-label="Name" />;
}
function handleInput(event: Event) {
  const target = event.currentTarget;
  if (!(target instanceof HTMLInputElement)) return;
  console.log(target.value);
}

Event handlers use on:click, on:input, or on:event-name:capture. Handlers receive native Event; narrow currentTarget locally before reading element fields.

Structural updates and trusted HTML

Use Show for a boolean branch and keyed For for a changing list. A Show whose condition stays true retains its child scope; changing a key in For removes the old scope and creates the new one. A JSX key on an ordinary element does not provide that reset behavior. Keep table structure valid: table children are native sections/rows, section children are rows, and row children are cells. Do not put Show/For marker structures directly inside those restricted parents; place the structural boundary around a complete table.

Use rawHtml only after a trust review at each site. It does not sanitize content. Script/style raw bodies require a static string, with no reactive replacement or mixed children. Prefer escaped children for user-provided text. modalDialog(scope, options) from @takazudo/zudo-doc/use-modal-dialog owns a native dialog for its scope; conditional dialogs need a child component inside Show so the helper's ref and lifetime belong to that child.

See also

Revision History

Takeshi TakatsudoCreated: 2026-07-13T23:42:56+09:00Updated: 2026-10-11T20:18:53+09:00

AI Assistant

Ask a question about the documentation.

Preview theme

Loading theme previews…