Custom Components
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
mdxExtraswhen 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.
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>
);
}import { defineChromeBindings } from "@takazudo/zudo-doc/chrome-bindings";
import { MyBadge } from "./components/my-badge";
export const chromeBindings = defineChromeBindings({
mdxExtras: { MyBadge },
});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, andCautionContent helpers:
Details,HtmlPreview,CodeGroup,Tabs,TabItem,Island,PresetGenerator,Asset, andAssetCodeHTML overrides such as
p,a,img, andtable
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.
"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>
);
}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 . 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/ demonstrates the same Island({...}) call form and display-name pinning. For a presentational className-based component, see the Pill renderer in src/ in this showcase.
Use the registered island from MDX like any other global component:
<CounterIsland />See also
Customizing zudo-doc for the minimal-to-extended customization ladder
Host Chrome Bindings for the bindings module and its non-component slots
Writing Documentation for regular MDX authoring