Recently, I decided to add a code snippets section to my website. I realized two key features would greatly improve the user experience:
- Better syntax highlighting
- A copy code block button
Let's walk through how to implement these features step by step.
Implementing Syntax Highlighting
First, let's look at our current setup. We're using MDX with next-mdx-remote to render our content. Here's the basic structure of our MDX processing:
const source = fs.readFileSync(contentPath); const { data, content } = matter(source); const mdxSource = await serialize(content, { mdxOptions: { format: "mdx", remarkPlugins: [remarkGfm], rehypePlugins: [ rehypeSlug, [rehypeAutolinkHeadings, { properties: { className: ["anchor"] } }], [rehypePrettyCode, shikiOptions], rehypeAccessibleEmojis, ], }, scope: data, });
This code reads the content file, processes it with various plugins, and prepares it for rendering. The key plugin for our syntax highlighting is rehypePrettyCode. rehype-pretty-code is a Rehype plugin powered by the shiki syntax highlighter that provides beautiful code blocks for Markdown or MDX. It works on both the server at build-time (avoiding runtime syntax highlighting) and on the client for dynamic highlighting.
To use a specific theme, we can configure shikiOptions like this:
const shikiOptions = { theme: "catppuccin-latte", };
I'm using the "catppuccin-latte" theme, but you can explore more themes at https://shiki.style/themes.
Adding copy button to codeblocks
Now that we have syntax highlighting working, let's add a copy button to our code blocks. Instead of creating a new custom component for each code block in our MDX files, we'll modify how the code blocks are rendered on the UI. Here's how a code block typically looks in the DOM:
We'll create a custom Figure component that will be used by MDXRemote to render these elements. This approach doesn't require importing the component in each MDX file.
const Figure = (props) => { const { children, ...rest } = props; const figureRef = useRef(null); const isReactElement = (node) => { return React.isValidElement(node); }; const childArray = React.Children.toArray(children); const figCaptionChild = childArray.find( (node) => isReactElement(node) && node.type === "figcaption" ); const preChild = childArray.find( (node) => isReactElement(node) && node.type === "pre" ); const handleCopyClick = async () => { const codeBlock = figureRef.current; if (codeBlock) { const codeNode = codeBlock.querySelector("code"); if (codeNode) { navigator.clipboard.writeText(codeNode.textContent || ""); } } }; return ( <figure ref={figureRef} {...rest}> {figCaptionChild && React.isValidElement(figCaptionChild) ? ( <FigureCaption {...figCaptionChild.props} handleCopyClick={handleCopyClick} /> ) : null} {preChild} </figure> ); };
This component does the following:
- Finds the figcaption and pre elements among its children
- Implements a handleCopyClick function to copy the code content
- Renders a custom FigureCaption component with the copy button
const FigureCaption = ({ children, handleCopyClick, ...rest }) => { const [isCopied, setIsCopied] = useState(false); const onClick = () => { setIsCopied(true); handleCopyClick(); setTimeout(() => setIsCopied(false), 1000); }; return ( <figcaption {...rest} className="flex items-center justify-between"> {children} <button type="button" onClick={onClick}> {isCopied ? <CopiedSVG /> : <CopySVG />} </button> </figcaption> ); };
Usages
import { MDXRemote } from "next-mdx-remote"; import Figure from "./Figure"; const MDXComponents = { // ...other components figure: Figure, }; const RenderContent = () => { return <MDXRemote {...snippet.source} components={MDXComponents} />; };
Finally rendered codeblock will look like this.
Top comments (0)