Scrim UI

Markdown Message UI

Rendered assistant reply — code blocks with copy, tables, lists and links.

messagemarkdowncoderender

Here’s a quick comparison of the three approaches:

1. Streaming — the answer starts appearing while the model is still writing it. Best when reading time roughly equals generation time. 2. Waiting — the model finishes, then the UI renders. Best for structured output that must be valid before it’s shown. 3. Hybrid — stream the prose, gate anything the user will copy or act on until it’s done.

See the streaming guide for the full decision rule.

Props

Component source

Single-file React + Tailwind component. No dependencies — drop it into any project with Tailwind configured.

markdown-message.tsx
"use client";

import * as React from "react";

/* ------------------------------------------------------------------ */
/* Types                                                               */
/* ------------------------------------------------------------------ */

export type MarkdownMessageProps = {
  /** Markdown-ish text — paragraphs, **bold**, *italic*, `code`,
   *  [links](url), fenced ```code blocks, bullet lists and pipe tables. */
  text: string;
  className?: string;
};

/* ------------------------------------------------------------------ */
/* Inline renderer — builds React nodes (no dangerouslySetInnerHTML)    */
/* ------------------------------------------------------------------ */

function renderInline(line: string, keyBase: string): React.ReactNode[] {
  const tokenRe = /(`[^`]+`)|(\*\*[^*]+\*\*)|(\*[^*]+\*)|(\[[^\]]+\]\([^)]+\))/g;
  const nodes: React.ReactNode[] = [];
  let last = 0;
  let m: RegExpExecArray | null;
  let i = 0;
  while ((m = tokenRe.exec(line)) !== null) {
    if (m.index > last) {
      nodes.push(
        <React.Fragment key={`${keyBase}-t${i++}`}>{line.slice(last, m.index)}</React.Fragment>,
      );
    }
    const tok = m[0];
    const k = `${keyBase}-k${i++}`;
    if (tok.startsWith("`")) {
      nodes.push(<code key={k}>{tok.slice(1, -1)}</code>);
    } else if (tok.startsWith("**")) {
      nodes.push(<strong key={k}>{tok.slice(2, -2)}</strong>);
    } else if (tok.startsWith("[")) {
      const md = tok.match(/^\[([^\]]+)\]\(([^)]+)\)$/);
      if (md) {
        nodes.push(
          <a
            key={k}
            href={md[2]}
            target="_blank"
            rel="noreferrer noopener"
            className="text-(--foreground) underline underline-offset-2"
          >
            {md[1]}
          </a>,
        );
      } else {
        nodes.push(<em key={k}>{tok.slice(1, -1)}</em>);
      }
    } else {
      nodes.push(<em key={k}>{tok.slice(1, -1)}</em>);
    }
    last = m.index + tok.length;
  }
  if (last < line.length) {
    nodes.push(<React.Fragment key={`${keyBase}-end`}>{line.slice(last)}</React.Fragment>);
  }
  return nodes;
}

function isTableSeparator(line: string): boolean {
  return /^\s*\|?[\s:|-]+\|?\s*$/.test(line) && line.includes("-");
}

/* ------------------------------------------------------------------ */
/* CodeBlock — fenced code with a language label and copy button       */
/* ------------------------------------------------------------------ */

function CodeBlock({ lang, code }: { lang: string; code: string }) {
  const [copied, setCopied] = React.useState(false);

  const copy = () => {
    void navigator.clipboard?.writeText(code);
    setCopied(true);
    window.setTimeout(() => setCopied(false), 1500);
  };

  return (
    <div className="my-3 overflow-hidden rounded-xl border border-zinc-200 dark:border-zinc-800">
      <div className="flex items-center justify-between bg-zinc-100 px-3 py-1.5 dark:bg-zinc-800/80">
        {/* zinc-600: this header sits on bg-zinc-100, where zinc-500 is
            4.39:1 — under AA at 11px. Dark mode is unaffected. */}
        <span className="text-[11px] font-medium text-zinc-600 dark:text-zinc-400">
          {lang || "text"}
        </span>
        <button
          type="button"
          onClick={copy}
          className="inline-flex h-6 items-center gap-1 rounded-md px-1.5 text-[11px] text-zinc-600 transition-colors hover:bg-zinc-200 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-700 dark:hover:text-zinc-100"
        >
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" width="12" height="12">
            <rect x="9" y="9" width="13" height="13" rx="2" />
            <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
          </svg>
          {copied ? "Copied" : "Copy"}
        </button>
      </div>
      <pre className="overflow-x-auto bg-zinc-950 px-3 py-3 text-[13px] leading-5 text-zinc-100 dark:bg-zinc-900">
        <code>{code}</code>
      </pre>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* Block parser — split into paragraphs / lists / tables / code        */
/* ------------------------------------------------------------------ */

function MarkdownBlocks({ text }: { text: string }) {
  const lines = text.split("\n");
  const blocks: React.ReactNode[] = [];
  let i = 0;
  let key = 0;

  while (i < lines.length) {
    const line = lines[i];
    const trimmed = line.trim();

    if (trimmed === "") {
      i++;
      continue;
    }

    /* Fenced code block */
    if (trimmed.startsWith("```")) {
      const lang = trimmed.replace(/^```/, "").trim();
      let j = i + 1;
      const code: string[] = [];
      while (j < lines.length && !lines[j].trimStart().startsWith("```")) {
        code.push(lines[j]);
        j++;
      }
      blocks.push(<CodeBlock key={key++} lang={lang} code={code.join("\n")} />);
      i = j + 1;
      continue;
    }

    /* Pipe table */
    if (line.includes("|") && i + 1 < lines.length && isTableSeparator(lines[i + 1])) {
      const header = line
        .split("|")
        .map((c) => c.trim())
        .filter((c, idx, arr) => !(c === "" && (idx === 0 || idx === arr.length - 1)));
      let j = i + 2;
      const body: string[][] = [];
      while (j < lines.length && lines[j].includes("|") && !isTableSeparator(lines[j])) {
        body.push(
          lines[j]
            .split("|")
            .map((c) => c.trim())
            .filter((c, idx, arr) => !(c === "" && (idx === 0 || idx === arr.length - 1))),
        );
        j++;
      }
      blocks.push(
        <div key={key++} className="my-3 overflow-x-auto">
          <table className="w-full border-collapse text-[13px] leading-5">
            <thead>
              <tr>
                {header.map((h, hk) => (
                  <th
                    key={hk}
                    className="border-b border-zinc-200 px-3 py-1.5 text-left font-medium text-zinc-500 dark:border-zinc-800 dark:text-zinc-400"
                  >
                    {renderInline(h, `th${hk}`)}
                  </th>
                ))}
              </tr>
            </thead>
            <tbody>
              {body.map((row, rk) => (
                <tr key={rk}>
                  {row.map((cell, ck) => (
                    <td
                      key={ck}
                      className="border-b border-zinc-200/70 px-3 py-1.5 text-zinc-700 dark:border-zinc-800/70 dark:text-zinc-300"
                    >
                      {renderInline(cell, `td${rk}-${ck}`)}
                    </td>
                  ))}
                </tr>
              ))}
            </tbody>
          </table>
        </div>,
      );
      i = j;
      continue;
    }

    /* Bullet list */
    if (/^\s*[-*]\s+/.test(line)) {
      const items: string[] = [];
      let j = i;
      while (j < lines.length && /^\s*[-*]\s+/.test(lines[j])) {
        items.push(lines[j].replace(/^\s*[-*]\s+/, ""));
        j++;
      }
      blocks.push(
        <ul key={key++} className="my-3 space-y-1 pl-5">
          {items.map((item, ik) => (
            <li key={ik} className="list-disc pl-1">
              {renderInline(item, `li${ik}`)}
            </li>
          ))}
        </ul>,
      );
      i = j;
      continue;
    }

    /* Paragraph */
    let j = i;
    const para: string[] = [];
    while (j < lines.length && lines[j].trim() !== "" && !lines[j].trimStart().startsWith("```")) {
      para.push(lines[j]);
      j++;
    }
    blocks.push(<p key={key++}>{renderInline(para.join(" "), `p${key}`)}</p>);
    i = j;
  }

  return <>{blocks}</>;
}

/* ------------------------------------------------------------------ */
/* MarkdownMessage                                                     */
/* ------------------------------------------------------------------ */

export function MarkdownMessage({ text, className = "" }: MarkdownMessageProps) {
  return (
    <div className={`whitespace-pre-wrap text-[15px] leading-7 text-zinc-800 dark:text-zinc-100 ${className}`}>
      <MarkdownBlocks text={text} />
    </div>
  );
}

Usage Guidelines

  • Render markdown as React nodes, never `dangerouslySetInnerHTML` — your model output is untrusted text from a remote model.
  • Render plain text while streaming, then upgrade to rendered markdown when the turn completes; highlighting breaks mid-token.
  • Give code blocks a language label and a copy button — users copy code far more than they copy prose.
  • Let wide tables scroll inside their own container; never let a table stretch the message column.

Common UX Mistakes

  • Attempting to render markdown token-by-token during streaming — bold and code spans are ambiguous until the token is complete.
  • Using an HTML-string renderer on model output without sanitizing links and HTML.
  • Hiding the raw text entirely; a 'view source' affordance on messages with heavy formatting builds trust.

Related Components