Skip to content
Blume
Esc
↑↓navigate↵open⌘Jpreview
Guides

Writing

Add live React component demos to your MDX docs

A live component demo beside the code that uses it, with a props table, a text version for readers and agents that can't run it, and a keyboard test on the production build.

By 11 min read

To show a working component beside its usage code, write a small React demo and put it on the page in one of two ways. Save it in an islands/ folder and use it by name in any MDX page, next to a fenced code block. Or save it in examples/ and render it with the built-in <Component path="…" />, which shows a live preview and the file's source in Preview and Code tabs. Either way, Blume turns React on for you, and the demo's JavaScript loads only on the pages that use it.

By the end, you have a switch component documented on its own page: a live demo that works with a mouse and a keyboard, the code to copy, a props table, a text description for readers and agents that can't run the demo, and a Playwright test that checks it against the production build.

Blume renders the demo you write; it doesn't generate one from your component. If your component doesn't respond to input, you don't need a demo at all: a static override renders as HTML with no JavaScript. And if you want a workbench that renders every state of every component with generated prop controls, a tool like Storybook fits better.

Choose an island, an example, or an override

OptionWhere it livesWhat readers get
Islandislands/SwitchDemo.tsx, or a components.ts entry with a client modeThe live component inline in the page, beside whatever code you write
Exampleexamples/switch/basic.tsxPreview and Code tabs from one file, with the preview in an isolated frame
Static overrideA components.ts entry with no client modeHTML rendered at build time, with no JavaScript

Use an island when you want the demo and a hand-picked snippet on screen together. Use an example when the code readers copy should be exactly the code that runs: it's one file, so the two can't drift apart. This guide builds the island first, then shows the same demo as an example.

Build the component

The component being documented is a switch built on a native <button> with role="switch". A native button already takes focus and responds to Space and Enter, so the component needs no key handlers. In a real project it lives in your component package. Here it sits in ui/ at the project root so the example is self-contained.

export interface SwitchProps {
  /** Whether the switch is on. */
  checked: boolean;
  /** The visible label, which is also the switch's accessible name. */
  label: string;
  /** Called with the new value when the reader toggles the switch. */
  onCheckedChange: (checked: boolean) => void;
  /**
   * Stops the switch from responding to clicks and keys.
   * @default false
   */
  disabled?: boolean;
}

export function Switch({
  checked,
  disabled = false,
  label,
  onCheckedChange,
}: SwitchProps) {
  return (
    <button
      aria-checked={checked}
      className="group inline-flex items-center gap-3 rounded-blume text-foreground text-sm focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-foreground disabled:opacity-50"
      disabled={disabled}
      onClick={() => onCheckedChange(!checked)}
      role="switch"
      type="button"
    >
      <span
        aria-hidden="true"
        className="flex h-6 w-11 shrink-0 items-center rounded-full bg-muted p-0.5 ring-1 ring-border ring-inset transition-colors group-aria-checked:bg-action motion-reduce:transition-none"
      >
        <span className="size-5 rounded-full bg-background shadow-sm transition-transform group-aria-checked:translate-x-5 motion-reduce:transition-none" />
      </span>
      {label}
    </button>
  );
}

The classes use Blume's theme tokens, like bg-muted and bg-action, so the switch follows the site's colors in light and dark mode. Blume generates Tailwind classes from the .tsx files in your project, so there's nothing to configure. Keep demos in .tsx too: the site's stylesheet doesn't scan .jsx files, so a class used only in one is never generated. React ships with Blume and switches on when your project has a .tsx or .jsx file. For type checking in your editor, add React's types with npm install -D @types/react.

Register the demo as an island

The demo holds the state, so the page can show the switch working. Create islands/SwitchDemo.tsx:

import { useState } from "react";
import { Switch } from "../ui/Switch";

export default function SwitchDemo({ label = "Email alerts" }: { label?: string }) {
  const [checked, setChecked] = useState(false);

  return (
    <div className="not-prose my-6 flex items-center justify-between gap-4 rounded-blume border border-border p-4">
      <Switch checked={checked} label={label} onCheckedChange={setChecked} />
      <code className="font-mono text-muted-foreground text-xs">
        checked: {String(checked)}
      </code>
    </div>
  );
}

Blume picks up every .tsx and .jsx file in islands/ and names the MDX component after the file: SwitchDemo.tsx becomes <SwitchDemo />, on every page, with no import. The file name must be a PascalCase identifier, and the component must be the file's default export. Vue and Svelte islands work the same way once you install their Astro integration, though the site's Tailwind scan skips .vue and .svelte files as well.

The not-prose class matters. MDX pages render inside the docs' typography styles, which would otherwise restyle the demo's text and code. The checked: readout shows readers the value their callback receives.

If you'd rather keep demos out of islands/, register them in components.ts. The client mode is what makes an entry an island:

import { defineComponents } from "blume";

export default defineComponents({
  mdx: {
    SwitchDemo: { component: "./demos/SwitchDemo.tsx", client: "visible" },
  },
});

Leave out client and the component renders as static HTML: the switch shows up but never responds, and Blume warns about it.

Place it beside the usage code

Create the page. Put the context first, then the demo, then the code readers copy:

---
title: Switch
description: A two-state control for settings that take effect at once.
---

Use a switch for a setting that applies as soon as it changes. Readers can click it, or Tab to it and press Space or Enter. It reports its state to assistive technology through `aria-checked`.

<SwitchDemo />

```tsx
import { useState } from "react";
import { Switch } from "@acme/ui";

export function NotificationSettings() {
  const [alerts, setAlerts] = useState(false);

  return (
    <Switch checked={alerts} label="Email alerts" onCheckedChange={setAlerts} />
  );
}
```

## Props

<AutoTypeTable path="./ui/Switch.tsx" name="SwitchProps" />

The snippet imports from @acme/ui, the package your readers install, while the demo imports the local file. That's the trade-off of an island: you choose exactly what the snippet shows, and you keep the two in step yourself.

Props you pass in MDX reach the component, so <SwitchDemo label="Marketing emails" /> relabels the demo. They're written into the page for hydration, so they must be serializable: strings, numbers, and plain objects, not functions.

AutoTypeTable reads the SwitchProps interface with the TypeScript compiler at build time. Descriptions come from the JSDoc comments, and @default sets the default value. It documents the type you name, not the component, so export a props type and keep its comments current.

Keep the demo and its code in one file

To show the exact code that runs, put the demo in examples/:

import { useState } from "react";
import { Switch } from "../../ui/Switch";

export default function Basic() {
  const [checked, setChecked] = useState(false);

  return (
    <Switch checked={checked} label="Email alerts" onCheckedChange={setChecked} />
  );
}

Then render it with the built-in Component, using its path under examples/ without the extension:

<Component path="switch/basic" />

Readers get a Preview tab and a Code tab that shows this file. The preview renders in its own frame with Tailwind and Blume's theme tokens but none of the docs' prose styles, so it doesn't need not-prose. Because the Code tab shows the file as written, import the component the way your readers will, from your package, once your docs project can resolve it. The monorepo guide covers docs that sit beside the package.

Choose when it hydrates

Hydration is when the demo's JavaScript loads and takes over the HTML Blume rendered at build time. Islands and examples default to visible: the switch is in the page's HTML from the start and becomes interactive when it scrolls into view. To change that, export a mode from the file, beside the default export:

export const client = "load";
ModeHydratesUse it for
visible (default)When scrolled into viewMost demos
loadAs soon as the page loadsA demo at the top of the page that readers use right away
idleWhen the browser is idleA demo that can wait
onlyIn the browser only, never rendered on the serverComponents that can't render on the server
mediaWhen a media query matchesDemos that only work at some screen sizes (a components.ts entry only, with a media query)

Blume reads the client line as text rather than running the file, so write the mode as a string literal. An unknown mode falls back to visible with a warning.

Server rendering is also where browser-only code breaks. If a component reads window, document, or localStorage while it renders, the page fails to render on the server, because none of those exist there. Move that code into an effect, which runs only in the browser:

import { useEffect, useState } from "react";

export default function ReducedMotionNote() {
  const [reduced, setReduced] = useState(false);

  // Effects run only in the browser, so window is safe to read here.
  useEffect(() => {
    setReduced(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
  }, []);

  return (
    <p className="not-prose text-muted-foreground text-sm">
      {reduced
        ? "Your system asks for reduced motion, so the thumb moves without sliding."
        : "The thumb slides when the switch changes."}
    </p>
  );
}

If a library touches window as soon as it's imported, an effect can't help, because the import itself runs on the server. Add export const client = "only"; so the component renders only in the browser. The cost is that its space stays empty until the JavaScript loads, and readers without JavaScript see nothing there.

Explain it for readers who can't run it

Treat the demo as an illustration, not the documentation. Some readers load the page with scripts blocked, and agents read the page's Markdown version, where nothing runs. The page should still say what the demo shows:

  • The prose carries the behavior. The sentence above <SwitchDemo /> says what the switch does and which keys work, so it stands on its own.
  • The server-rendered HTML shows the starting state. In any mode but only, readers without JavaScript see the switch in its off position, though it doesn't respond.
  • The Markdown version needs a description. In a page's .md version, an examples/ preview becomes its source code automatically, but an island stays as a bare <SwitchDemo /> tag.

Give the island a Markdown form in your config:

import type { ComponentMarkdown } from "blume";
import { defineConfig } from "blume";

const switchDemo: ComponentMarkdown = ({ props }) =>
  `*Interactive demo: a switch labeled "${props.label ?? "Email alerts"}" that starts off. Clicking it, or pressing Space or Enter while it has focus, turns it on.*`;

export default defineConfig({
  agents: {
    markdownComponents: {
      SwitchDemo: switchDemo,
    },
  },
});

The same description replaces the tag in llms-full.txt and in the MCP server's page tool. AutoTypeTable stays as written in the Markdown version, because it needs the type checker. If agents reading your props matter more than the table staying in sync with the source, write the rows in a TypeTable, which converts to a Markdown table.

Test it from the keyboard

Run npx blume dev, open the page, and put the mouse away. Check that:

  • Tab reaches the switch, and a focus ring shows around it.
  • Space and Enter both toggle it, and the readout changes.
  • The next Tab moves past the demo, so focus never gets stuck.
  • Your browser's accessibility inspector, or a screen reader, reports a switch named "Email alerts" whose state changes.
  • On an example, the arrow keys move between the Preview and Code tabs, and Tab moves from the selected tab into the preview.

Then automate the check. Install Playwright and its browser:

npm install -D @playwright/test@1.63.0
npx playwright install chromium

Point it at a production build, since that's what readers get:

import { defineConfig } from "@playwright/test";

export default defineConfig({
  testDir: "tests",
  use: { baseURL: "http://localhost:4321" },
  webServer: {
    command: "npx blume build && npx blume preview --port 4321",
    url: "http://localhost:4321/components/switch",
    reuseExistingServer: !process.env.CI,
    timeout: 180_000,
  },
});
import { expect, test } from "@playwright/test";

test("the switch demo works from the keyboard", async ({ page }) => {
  await page.goto("/components/switch");
  const toggle = page.getByRole("switch", { name: "Email alerts" });
  await toggle.scrollIntoViewIfNeeded();

  // Astro removes the island's ssr attribute once it has hydrated.
  const island = page.locator("astro-island", { has: toggle });
  await expect(island).not.toHaveAttribute("ssr");
  await expect(toggle).toHaveAttribute("aria-checked", "false");

  await toggle.focus();
  await page.keyboard.press("Space");
  await expect(toggle).toHaveAttribute("aria-checked", "true");
  await page.keyboard.press("Enter");
  await expect(toggle).toHaveAttribute("aria-checked", "false");
});

Run it with npx playwright test. The wait for hydration matters: a key press that lands on the server-rendered button before its JavaScript arrives does nothing, and the test would fail for the wrong reason.

Check the production build

Island problems mostly surface as build warnings, so read the output as well as the exit code:

npx blume build
npx blume preview

In the build output, look for:

  • A warning that an island was skipped for its file name, or BLUME_UNKNOWN_COMPONENT naming a page that uses a tag Blume doesn't know.
  • A warning that a components.ts entry renders as static HTML with no interactivity.
  • For a Vue or Svelte island, BLUME_DEPENDENCY_MISSING, which fails the build until you install the framework's Astro integration.

Then open the page in the preview. Check that the demo works, that the page still reads correctly with JavaScript turned off in your browser, and that /components/switch.md shows your description where the demo was. blume preview can't serve a Vercel or Netlify server build. On those, run the checks against npx blume dev, or deploy a preview.

Troubleshooting

The demo shows up but doesn't respond

A components.ts entry without client renders as static HTML, and Blume warns that it has no hydration mode. Add one. If the entry is right, open the browser console: an error thrown while the island hydrates leaves the server-rendered HTML in place.

Blume says the tag isn't a known component

BLUME_UNKNOWN_COMPONENT means no built-in, island, or components.ts entry matches the tag. Check that the file is in islands/ at the project root, that its name is PascalCase with only letters, digits, and underscores (switch-demo.tsx is skipped with a warning), and that the tag matches it exactly. When two islands share a name, Blume keeps the first by file path and warns about the other.

The build fails with "window is not defined"

Browser-only code ran during server rendering. Move it into an effect, or use client = "only" for a library that touches the browser on import (see Choose when it hydrates).

A built-in component changed on every page

An island named like a built-in, such as Badge.tsx, replaces it everywhere. Rename the file, for example to BadgeDemo.tsx, and update the tags that use it.

The demo's styles look wrong

Stray margins, fonts, or code styling come from the docs' typography: add not-prose to the island's outer element. Missing classes mean Tailwind never scanned the file that uses them. The site's stylesheet scans your project's .tsx, .ts, .mdx, and .astro files, so a class used only in a .jsx, .vue, or .svelte island is never generated: rename a React island to .tsx, or add @source "./islands"; to your theme.css. A component in a sibling package needs an @source line for that package, in theme.css or, for preview frames, in the stylesheet your examples.css config points at, as the Component docs describe.

Next step

Add your first island

Save a React component in islands/ and use it by name in any MDX page, with no import.

npx blume dev
Read the islands docs

A step here not working for you? Report a broken step.

Keep going.More guides.

Upgrade your docs with Blume.

Install today and ship a production-grade docs site in minutes. Free and open source, forever.

npx blume init