## 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

```mdx
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:

```tsx title="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>
  );
}
```

```tsx title="src/chrome-bindings.tsx"
import { defineChromeBindings } from "@takazudo/zudo-doc/chrome-bindings";
import { MyBadge } from "./components/my-badge";

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

```ts title="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](https://zudo-doc.takazudomodular.com/docs/reference/host-chrome-bindings).

```mdx
<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](https://zudo-doc.takazudomodular.com/docs/guides/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.

```tsx title="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>
  );
}
```

```tsx title="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 /> })}</>;
}
```

```tsx title="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:

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

```tsx title="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.

```tsx title="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" />;
}
```

```tsx
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

- [Customizing zudo-doc](https://zudo-doc.takazudomodular.com/docs/reference/customizing)
- [Writing Documentation](https://zudo-doc.takazudomodular.com/docs/getting-started/writing-docs)
- [Component First Strategy](https://zudo-doc.takazudomodular.com/docs/reference/component-first)