Rendering HTML Details/Summary in MDX with Custom Components
When working with MDX in Next.js projects, you might encounter a common challenge: HTML <details> and <summary> elements don't render properly. This guide explains the problem and provides a complete solution using custom React components.
Table of Contents
- The Problem
- Why rehype-raw Doesn't Work
- The Solution
- Why First Child as Summary?
- Handling the open Attribute
- Extending the Solution
- Summary
The Problem
MDX compiles content into JSX, but it doesn't natively support raw HTML elements like <details> and <summary>. When you try to use these elements in your MDX content:
html<details>
<summary>Click to expand</summary>
This content is hidden by default.
</details>
You'll likely encounter one of these issues:
- Compilation Error: MDX throws an error because it can't process raw HTML nodes
- No Rendering: The elements are ignored or rendered as plain text
- rehype-raw Conflict: Using
rehype-rawplugin causes conflicts with MDX JSX nodes
The error message typically looks like:
Cannot compile `mdxJsxTextElement` node. It looks like you are using MDX nodes
with `hast-util-raw` (or `rehype-raw`).
Why rehype-raw Doesn't Work
You might think adding rehype-raw to your MDX configuration would solve this:
typescript// ❌ This doesn't work with MDX
import rehypeRaw from "rehype-raw";
const options = {
mdxOptions: {
rehypePlugins: [rehypeRaw], // Causes compilation errors
},
};
The rehype-raw plugin is designed for regular markdown, not MDX. MDX has its own JSX handling that conflicts with raw HTML processing.
The Solution
The solution involves three parts:
- Create custom React components to handle the rendering
- Transform HTML tags to JSX components before MDX compilation
- Register components in the MDX component mapping
Step 1: Create Custom Components
Create Details and Summary components. Here's a basic implementation:
tsx// components/mdx-components/Details.tsx
"use client";
import { useState, Children, isValidElement, type ReactNode } from "react";
interface DetailsProps {
children?: ReactNode;
open?: boolean;
}
interface SummaryProps {
children?: ReactNode;
}
/**
* Renders HTML `<summary>` element.
* Content is extracted by DetailsComponent for the header.
*/
export function SummaryComponent({ children }: SummaryProps) {
return <>{children}</>;
}
SummaryComponent.displayName = "SummaryComponent";
/**
* Renders HTML `<details>` element as a collapsible component.
* Automatically extracts the first child (summary) as the header.
*/
export function DetailsComponent({ children, open = false }: DetailsProps) {
const [isOpen, setIsOpen] = useState(open);
// Separate summary from content
// In HTML spec, the first child of <details> is always <summary>
const childArray = Children.toArray(children);
let summaryContent: ReactNode = "Details";
const contentChildren: ReactNode[] = [];
childArray.forEach((child, index) => {
// First child is always the summary
if (index === 0 && isValidElement(child)) {
const props = child.props as { children?: ReactNode };
summaryContent = props.children;
} else {
contentChildren.push(child);
}
});
return (
<div className="details-container">
<button
type="button"
className="details-summary"
onClick={() => setIsOpen(!isOpen)}
aria-expanded={isOpen}
>
<span className="details-icon">{isOpen ? "▼" : "▶"}</span>
<span className="details-title">{summaryContent}</span>
</button>
{isOpen && (
<div className="details-content">
{contentChildren}
</div>
)}
</div>
);
}
DetailsComponent.displayName = "DetailsComponent";
Add some basic styles:
css/* styles/details.css */
.details-container {
margin: 1rem 0;
border: 1px solid #e0e0e0;
border-radius: 8px;
overflow: hidden;
}
.details-summary {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 1rem;
background: #f5f5f5;
border: none;
cursor: pointer;
font-size: 1rem;
font-weight: 600;
text-align: left;
}
.details-summary:hover {
background: #ebebeb;
}
.details-icon {
font-size: 0.75rem;
}
.details-content {
padding: 1rem;
}
Step 2: Transform HTML Tags Before Compilation
The key insight is that we need to transform HTML tags before MDX compiles them. We also need to be careful not to transform tags inside code blocks or inline code. Create a context-aware transform function:
tsx// components/mdx.tsx
import { MDXRemote } from "next-mdx-remote/rsc";
import { useMDXComponents } from "../mdx-components";
/**
* Transforms HTML details/summary tags to JSX Details/Summary components
* before MDX compilation since MDX doesn't support raw HTML tags.
* Skips transformation for content inside code blocks and inline code.
*/
function transformHtmlToJsx(source: string): string {
// Regex to match code blocks (```...```) and inline code (`...`)
const codeRegex = /(```[\s\S]*?```|`[^`\n]*`)/g;
// Split by code blocks/inline code while keeping the delimiters
const parts = source.split(codeRegex);
return parts
.map((part) => {
// If this part is a code block or inline code, don't transform it
if (part.match(codeRegex)) {
return part;
}
// Transform HTML tags to JSX components for non-code parts
return part
.replace(/<details(\s|>)/gi, "<Details$1")
.replace(/<\/details>/gi, "</Details>")
.replace(/<summary(\s|>)/gi, "<Summary$1")
.replace(/<\/summary>/gi, "</Summary>");
})
.join("");
}
export function CustomMDX({ source }: { source: string }) {
const components = useMDXComponents({});
const transformedSource = transformHtmlToJsx(source);
return (
<MDXRemote
source={transformedSource}
components={components}
options={{
mdxOptions: {
remarkPlugins: [/* your plugins */],
rehypePlugins: [/* your plugins */],
},
}}
/>
);
}
This refined transformation:
- Identifies code regions using regex to prevent accidental transformation within examples.
- Converts
<details>to<Details>only in prose. - Converts
</details>to</Details>only in prose. - Converts
<summary>to<Summary>only in prose. - Converts
</summary>to</Summary>only in prose.
Step 3: Register Components in MDX
Finally, register the components in your MDX component mapping:
tsx// mdx-components.tsx
import type { MDXComponents } from "mdx/types";
import { DetailsComponent, SummaryComponent } from "./components/mdx-components/Details";
export function useMDXComponents(components: MDXComponents): MDXComponents {
return {
...components,
// Map JSX component names (capitalized)
Details: DetailsComponent,
Summary: SummaryComponent,
// ... other components
};
}
Why First Child as Summary?
In the HTML specification, <details> element's first child should always be <summary>. The component leverages this by:
- Taking the first child's content as the collapsible header
- Putting all remaining children in the collapsible body
This approach is more reliable than trying to identify the Summary component by type checking, which can fail due to how MDX compiles components.
Handling the open Attribute
HTML <details> supports an open attribute to show content by default:
html<details open>
<summary>Already expanded</summary>
This content is visible by default.
</details>
The component handles this with an initial state:
tsxconst [isOpen, setIsOpen] = useState(open);
Extending the Solution
You can extend this pattern for other HTML elements that MDX doesn't handle well by adding more replacements inside the non-code part of your transform function:
typescript// Inside the transformHtmlToJsx .map() callback:
return part
// Details/Summary
.replace(/<details(\s|>)/gi, "<Details$1")
.replace(/<\/details>/gi, "</Details>")
.replace(/<summary(\s|>)/gi, "<Summary$1")
.replace(/<\/summary>/gi, "</Summary>")
// Dialog
.replace(/<dialog(\s|>)/gi, "<Dialog$1")
.replace(/<\/dialog>/gi, "</Dialog>")
// Mark
.replace(/<mark(\s|>)/gi, "<Mark$1")
.replace(/<\/mark>/gi, "</Mark>");
Summary
When MDX doesn't support certain HTML elements:
- Don't use rehype-raw - it conflicts with MDX's JSX processing
- Transform tags before compilation - convert HTML tags to JSX component names using string replacement
- Create custom React components - implement the desired behavior with your preferred UI approach
- Register in component mapping - make components available to MDX
This pattern maintains compatibility with remote or external MDX content while providing full control over how elements render.