Overview
I'd like to share how to embed live code editor for React components in your docs page like Chakra UI's docs. Live means that you can, for instance, click the rendered button and the code is editable: if you change the inner text of the button, the preview reflects the change.
By taking the advantage of the extensibility of MDX, we can (kind of) easily accomplish the functionality which looks hard to implement.
Notation is like this:
```tsx live
<>
<Button variant="solid">Solid</Button>
<Button variant="subtle">Solid</Button>
<Button variant="ghost">Solid</Button>
</>
```
And the rendered page is:
We want a live preview (and a code editor) to be shown if there is a live
argument, while a normal code block should be shown if no argument passed.
In this article, I assume you already have a statically-generated site powered by a unified
pipeline. Take a look at Gatsby or Next.js starters if not.
Add remark-mdx-code-meta
plugin
Let's get started from customizing unified
pipeline to support the live
argument after the triple backtick (code fence) and the name of language. Install remark-mdx-code-meta to do so.
pnpm add remark-mdx-code-meta
/** @type {import('@mdx-js/mdx').CompileOptions} */
const mdxOptions = {
remarkPlugins: [remarkMdxCodeMeta, ...otherPlugins],
rehypePlugins: [...otherPlugins],
};
This plugin enables the support for ```language key=value
notation. The key-value pair is passed to the custom Pre
component we'll make in the next section.
Custom Pre
component
MDX allows us to specify custom components dedicated to each HTML tag by passing an object like { image: Image, pre: Pre }
. A code fence is transformed into <pre><code>Your code here</code></pre>
so we want to pass a custom component to add the live-preview feature.
The following component examines the content of a pre
tag to decide whether it's a code block because pre
can be used for other purposes. The role for rendering a preview and highlighted code should be delegated to CodeBlock
which is implemented next.
import React from "react";
// We'll create this later
import CodeBlock from "@/components/MdxComponents/CodeBlock";
type Props = {
live?: boolean;
children?: React.ReactNode;
};
export default function Pre({ live, children, ...props }: Props) {
if (React.isValidElement(children) && children.type === "code") {
return (
<div {...props}>
<CodeBlock live={live} {...children.props} />
</div>
);
}
return <pre {...props}>{children}</pre>;
}
export const mdxComponents: MDXComponents = {
pre: Pre,
} as const;
CodeBlock
component
We depend on react-live for preview and editor.
import React from "react";
import { LiveProvider, LiveError, LivePreview, LiveEditor } from "react-live";
import { type Language } from "prism-react-renderer";
import theme from "prism-react-renderer/themes/vsDark";
import Button from "@/components/Button";
export default function CodeBlock({ children, className, live }: Props) {
const language = className.replace(/language-/, "") as Language;
const code = children.replace(/\n$/, "");
const codeBlock = <code>Render Non-interactive code here...</code>;
if (live) {
return (
<LiveProvider code={code} scope={{ Button }}>
<LivePreview />
<LiveError />
<LiveEditor code={code} language={language} theme={theme} />
</LiveProvider>
);
}
return codeBlock;
}
There is nothing difficult here thanks to the simple API, but some things to consider:
- The newline (
\n
) at the end must be trimmed otherwise a blank line is shown in the editor. - Every component you want to render as a preview must be passed to the
scope
prop. - For non-live
codeBlock
, you may want to render it byprism-react-renderer
which is working also under theLiveEditor
. I'm not sure what is the best way to share the style and theme between them but do so anyhow. LivePreview
is not server-rendered so a layout shift occurs, thoughLiveEditor
seems to support SSR. You should refer to Docusaurus Live Codeblock theme for more solid implementation including fallback on the server. Also, see the issue about SSR
Summary
The amount of code is not much but it took me a while for finding the correct way to achieve this feature. I like the docs of MUI and Mantine don't cause any layout shift but their implementation is more complex so I give up for now.