zudo-doc
GitHub repository

Type to search...

to open search from anywhere

Custom Components

Created Jul 13, 2026Updated Aug 30, 2026Takeshi Takatsudo

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

Two ways to add a component

You can make a component available to MDX in two ways:

  • Import it in one file when it belongs to a single document or a small, explicit set of documents.

  • Register it globally with mdxExtras when authors should be able to use the component throughout the documentation, or when you are replacing a built-in MDX component.

Both approaches render on the server. Choose global registration only for a shared authoring surface; it is not required for ordinary document-specific UI.

Per-file imports

Import a component directly at the top of an .mdx file. The scaffold's @/ alias works from MDX, so the component can live with the rest of your project code.

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

<MyBadge>New</MyBadge>

This is the smallest and most local option. A bare MDX import is SSR-only: it renders the component into HTML, but it does not make a client component hydrate. See Interactive islands (experimental) when the component needs browser-side state or event handlers.

Global registration: mdxExtras

mdxExtras is the global registry for project-owned MDX components. Create a bindings module and point zudoDoc() at it. Fresh scaffold routes already consume the configured module through virtual:zudo-doc-chrome-bindings, so presentational components need no route-stub edit.

Start with this complete example.

src/components/my-badge.tsx
import type { ComponentChildren } from "preact";

export function MyBadge({ children }: { children: ComponentChildren }) {
  return (
    <span className="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 "zfb/config";
import { zudoDoc } from "@takazudo/zudo-doc/config";

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

The generated pages/docs/[[...slug]].tsx and locale counterpart both import chromeBindings from virtual:zudo-doc-chrome-bindings and call createChrome(routeCtx, chromeBindings). When doc history is enabled, the generator merges DocHistory over that same object rather than replacing it. This is the supported no-fork path for presentational, server-rendered mdxExtras and every other host binding.

The virtual path does not make islands hydrate

The generated virtual path does not make a component declared inside the bindings module scanner-reachable. It therefore does not turn a presentational binding into a hydrating client island. If an experimental island needs route-level static reachability, explicitly import the project bindings module from every applicable route stub as described below; that is an advanced route customization, not required for ordinary mdxExtras.

Once registered, every MDX page can use the component without an import:

<MyBadge>New</MyBadge>

For the delivery channel and its other host-bound slots, see Host Chrome Bindings.

Overriding built-in components

mdxExtras also overrides a built-in component when it uses the same key. This resolves in two stages: chrome/derive merges host mdxExtras over its package defaults, then the package MDX-component factory spreads those extras last after its default map. Your same-named binding therefore wins.

The overridable names include:

  • Admonitions: Note, Tip, Info, Warning, Danger, Important, and Caution

  • Content helpers: Details, HtmlPreview, CodeGroup, Tabs, TabItem, Island, PresetGenerator, Asset, and AssetCode

  • HTML overrides such as p, a, img, and table

Keep the replacement compatible with the MDX syntax and props that your existing documents use. Replacing p or another HTML element affects every matching element in the documentation, so prefer a new named component unless a global rendering change is intentional.

Asset and AssetCode are the package defaults for manifest-backed asset cards and source excerpts. See Asset Viewer for their authoring syntax and the settings that supply their asset manifest.

className, not class

Write className in project .tsx components, not class. The scaffold maps react to preact/compat, so authored components type-check through the React-compatible JSX types. class= fails zfb check with TS2322; className works in both the scaffold and Preact-compatible code.

// Correct in project-owned .tsx files.
export function MyBadge() {
  return <span className="text-caption">New</span>;
}

Some zudo-doc package and showcase source uses class=. That source follows its own Preact compilation boundary; do not copy that spelling into a newly authored scaffold component.

Interactive islands (experimental)

An interactive component needs more than an MDX import: its module must opt into client execution, and zfb must be able to reach that import through a static route graph. This recipe registers a visible-on-scroll counter island through the same static bindings path described above.

src/components/counter.tsx
"use client";

import { useState } from "preact/hooks";

export default function Counter() {
  const [count, setCount] = useState(0);

  return (
    <button type="button" className="rounded-full bg-surface px-hsp-sm py-vsp-2xs text-caption text-fg" onClick={() => setCount((value) => value + 1)}>
      Count: {count}
    </button>
  );
}
src/chrome-bindings.tsx
import { Island } from "@takazudo/zfb";
import { defineChromeBindings } from "@takazudo/zudo-doc/chrome-bindings";
import Counter from "./components/counter";

(Counter as { displayName?: string }).displayName = "Counter";

const CounterIsland = () =>
  Island({
    when: "visible",
    children: <Counter />,
  });

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

For this experimental island case only, replace the generated virtual import in every applicable document-route stub with a static ../../src/chrome-bindings import (adjusting the relative path for locale routes). The default virtual-module path is SSR-presentational and cannot guarantee discovery. Setting Counter.displayName pins the island marker name even when the render pipeline rewrites function names.

Experimental scanner contract

This pattern is experimental because upstream island-marker deduplication remains fragile; follow issue #2718 for that work. Test the hydrated behavior in your built site whenever you change an island's registration path.

This showcase's pages/lib/_preset-generator.tsx demonstrates the same Island({...}) call form and display-name pinning. For a presentational className-based component, see the Pill renderer in src/config/frontmatter-preview-renderers.tsx in this showcase.

Use the registered island from MDX like any other global component:

<CounterIsland />

See also

Revision History

Takeshi TakatsudoCreated: 2026-07-13T23:42:56+09:00Updated: 2026-08-30T22:39:06+09:00

AI Assistant

Ask a question about the documentation.

Preview theme

Loading theme previews…