# LLMRender [![llmrender](https://img.shields.io/npm/v/llmrender?label=llmrender&color=greenlime)](https://www.npmjs.com/package/llmrender) [![tests](https://github.com/franciscop/llmrender/workflows/tests/badge.svg)](https://github.com/franciscop/llmrender/actions) [![gzip size](https://img.badgesize.io/franciscop/llmrender/master/index.min.js.svg?label=gzip&logo=&compression=gzip)](https://github.com/franciscop/llmrender/blob/master/index.min.js) [![dependencies](https://img.shields.io/badge/dependencies-0-limegreen.svg)](https://github.com/franciscop/llmrender/blob/master/package.json) A React Markdown renderer packed with features for all your LLM output: ```jsx import "llmrender/themes/default.css"; import Markdown from "llmrender"; export default function BlogPost({ text }) { return {text}; } ``` - **Zero dependencies** and a fraction of the size of remark/rehype or markdown-it. - **Syntax highlighting**: 30+ languages built-in and multiple themes. - **Latex rendering**: transforms Mathematics into browser-native MathML. - **GitHub Flavored Markdown**: tables, task lists, strikethrough, callouts, auto-links. - **Easy styles**: renders in a `
` so Tailwind, Styled Components, CSS, all work. Traditional Markdown renderers (and their officially recommended plugins) are much bigger ([see methodology](#how-were-the-sizes-measured)): | Package | Base | Math | Highlight | Sanitize | Full Size (gzip) | |:------------------------|---------:|---------:|----------:|---------:|-----------------:| | **LLMRender** | **9 KB** | built-in | built-in | built-in | **12 KB** | | `react-markdown@10.1.0` | 33.3 KB | +74.6 KB | +508 KB | built-in | 340 KB | | `marked@18.0.3` | 12.1 KB | +74.6 KB | +298 KB | +8.7 KB | 403 KB | | `markdown-it@14.1.1` | 43.3 KB | +74.6 KB | +298 KB | +8.7 KB | 436 KB | ## Getting Started First create a React project, and install the library: ```sh npm i llmrender ``` Pass your Markdown string as `children`: ```jsx import "llmrender/themes/default.css"; // Import a theme as well import Markdown from "llmrender"; // This would normally come from the DB, LLM output, API, or a file read const text = ` # Hello world This is **bold**, _italic_, ~~strikethrough~~, and [a link](https://example.com). \`\`\`js const greet = (name) => \`Hello, \${name}!\`; \`\`\``; // Actually render it {text} ``` Every HTML attribute you'd put on a `
` (`className`, `style`, `id`, `data-*`) passes through: ```jsx {content} ``` LLMRender ships four ready-to-use themes. Import one to get syntax highlighting, callout styles, and table styling with a single line: ```js import "llmrender/themes/default.css"; // GitHub light (default) import "llmrender/themes/dark.css"; // GitHub dark import "llmrender/themes/adaptive.css"; // follows prefers-color-scheme import "llmrender/themes/contrast.css"; // high contrast dark (WCAG AAA) ``` ## \ ```ts > {markdownString} ``` | Prop | Type | Default | Description | |---|---|---|---| | `children` | `string` | required | Markdown source to render | | `highlight` | `HighlightFn \| boolean` | `true` | Syntax highlighter: `false` disables, `true` uses built-in | | `math` | `MathFn \| boolean` | `true` | Math renderer: `false` disables, `true` uses built-in | | `rawHtml` | `RawHtml \| boolean` | `false` | Allow HTML tags in Markdown source: see [Security](#security) | | `...props` | `HTMLAttributes` | - | Forwarded to the wrapping `
` | ### `children` The Markdown string to render. Partial strings are safe: LLMRender handles incomplete fences, unclosed math blocks, and mid-word formatting gracefully, so you can pass tokens as they stream in: ```jsx const [text, setText] = useState(""); // Re-render on every chunk no buffering needed return {text}; ``` ### `highlight` > [!TIP] > We're under active development, so if you find a bug with the default syntax highlighting we suggest [you file a ticket](https://github.com/franciscop/llmrender/issues). Called for every fenced code block. Return a `ReactNode` or `ReactNode[]` to replace the entire code block. Pass `false` to avoid highlighting. ```ts type HighlightFn = (code: string, lang: string) => ReactNode | ReactNode[]; ``` The built-in highlighter covers 30+ languages with `` tokens and CSS variable colors. Swap it for any library: ```jsx import Prism from "prismjs"; import "prismjs/themes/prism.css"; function highlight(code, lang) { const grammar = lang && Prism.languages[lang]; const highlighted = grammar ? Prism.highlight(code, grammar, lang) : code; return (
      
    
); } {content} ``` Or Shiki (initialize synchronously before rendering): ```jsx import { createHighlighterSync } from "shiki/sync"; const hl = createHighlighterSync({ themes: ["github-light"], langs: ["js", "ts", "python"] }); function highlight(code, lang) { const html = hl.codeToHtml(code, { lang, theme: "github-light" }); return
; } {content} ``` > [!NOTE] > The `highlight` function must be synchronous. Shiki's async `createHighlighter` won't work directly; use `createHighlighterSync` and pre-load the languages you need. All colors in the built-in theme are CSS variables you can override without touching the rest: ```css :root { --llmrender-keyword: #d73a49; --llmrender-string: #032f62; --llmrender-function: #6f42c1; --llmrender-pre-bg: #f8f8f8; } ``` | Variable | Default (light) | Controls | |---|---|---| | `--llmrender-keyword` | `#cf222e` | `if`, `const`, `return`, … | | `--llmrender-string` | `#0969da` | string literals | | `--llmrender-comment` | `#6e7781` | comments | | `--llmrender-number` | `#0550ae` | numeric literals | | `--llmrender-function` | `#8250df` | function calls | | `--llmrender-type` | `#953800` | class / type names | | `--llmrender-operator` | `#24292f` | `=`, `=>`, `+`, … | | `--llmrender-pre-bg` | `#f6f8fa` | code block background | | `--llmrender-pre-color` | `#24292f` | code block text | | `--llmrender-inline-bg` | `#f6f8fa` | inline code background | | `--llmrender-table-border` | `#d0d7de` | table borders | ### `math` > [!TIP] > We're under active development, so if you find a bug with the default math rendering we suggest [you file a ticket](https://github.com/franciscop/llmrender/issues). Called for each math expression. `tex` is the raw LaTeX string, `block` is `true` for display `$$…$$` and `false` for inline `$…$`. Return a `ReactNode` to replace it: ```ts type MathFn = (tex: string, block: boolean) => ReactNode; ``` By default LLMRender parses LaTeX and outputs browser-native **MathML**: no extra dependencies, no fonts to load, crisp at any zoom level, and accessible to screen readers. It covers fractions, roots, sums, integrals, Greek letters, matrices, accents, and most constructs LLMs commonly emit. Pass `math={false}` to treat `$…$` and `$$…$$` as plain text, useful if your content doesn't contain math and you want to avoid false positives on dollar signs. ```jsx {content} ``` To use KaTeX or MathJax instead of the built-in renderer, pass a function. For KaTeX: ```jsx import Markdown from "llmrender"; import katex from "katex"; import "katex/dist/katex.min.css"; function renderMath(tex, block) { const html = katex.renderToString(tex, { displayMode: block, throwOnError: false }); return ; } {content} ``` The function can also add a wrapper for copy-on-click or a tooltip showing the raw LaTeX: ```jsx function renderMath(tex, block) { return ( {/* your renderer here */} ); } ``` ### `rawHtml` By default, HTML tags written inside Markdown source are escaped and appear as literal text. This keeps LLMRender safe with untrusted content out of the box. The `rawHtml` prop opts in to rendering them. **`rawHtml={true}`** renders HTML through a built-in allowlist of known-safe tags. Dangerous tags (`script`, `style`, `iframe`, `form`, …) and attributes (`on*` event handlers, `style`, `srcdoc`) are always stripped regardless. Useful when your content comes from a trusted source and includes intentional HTML: ```jsx {content} ``` ```md This paragraph has highlighted text and a
spoilerhidden content
. ``` **`rawHtml={{ tag: ["attr", …] }}`** is the strict form: only the tags and attributes you list are rendered, everything else is dropped as text. Use this for untrusted sources where you know exactly what HTML the content may contain: ```jsx // Only allow (no attributes) and {content} ``` The exported `allowTags` constant is the full object used by `rawHtml={true}`. Spread it to extend rather than replace the defaults: ```jsx import Markdown, { allowTags } from "llmrender"; // Default allowlist, but allow the "open" attribute on
{content} ``` See [Security](#security) for the complete allowlist and URL sanitization rules. #### HTML across multiple lines HTML elements must open and close on the same line: ```md This works, and so does this. ``` `
` is the one exception, because collapsible sections are too useful in a README to lose. It may span as many lines as you like, an optional `` becomes the clickable label, and everything between the tags is parsed as Markdown: ```md
Show the config example Any **Markdown** works in here: - lists - tables - fenced code
``` Add `open` to expand it by default. Every other multi-line element still breaks apart: ```md
This does not work.
``` ## Syntax ### Headings ```md # H1 ## H2 ### H3 #### H4 ``` Headings render with an `id` derived from their text (e.g. `## Getting Started` → `id="getting-started"`), so anchor links like `[jump](#getting-started)` work out of the box. ### Inline formatting ```md **bold**, _italic_, ~~strikethrough~~, and `inline code` can be mixed **_freely_**. [link text](https://example.com) [link with title](https://example.com "Opens example.com") ![alt text](https://example.com/image.png) https://auto-linked.com ``` ### Ruby annotations Ruby text (furigana / phonetic glosses) follows the syntax proposed in the [CommonMark discussion](https://talk.commonmark.org/t/proper-ruby-text-rb-syntax-support-in-markdown/2279/8): ```md [漢字]{かんじ} [漢字]{かんじ "kanji"} ``` The first renders `漢字かんじ`. The second adds a `title` attribute to ``, useful as a tooltip or machine-readable gloss: ```html 漢字かんじ ``` ### Blockquotes ```md > This is a blockquote. > It can span multiple lines. > > > And nest. ``` ### Callouts GitHub-style callouts inside blockquotes: ```md > [!NOTE] > Highlights information users should know. > [!TIP] > Optional information to help a user be more successful. > [!IMPORTANT] > Crucial information necessary for users to succeed. > [!WARNING] > Critical content demanding immediate user attention. > [!CAUTION] > Negative potential consequences of an action. ``` Each renders as `
` with a `

` header. The included themes style them out of the box; you can also target them directly: ```css .callout-note { border-left: 4px solid #0969da; background: #ddf4ff; } .callout-warning { border-left: 4px solid #d1242f; background: #ffebe9; } ``` ### Lists ```md - Apples - Oranges - Navel - Blood - Bananas 1. Preheat oven to 200°C 2. Mix the dry ingredients 1. Flour 2. Baking powder 3. Fold in the wet ingredients - [x] Design the API - [x] Write tests - [ ] Publish to npm ``` Task items render with a disabled ``. ### Code blocks ````md ```ts async function fetchUser(id: string): Promise { const res = await fetch(`/api/users/${id}`); if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); } ``` ```` Supported languages include: `js`/`ts`/`jsx`/`tsx`, `py`, `go`, `rust`, `java`, `c`/`cpp`/`cs`, `html`, `css`/`scss`, `json`, `bash`/`sh`, `sql`, `yaml`, `svelte`, `vue`, and more. ### Tables ```md | Language | Paradigm | Typing | First appeared | |:-----------|:------------:|--------:|---------------:| | TypeScript | Multi | Static | 2012 | | Python | Multi | Dynamic | 1991 | | Haskell | Functional | Static | 1990 | ``` Column alignment: `:---` left, `:---:` center, `---:` right. Inline markup and escaped pipes (`\|`) work inside cells. ### Math Inline with `$…$`, display with `$$…$$`: ```md Einstein's mass-energy equivalence $E = mc^2$ is one of the most famous equations in physics. The quadratic formula gives the roots of $ax^2 + bx + c = 0$: $$x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}$$ A Gaussian integral: $$\int_{-\infty}^{\infty} e^{-x^2}\,dx = \sqrt{\pi}$$ And the sum of the first $n$ natural numbers: $$\sum_{k=1}^{n} k = \frac{n(n+1)}{2}$$ ``` ### Horizontal rule ```md --- ``` ## Examples ### Tailwind Typography ```jsx {content} ``` ### Styled Components ```jsx import styled from "styled-components"; import Markdown from "llmrender"; const Article = styled(Markdown)` font-family: Georgia, serif; line-height: 1.7; h1, h2, h3 { font-weight: 700; margin-top: 1.5em; } a { color: #0969da; text-decoration: underline; } pre { background: #f6f8fa; padding: 1em; border-radius: 6px; overflow-x: auto; } blockquote { border-left: 4px solid #d0d7de; padding-left: 1em; color: #57606a; } `; export default function Post({ content }) { return

{content}
; } ``` ### Prism.js > [!TIP] > We're under active development, so if you find a bug with the default syntax highlighter we suggest [you file a ticket](https://github.com/franciscop/llmrender/issues). To replace the built-in highlighter with Prism: ```bash npm i prismjs ``` ```jsx import Markdown from "llmrender"; import Prism from "prismjs"; import "prismjs/themes/prism.css"; import "prismjs/components/prism-typescript"; import "prismjs/components/prism-python"; function highlight(code, lang) { const grammar = lang && Prism.languages[lang]; const highlighted = grammar ? Prism.highlight(code, grammar, lang) : code; return (
      
    
); } {content} ``` ### KaTeX > [!TIP] > We're under active development, so if you find a bug with the default math renderer we suggest [you file a ticket](https://github.com/franciscop/llmrender/issues). For full LaTeX beyond the built-in renderer, you can add the full Katex: ```bash npm i katex ``` ```jsx import Markdown from "llmrender"; import katex from "katex"; import "katex/dist/katex.min.css"; function renderMath(tex, block) { const html = katex.renderToString(tex, { displayMode: block, throwOnError: false }); return ; } {content} ``` ### Mermaid diagrams Intercept ` ```mermaid ` blocks via the `highlight` prop: ```jsx import Markdown, { highlightCode } from "llmrender"; import mermaid from "mermaid"; import { useEffect, useRef } from "react"; mermaid.initialize({ startOnLoad: false }); function Diagram({ code }) { const ref = useRef(null); useEffect(() => { if (ref.current) mermaid.run({ nodes: [ref.current] }); }, [code]); return
{code}
; } function highlight(code, lang) { if (lang === "mermaid") return []; return highlightCode(code, lang); } {content} ``` ````md ```mermaid graph TD A[Start] --> B{Decision} B -->|Yes| C[Do it] B -->|No| D[Skip] ``` ```` ### Streaming LLM output Render tokens as they arrive. Pass the partial string and LLMRender handles incomplete Markdown gracefully: ```jsx import { useChat } from "@ai-sdk/react"; import Markdown from "llmrender"; export default function Chat() { const { messages, input, handleInputChange, handleSubmit } = useChat(); return (
{messages.map((m) => (
{m.role === "user" ? (

{m.content}

) : ( {m.content} )}
))}
); } ``` ### Chat messages Render a conversation where each assistant message is Markdown. User messages are plain text: ```jsx import Markdown from "llmrender"; const messages = [ { role: "user", content: "What is the quadratic formula?" }, { role: "assistant", content: "The quadratic formula solves $ax^2 + bx + c = 0$:\n\n$$x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}$$\n\nwhere $a \\neq 0$.", }, ]; export default function ChatThread() { return (
{messages.map((m, i) => (
{m.role === "user" ? (

{m.content}

) : ( {m.content} )}
))}
); } ``` For streaming output as the AI types, see [Streaming LLM output](#streaming-llm-output). ### Next.js LLMRender works as a **React Server Component** for static content, no `"use client"` needed: ```tsx // app/blog/[slug]/page.tsx import Markdown from "llmrender"; import "llmrender/themes/default.css"; export default async function PostPage({ params }: { params: { slug: string } }) { const post = await db.post.findUnique({ where: { slug: params.slug } }); return {post.content}; } ``` Add `"use client"` only when you need interactivity in the same component (streaming, copy buttons, live editor): ```tsx "use client"; import { useChat } from "@ai-sdk/react"; import Markdown from "llmrender"; export default function Chat() { const { messages, input, handleInputChange, handleSubmit } = useChat(); // ... } ``` ### Live editor ```jsx import { useState } from "react"; import Markdown from "llmrender"; export default function Editor() { const [text, setText] = useState("# Hello\n\nStart typing..."); return (