Markdown React component for Tailwind CSS v4
v1.0.1Updated in 1.10.0
A React Markdown renderer in the library's look: scrolling tables, highlighted code, task lists and a flicker-free streaming mode for AI.
Read it like any page: headings keep their levels, links are always underlined (external ones get a small arrow and open in a new tab), done tasks are struck through, and fenced code gets a highlighted block with a copy button. A table wider than the column scrolls sideways inside its own rounded box with soft edge fades, never the page; once it overflows it is a focusable region, so Tab reaches it and the arrow keys scroll it. Tab also stops on each link (Enter follows it), on every code block's scroller and on its copy button. An image that fails to load shows its alt text instead of a broken icon. With streaming on, text arrives without flicker: half-typed bold, code spans, links and table headers stay hidden until they complete, only the last block re-renders, each new block fades in and a caret pulses at the end of the text. Press Replay as a stream in the playground to replay the answer. On a phone the text stays at 15px, long words and links wrap, and tables and code scroll with a swipe. Reduced motion drops the fade and stills the caret.
Markdown is a Pro React component in the React data display components category, written in TypeScript for React 19 and Tailwind CSS v4. It installs as source with npx wingo-ui@latest add markdown, together with Code Block, useScrollEdges, Tones and UIProvider and the npm packages react-markdown, remark-gfm and lucide-react.
Add to a project
Requirements: React 19 and Tailwind CSS v4, in Next.js (App Router or Pages), Vite or React Router. Installation guide · Next.js setup · Vite setup
npx wingo-ui@latest add markdownMarkdown is part of Pro, with 298 more Pro items: $8 a month or $150 once. 14-day refund before you download any Pro item. See plans
Usage
Import Markdown from @/components/ui/markdown.
import { useEffect, useRef, useState } from "react"
import { Sparkles } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Markdown } from "@/components/ui/markdown"
import { mockStream } from "@/lib/ai-chat"
const answer = `I found **3 overdue invoices**, **$22,775.95** in total:
| Client | Invoice | Amount |
|---|---|---:|
| Acme Corp | #2026-0405 | $12,480.00 |
| Greenleaf Pharmacy | #2026-0371 | $7,315.20 |
Shall I draft the emails?`
export function AssistantAnswer() {
const [text, setText] = useState("")
const [streaming, setStreaming] = useState(false)
const stream = useRef<AbortController | null>(null)
useEffect(() => () => stream.current?.abort(), [])
async function generate() {
stream.current?.abort()
const controller = new AbortController()
stream.current = controller
setText("")
setStreaming(true)
for await (const delta of mockStream(answer, { signal: controller.signal })) setText((t) => t + delta)
// a restart aborted this stream: the new one owns the flag
if (stream.current === controller) setStreaming(false)
}
return (
<div className="flex flex-col gap-3">
<Button variant="soft" size="sm" leftIcon={<Sparkles />} onClick={generate}>
Generate
</Button>
<Markdown streaming={streaming} variant="compact">
{text}
</Markdown>
</div>
)
}Props
childrendefault"I found **3 overdue invoices** in September, **$22,775.95** in total: | Client | Invoice | Amount | Late | |---|---|---:|---:| | Acme Corp | #2026-0405 | $12,480.00 | 1 day | | Greenleaf Pharmacy | #2026-0371 | $7,315.20 | 53 days | | West Side Books | #2026-0366 | $2,980.75 | 57 days | **What I suggest:** 1. A polite reminder to Acme Corp, the due date passed yesterday. 2. A call to Greenleaf Pharmacy: Daniel Reed, `(503) 555-0118`. 3. For West Side Books, a formal notice. I can use the **Payment notice** template. Shall I draft the emails?"string | number | nullThe markdown source (GitHub flavored: tables, task lists, strikethrough, autolinks, footnotes). null, undefined and an empty string render nothing; other values go through String(). Raw HTML in the source is never rendered.
sizedefault"md""sm" | "md" | "lg"Text size and line height: sm 14 / 24px (side panels, reasoning), md 15 / 28px (chat, notes, comfortable on a phone), lg 16 / 28px (documents). Headings, code and tables scale with it.
variantdefault"prose""prose" | "compact"prose is a document's rhythm: blocks 1em apart and headings up to 1.6em. compact is a chat's rhythm: blocks 0.75em apart and headings never above 1.2em, so an answer never shouts.
streamingdefaultfalsebooleanText is still arriving. Half-typed syntax is repaired (completeMarkdown), only the last top-level block re-parses per chunk, new blocks fade in, a caret shows at the end and the root gets aria-busy. Turn it off when the answer completes: the whole source then renders in one pass.
caretdefault"dot""dot" | "bar" | "block" | "none"The caret at the end of streaming text: a pulsing 10px dot, a thin text-high bar, a block cursor, or none. It sits inline after the last word, and on a line of its own after a code block.
tonedefault"neutral""neutral" | "brand" | "success" | "warning" | "danger" | "info"Colors the links, the checked task boxes and the blockquote rail. neutral keeps links in the text color with a soft underline that darkens on hover.
colordefault""stringAny CSS color. Replaces the tone for links, task boxes and the quote rail, mixed for contrast in both themes.
codeAccentdefault""stringThe accent of fenced code blocks, a hex color that the whole code theme is built from. Empty keeps code-block's own; set it app-wide with UIProvider defaults={{ "code-block": { accent } }}.
codeLineNumbersdefaultfalsebooleanA line-number gutter in fenced code blocks.
codeMaxHeightdefault480numberThe tallest a code block gets, in px; longer code scrolls inside its block. 0 lets it grow with the code.
headingIdsdefaultfalsebooleanGives h1 to h4 an id from slugifyHeading (What's new becomes whats-new, repeats get -2, -3), for #anchors and a table of contents. Ids a plugin already set are kept.
linkTargetdefault"auto""auto" | "_blank" | "_self"auto opens absolute http(s) links in a new tab with rel noopener noreferrer nofollow and keeps relative links and #anchors in place; _blank opens every link but #anchors in a new tab; _self never does. Links that open a new tab show a small arrow and say so to screen readers.
allowImagesdefaulttruebooleanfalse renders every image as a link to it with its alt text, so untrusted model output can never load a tracking pixel by itself.
breaksdefaultfalsebooleanA single newline becomes a line break, as in chat apps and GitHub comments, so signatures, addresses and verse keep their lines. Off follows CommonMark, where a single newline is a space. Code keeps its newlines either way.
componentsComponents (react-markdown)Replace any element, e.g. render crm://client/c01 links as chips. Yours win over the built-in look; each receives the element's props plus the hast node. A code override also receives fenced code (className language-x), as in react-markdown. Keep the object stable (module scope or useMemo), or every block re-renders.
remarkPluginsPluggableListRemark plugins, appended after remark-gfm (always on, single tildes stay text). Keep the array stable.
rehypePluginsPluggableListRehype plugins, run before the heading ids and the caret. Keep the array stable.
urlTransform(url: string, key: string, node: Element) => (string | null | undefined)Runs on every href and src first. The default, react-markdown's defaultUrlTransform, drops javascript: and other unsafe protocols. An empty result renders the link text without a link. Allow your own scheme with url.startsWith("crm://") ? url : defaultUrlTransform(url).
onLinkClick(href: string, event: MouseEvent<HTMLAnchorElement>) => voidCalled on every link click, for in-app navigation; call event.preventDefault() to stop the browser. The newest handler is used, so an inline function is fine.
labelsPartial<MarkdownLabels>Every string it reads out: streaming "Generating" (sr-only after the caret), image "Image", taskDone "Done", taskOpen "Not done", code (language) => `${language} code`, newTab "(opens in a new tab)", footnotes "Footnotes", footnoteBack (index) => `Back to reference ${index}`.
classNamesPartial<Record<MarkdownSlot, string>>Extra classes for the parts: heading, paragraph, link, list, listItem, blockquote, code (the block around a CodeBlock), inlineCode, tableWrapper (the bordered box), table, hr, image, caret.
classNamestringExtra classes for the root, merged last. The root spreads every other div prop (id, aria-*, data-*).
Parts
MarkdownLink
componentThe built-in link (target, rel, the new-tab arrow and its sr-only text, onLinkClick), exported so a components.a override can hand back the links it does not handle: href?.startsWith("crm://") ? <Chip /> : <MarkdownLink {...props} />. Props: MarkdownLinkProps.
completeMarkdown
functionThe streaming repair, pure: closes an open code fence, appends closers for unmatched ** __ ~~ ` * _ (snake_case and 2*3 stay as they are), drops a marker with nothing after it, turns a half-typed [text](htt into text, holds a table back until its separator row arrives and drops half-typed list markers, # and >. Never touches code. Signature: (text: string) => string.
splitMarkdownBlocks
functionSplits a source into top-level blocks at blank lines, keeping code fences, loose lists and indented continuations whole. The unit the streaming memo works in. Signature: (text: string) => string[].
markdownToText
functionPlain text for copying and screen reader announcements: syntax stripped, list items on their own lines (bullets become •), table cells joined with tabs, code kept as written. Signature: (text: string) => string.
slugifyHeading
functionThe id headingIds uses: lowercase, accents folded, anything but letters and digits turned into dashes (What's new in 4.2 becomes whats-new-in-4-2). Signature: (text: string) => string.
DEFAULT_MARKDOWN_LABELS
constantThe English strings, to spread under your own or translate once in UIProvider defaults. Type: MarkdownLabels.
Examples
Streaming
The email answer streams letter by letter: bold, the quote and the SQL fence never show raw, the caret follows the text, and breaks keeps the signature on two lines.
Document
size lg, prose and headingIds for release notes, with a table of contents fed by slugifyHeading.
Code heavy
Bash, TypeScript and JSON fences with a brand accent and line numbers; long code scrolls in its block.
Untrusted output
allowImages off and linkTarget _blank: the pixel becomes a link, javascript: loses its link, raw HTML renders nothing.
Sizes and variants
sm, md and lg side by side; switch between the compact chat rhythm and the prose document rhythm.
Custom components
components.a turns crm:// links into client chips and hands every other link back to MarkdownLink; urlTransform lets the scheme through.
Files
- components/ui/markdown.tsx
FAQ
Is the React Markdown component free?
The preview, props and docs are free; copying the source needs a Pro plan. See plans.
How do I install Markdown?
Run npx wingo-ui@latest add markdown. It also adds the Wingo UI items it builds on (Code Block, useScrollEdges, Tones and UIProvider) and the npm packages react-markdown, remark-gfm and lucide-react. With shadcn, add the @wingo-ui registry with your API key, then run npx shadcn@latest add @wingo-ui/markdown.
How does Markdown behave on phones?
On a phone the text stays at 15px, long words and links wrap, and tables and code scroll with a swipe.
See also
Used in
More React data display components
Articles using this component
- React Rich Text Editor: Tiptap, Markdown and Mentions
Component guides9 min read
- Nextjs llms.txt: Generate llms-full.txt and .md Pages
Building UI with AI agents10 min read
- React Markdown Streaming Without Flicker or Scroll Jumps
Building UI with AI agents10 min read
