🔝

LLMRender npm install llmrender test badge gzip size

A React Markdown renderer packed with features for all your LLM output:

import Markdown from "llmrender";

export default function Post({ content }) {
  return <Markdown className="prose">{content}</Markdown>;
}
  • Zero dependencies and a fraction of the size of remark/rehype or markdown-it.
  • Syntax highlighting — 30+ languages and multiple themes to pick. Can be replaced for Shiki or Prism easily.
  • Math — built-in Math renderer for common Latex. Can be replaced for full KaTeX easily.
  • GitHub Flavored Markdown — tables, task lists, strikethrough, callouts, auto-links.
  • Drop-in styling — renders in a plain <div>. Tailwind, Styled Components, or plain CSS all work.

Getting started

First create a React project, and install the library:

npm i llmrender

Pass your Markdown string as children:

import Markdown from "llmrender";

<Markdown>{`
# Hello world

This is **bold**, _italic_, ~~strikethrough~~, and [a link](https://example.com).

\`\`\`js
const greet = (name) => \`Hello, \${name}!\`;
\`\`\`
`}</Markdown>

Every HTML attribute you'd put on a <div>className, style, id, data-* — passes through:

<Markdown className="prose" id="content" data-testid="article">
  {content}
</Markdown>

Themes

LLMRender ships four ready-to-use themes. Import one to get syntax highlighting, callout styles, and table styling with a single line:

import "llmrender/llmrender.css";  // GitHub light (default)
import "llmrender/dark.css";       // GitHub dark
import "llmrender/adaptive.css";   // follows prefers-color-scheme
import "llmrender/contrast.css";   // high contrast dark (WCAG AAA)

All colors are CSS variables, so you can override any token without touching the rest:

: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

API

<Markdown
  highlight?: (code: string, lang: string) => ReactNode[]
  math?: ((tex: string, block: boolean) => ReactNode) | false
  ...HTMLAttributes<HTMLDivElement>
>
  {markdownString}
</Markdown>
Prop Type Default Description
children string required Markdown source to render
highlight (code, lang) => ReactNode[] built-in Syntax highlighter for fenced code blocks
math (tex, block) => ReactNode | false built-in Math renderer for $…$ and $$…$$
rawHtml boolean | Record<string, string[]> undefined Allow HTML tags in Markdown source — see Security
...props HTMLAttributes<HTMLDivElement> Forwarded to the wrapping <div>

highlight

Called for every fenced code block. Return an array of ReactNodes rendered inside <code>.

type HighlightFn = (code: string, lang: string) => ReactNode[];

The built-in highlighter covers 30+ languages with <span> tokens and CSS variable colors. Swap it for any library:

import { codeToHtml } from "shiki";

async function highlight(code, lang) {
  const html = await codeToHtml(code, { lang, theme: "github-light" });
  return [<span key="h" dangerouslySetInnerHTML={{ __html: html }} />];
}

<Markdown highlight={highlight}>{content}</Markdown>

math

Called for every math expression. block is true for $$…$$, false for $…$.

type MathFn = (tex: string, block: boolean) => ReactNode;

The built-in renderer converts common LaTeX to MathML — Greek letters, fractions, superscripts, subscripts, square roots, binomials, and operators — with no extra dependencies. Pass math={false} to disable math parsing entirely.

Syntax

Headings

# H1
## H2
### H3
#### H4

Headings render with an id derived from their text (e.g. ## Getting Startedid="getting-started"), so anchor links like [jump](#getting-started) work out of the box.

Inline formatting

**bold**  _italic_  ~~strikethrough~~  `inline code`

[link text](https://example.com)
![alt text](https://example.com/image.png)
https://auto-linked.com

Blockquotes

> This is a blockquote.
> It can span multiple lines.
>
> > And nest.

Callouts

GitHub-style callouts inside blockquotes:

> [!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 <blockquote class="callout-{type}"> with a <p class="callout-title"> header. The included themes style them out of the box; you can also target them directly:

.callout-note    { border-left: 4px solid #0969da; background: #ddf4ff; }
.callout-warning { border-left: 4px solid #d1242f; background: #ffebe9; }

Lists

- Unordered item
- Another item
  - Nested item

1. Ordered item
2. Second item
   1. Nested

- [ ] Unchecked task
- [x] Checked task

Task items render with a disabled <input type="checkbox">.

Code blocks

```js
const greeting = "hello world";
console.log(greeting);
```

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

| Name  | Role    | Score |
|:------|:-------:|------:|
| Alice | Admin   |    99 |
| Bob   | Viewer  |    42 |

Column alignment: :--- left, :---: center, ---: right. Inline markup and escaped pipes (\|) work inside cells.

Math

Inline with $…$, display with $$…$$:

The formula $E = mc^2$ changed everything.

$$\frac{a}{b} = \sqrt{1 + x^2}$$

Supported out of the box: Greek letters (\alpha, \beta, …), fractions (\frac), square roots (\sqrt), superscripts, subscripts, sums (\sum), products (\prod), integrals (\int), limits (\lim), and binomials (\binom).

Horizontal rule

---

Security

LLMRender is safe to use with untrusted Markdown. Here is how that works, and what the limits are.

How React does the heavy lifting

LLMRender never produces raw HTML strings. Every piece of content — headings, paragraphs, link text, table cells, code, image alt text — becomes a React element rendered through JSX. React automatically escapes all text children, so input like [<script>alert(1)</script>](url) renders the <script> as literal visible text, not an executed tag. This protection is unconditional and applies to every Markdown construct.

URL attributes (href, src, etc.) are the one place text becomes an executable value. LLMRender uses an allowlist here too: only http: and https: URLs are allowed. Relative URLs and # fragments have no scheme and are always allowed. Everything else — javascript:, data:, blob:, ftp:, custom app protocols, and anything new — is replaced with #. Control characters that browsers strip from scheme names before resolving (e.g. java\x09script:) are removed before the check. Auto-linked bare URLs in Markdown only match http:// and https:// by definition.

Raw HTML (rawHtml prop)

By default, HTML tags written inside Markdown source are not rendered — they appear as escaped text. The rawHtml prop opts in to rendering them.

rawHtml={true} — renders tags from a built-in allowlist of known-safe content elements. Anything not on the list is silently dropped as text. The allowlist covers:

  • Inline: a, abbr, b, bdi, bdo, br, cite, code, data, dfn, em, i, kbd, mark, q, rp, rt, ruby, s, samp, small, span, strong, sub, sup, time, u, var, wbr
  • Block: address, article, aside, blockquote, dd, del, details, div, dl, dt, figcaption, figure, footer, h1h6, header, hr, ins, li, main, nav, ol, p, pre, section, summary, ul
  • Tables: caption, col, colgroup, table, tbody, td, th, thead, tr
  • Media: audio, img, map, area, picture, source, track, video

Tags not on this list — including script, style, iframe, canvas, svg, form, input, dialog, and anything new browsers add in the future — are never rendered. This allowlist model means the library stays secure by default even as the HTML spec evolves.

Regardless of the allowlist, these attributes are always stripped: all on* event handlers, srcdoc, style, ping. URL-bearing attributes (href, src, action, etc.) are sanitized to block any scheme other than http: and https:. Any <a target="_blank"> automatically gets rel="noopener noreferrer" added (merged with any existing rel value) to prevent tabnapping.

rawHtml={{ tag: ["attr", …] }} — explicit allowlist. Only the listed tags render, and only the listed attributes pass through. The hard-blocked tags and attributes above cannot be unlocked even here:

// Allow <mark> with no attributes, and <span> with only class
<Markdown rawHtml={{ mark: [], span: ["class"] }}>{content}</Markdown>

Prefer the explicit allowlist for untrusted sources — it gives you precise control over what HTML can appear.

Recipes

Tailwind Typography

<Markdown className="prose prose-lg dark:prose-invert max-w-none">
  {content}
</Markdown>

Styled Components

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 <Article>{content}</Article>;
}

KaTeX

For full LaTeX — matrices, stretchy brackets, and anything beyond the built-in renderer:

npm i katex
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 <span dangerouslySetInnerHTML={{ __html: html }} />;
}

<Markdown math={renderMath}>{content}</Markdown>

Mermaid diagrams

Intercept ```mermaid blocks via the highlight prop:

import Markdown 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 [<div ref={ref} className="mermaid" key="d">{code}</div>];
}

function highlight(code, lang) {
  if (lang === "mermaid") return [<Diagram code={code} key="d" />];
  return [code];
}

<Markdown highlight={highlight}>{content}</Markdown>
```mermaid
graph TD
  A[Start] --> B{Decision}
  B -->|Yes| C[Do it]
  B -->|No| D[Skip]
```