Skip to main content
Custom components give you full control over how markdown is rendered in React. This guide covers advanced patterns for building interactive, accessible, and feature-rich markdown renderers.

Component architecture

The Markdown component uses a two-level rendering system:

Creating reusable component sets

Define component sets for consistent styling across your application:
import type { MarkdownComponents } from "react-markdown-parser";

// Define your component library
export const blogComponents: Partial<MarkdownComponents> = {
  Heading: ({ level, children }) => {
    const sizes = {
      1: "text-4xl font-bold mt-8 mb-4",
      2: "text-3xl font-semibold mt-6 mb-3",
      3: "text-2xl font-semibold mt-4 mb-2",
      4: "text-xl font-medium mt-3 mb-2",
      5: "text-lg font-medium mt-2 mb-1",
      6: "text-base font-medium mt-2 mb-1",
    };
    const Heading = `h${level}` as const;
    return <Heading className={sizes[level]}>{children}</Heading>;
  },
  
  Paragraph: ({ children }) => (
    <p className="my-4 text-gray-800 leading-7">{children}</p>
  ),
  
  // ... more components
};

// Use across your app
import { Markdown } from "react-markdown-parser";
import { blogComponents } from "./components";

export function BlogPost({ content }: { content: string }) {
  return <Markdown content={content} components={blogComponents} />;
}

Interactive code blocks

Add copy buttons, line numbers, and syntax highlighting:
1

Install dependencies

npm install shiki react-copy-to-clipboard
2

Create CodeBlock component

"use client";

import { useState } from "react";
import { CopyToClipboard } from "react-copy-to-clipboard";
import { codeToHtml } from "shiki";

export function InteractiveCodeBlock({ 
  content, 
  info 
}: { 
  content: string; 
  info?: string; 
}) {
  const [copied, setCopied] = useState(false);
  const [html, setHtml] = useState<string>("");
  
  // Highlight code on mount
  useEffect(() => {
    codeToHtml(content, {
      lang: info || "text",
      theme: "github-dark",
    }).then(setHtml);
  }, [content, info]);
  
  const handleCopy = () => {
    setCopied(true);
    setTimeout(() => setCopied(false), 2000);
  };
  
  return (
    <div className="relative group">
      <div className="flex items-center justify-between bg-gray-800 px-4 py-2 rounded-t-md">
        <span className="text-sm text-gray-300">{info || "code"}</span>
        <CopyToClipboard text={content} onCopy={handleCopy}>
          <button className="text-sm text-gray-300 hover:text-white">
            {copied ? "Copied!" : "Copy"}
          </button>
        </CopyToClipboard>
      </div>
      <div 
        className="overflow-x-auto rounded-b-md"
        dangerouslySetInnerHTML={{ __html: html }}
      />
    </div>
  );
}
3

Use in Markdown component

import { Markdown } from "react-markdown-parser";
import { InteractiveCodeBlock } from "./InteractiveCodeBlock";

export function Article({ content }: { content: string }) {
  return (
    <Markdown
      content={content}
      components={{
        CodeBlock: InteractiveCodeBlock,
      }}
    />
  );
}

Enhanced table components

Add sorting, filtering, and responsive behavior:
"use client";

import { useState } from "react";
import type { ReactNode } from "react";

type TableProps = {
  head: {
    cells: { children: ReactNode; align?: "left" | "right" | "center" }[];
  };
  body: {
    rows: {
      cells: { children: ReactNode; align?: "left" | "right" | "center" }[];
    }[];
  };
};

export function SortableTable({ head, body }: TableProps) {
  const [sortColumn, setSortColumn] = useState<number | null>(null);
  const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
  
  const handleSort = (columnIndex: number) => {
    if (sortColumn === columnIndex) {
      setSortDirection(sortDirection === "asc" ? "desc" : "asc");
    } else {
      setSortColumn(columnIndex);
      setSortDirection("asc");
    }
  };
  
  const sortedRows = [...body.rows].sort((a, b) => {
    if (sortColumn === null) return 0;
    
    const aCell = a.cells[sortColumn];
    const bCell = b.cells[sortColumn];
    
    // Simple text comparison (you can enhance this)
    const aText = String(aCell?.children);
    const bText = String(bCell?.children);
    
    const comparison = aText.localeCompare(bText);
    return sortDirection === "asc" ? comparison : -comparison;
  });
  
  return (
    <div className="overflow-x-auto">
      <table className="min-w-full border-collapse">
        <thead className="bg-gray-50 border-b-2">
          <tr>
            {head.cells.map((cell, index) => (
              <th
                key={index}
                align={cell.align}
                className="px-4 py-2 text-left cursor-pointer hover:bg-gray-100"
                onClick={() => handleSort(index)}
              >
                <div className="flex items-center gap-2">
                  {cell.children}
                  {sortColumn === index && (
                    <span>{sortDirection === "asc" ? "↑" : "↓"}</span>
                  )}
                </div>
              </th>
            ))}
          </tr>
        </thead>
        <tbody>
          {sortedRows.map((row, rowIndex) => (
            <tr key={rowIndex} className="border-b hover:bg-gray-50">
              {row.cells.map((cell, cellIndex) => (
                <td
                  key={cellIndex}
                  align={cell.align}
                  className="px-4 py-2"
                >
                  {cell.children}
                </td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}
Implement proper accessibility features:
import type { ReactNode } from "react";

export function AccessibleLink({
  href,
  title,
  children,
}: {
  href: string;
  title?: string;
  children: ReactNode;
}) {
  const isExternal = href.startsWith("http") && !href.includes(window.location.hostname);
  const isHash = href.startsWith("#");
  const isEmail = href.startsWith("mailto:");
  const isPhone = href.startsWith("tel:");
  
  // Build aria-label for screen readers
  const ariaLabel = 
    isExternal ? `${children} (opens in new tab)` :
    isEmail ? `Send email to ${children}` :
    isPhone ? `Call ${children}` :
    undefined;
  
  return (
    <a
      href={href}
      title={title}
      aria-label={ariaLabel}
      target={isExternal ? "_blank" : undefined}
      rel={isExternal ? "noopener noreferrer" : undefined}
      className="text-blue-600 hover:text-blue-800 underline focus:outline-none focus:ring-2 focus:ring-blue-500 rounded"
    >
      {children}
      {isExternal && (
        <svg
          className="inline-block w-4 h-4 ml-1"
          fill="none"
          stroke="currentColor"
          viewBox="0 0 24 24"
          aria-hidden="true"
        >
          <path
            strokeLinecap="round"
            strokeLinejoin="round"
            strokeWidth={2}
            d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
          />
        </svg>
      )}
    </a>
  );
}

Lazy-loaded images

Optimize image loading with Next.js Image or lazy loading:
import Image from "next/image";

export function OptimizedImage({
  href,
  alt,
  title,
}: {
  href: string;
  alt: string;
  title?: string;
}) {
  return (
    <div className="my-6">
      <Image
        src={href}
        alt={alt}
        title={title}
        width={800}
        height={600}
        className="rounded-lg"
        loading="lazy"
      />
    </div>
  );
}

Collapsible blockquotes

Create expandable callouts:
"use client";

import { useState } from "react";
import type { ReactNode } from "react";

export function CollapsibleBlockquote({ children }: { children: ReactNode }) {
  const [isExpanded, setIsExpanded] = useState(true);
  
  return (
    <blockquote className="my-4 border-l-4 border-blue-500 bg-blue-50 rounded-r-lg">
      <button
        onClick={() => setIsExpanded(!isExpanded)}
        className="w-full px-4 py-2 text-left font-medium flex items-center justify-between hover:bg-blue-100"
      >
        <span>Note</span>
        <svg
          className={`w-5 h-5 transition-transform ${isExpanded ? "rotate-180" : ""}`}
          fill="none"
          stroke="currentColor"
          viewBox="0 0 24 24"
        >
          <path
            strokeLinecap="round"
            strokeLinejoin="round"
            strokeWidth={2}
            d="M19 9l-7 7-7-7"
          />
        </svg>
      </button>
      {isExpanded && (
        <div className="px-4 pb-4">{children}</div>
      )}
    </blockquote>
  );
}
Add auto-generated anchor links to headings:
import { ReactNode } from "react";

function slugify(text: string): string {
  return text
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/(^-|-$)/g, "");
}

export function HeadingWithAnchor({
  level,
  children,
}: {
  level: 1 | 2 | 3 | 4 | 5 | 6;
  children: ReactNode;
}) {
  const Heading = `h${level}` as const;
  const text = String(children);
  const id = slugify(text);
  
  return (
    <Heading id={id} className="group relative">
      {children}
      <a
        href={`#${id}`}
        className="ml-2 text-gray-400 opacity-0 group-hover:opacity-100 transition-opacity"
        aria-label={`Link to ${text}`}
      >
        #
      </a>
    </Heading>
  );
}

Context-aware components

Access surrounding context in custom components:
import { createContext, useContext, type ReactNode } from "react";
import { Markdown } from "react-markdown-parser";

const ArticleContext = createContext<{
  author?: string;
  publishDate?: string;
}>({});

export function ArticleProvider({
  author,
  publishDate,
  children,
}: {
  author: string;
  publishDate: string;
  children: ReactNode;
}) {
  return (
    <ArticleContext.Provider value={{ author, publishDate }}>
      {children}
    </ArticleContext.Provider>
  );
}

function ContextAwareLink({
  href,
  title,
  children,
}: {
  href: string;
  title?: string;
  children: ReactNode;
}) {
  const { author } = useContext(ArticleContext);
  
  // Add author info to internal links
  const enhancedTitle = href.startsWith("/") && author
    ? `${title || ""} by ${author}`.trim()
    : title;
  
  return (
    <a href={href} title={enhancedTitle} className="text-blue-600">
      {children}
    </a>
  );
}

// Usage
export function Article({ content, author, publishDate }: {
  content: string;
  author: string;
  publishDate: string;
}) {
  return (
    <ArticleProvider author={author} publishDate={publishDate}>
      <Markdown
        content={content}
        components={{
          Link: ContextAwareLink,
        }}
      />
    </ArticleProvider>
  );
}

Performance optimization

Memoize expensive component operations:
import { memo } from "react";
import type { ReactNode } from "react";

// Memoize components that render frequently
export const MemoizedParagraph = memo(function Paragraph({ 
  children 
}: { 
  children: ReactNode 
}) {
  return <p className="my-4">{children}</p>;
});

export const MemoizedCodeBlock = memo(function CodeBlock({
  content,
  info,
}: {
  content: string;
  info?: string;
}) {
  // Expensive syntax highlighting
  const highlighted = useMemo(
    () => highlightCode(content, info),
    [content, info]
  );
  
  return <pre dangerouslySetInnerHTML={{ __html: highlighted }} />;
});
Memoize components that perform expensive operations like syntax highlighting or complex rendering logic.

Component composition

Combine multiple component sets:
import type { MarkdownComponents } from "react-markdown-parser";

const baseComponents: Partial<MarkdownComponents> = {
  Heading: ({ level, children }) => { /* ... */ },
  Paragraph: ({ children }) => { /* ... */ },
};

const interactiveComponents: Partial<MarkdownComponents> = {
  CodeBlock: InteractiveCodeBlock,
  Table: SortableTable,
};

const accessibilityComponents: Partial<MarkdownComponents> = {
  Link: AccessibleLink,
  Image: OptimizedImage,
};

// Merge component sets
export const fullComponents: Partial<MarkdownComponents> = {
  ...baseComponents,
  ...interactiveComponents,
  ...accessibilityComponents,
};

Testing custom components

Test your components with the Markdown component:
import { render, screen } from "@testing-library/react";
import { Markdown } from "react-markdown-parser";
import { InteractiveCodeBlock } from "./InteractiveCodeBlock";

test("renders code block with copy button", () => {
  render(
    <Markdown
      content="```js\nconsole.log('test');\n```"
      components={{ CodeBlock: InteractiveCodeBlock }}
    />
  );
  
  expect(screen.getByText("Copy")).toBeInTheDocument();
  expect(screen.getByText(/console\.log/)).toBeInTheDocument();
});

Next steps

API reference

Complete component API documentation

Custom renderers

Browse all available component overrides

Build docs developers (and LLMs) love