kmail.at
contact

elsewhere

EnglishEN
← learning

gradio · difficulty ◆◆

gradio Markdown — headers, tables and LaTeX, plus the live status board nobody uses it for

gr.Markdown(fn, inputs=[x]) is a live readout: gradio calls your function on app load and again every time x changes, with no .change() chain in sight.

The plainest component in Gradio has an inputs= parameter almost nobody mentions. Wire it up and a paragraph turns into a panel that redraws itself whenever another component moves.

fully AI-generatedThis text is fully AI-generated and was published without human editorial review.

2026-10-16 · 5 min read

$ gr.Markdown()
24
Parameters in 6.18.0

inspect.signature(gr.Markdown.__init__). The 3.12.0 wheel had 3 (value, visible, elem_id); 4.0.0 had 12; 5.0.0 had 20.

2.8.6
First stable release

2022-03-02, per the PyPI wheel files. It was in the 2.8.0b20 beta from 2022-02-22.

1 ($$ … $$)
Default LaTeX delimiters

[{left: '$$', right: '$$', display: True}] — inline $…$ needs a delimiter you add yourself.

2
Events on the component

gr.Markdown.EVENTS is exactly [change, copy]. There is no .click() — copy arrived in 5.7.0 (PR #9979).

What it does

Renders GitHub-flavoured Markdown inside your Blocks: headings, bold, lists, blockquotes, pipe tables, inline `code`, fenced code blocks with syntax highlighting and a copy button, and KaTeX maths. No HTML, no CSS, no sanitising on your side. The signature is 24 parameters in gradio 6.18.0 and the interesting one is the first: `value` accepts a plain string or a callable. Pass a function and gr.Markdown stops being a caption — gradio calls it to paint the block, again on app load, and again every time a component listed in `inputs=` changes. Same for `every=1.0`, which re-calls the function on a timer. Both register a real dependency; no `.change()` handler, no `gr.update()`.

Why it matters

Every demo has a column of dead space that should be telling the user something: what the model receives, which preprocessing ran, how long the last call took, which of the three models is winning. A Markdown block fills that space with a table in two lines of Python, and in reactive mode it stays current without any callback plumbing. I use it for three things on repeat: an input inspector next to an image or audio input, a metrics board that refreshes on `every=`, and a results report that a button returns as Markdown with a table and a code fence. It is also the only place in Gradio where maths renders — papers and notebooks port over almost unchanged.

Example

$ import numpy as np
import gradio as gr
from PIL import Image

def classify(img):
    if img is None:
        return {"upload something": 1.0}
    arr = np.asarray(img.convert("L"), dtype=float)
    b = arr.mean() / 255
    if b > 0.6:
        return {"sky": 0.83, "cloud": 0.12}
    if b > 0.3:
        return {"forest": 0.61, "meadow": 0.27}
    return {"night": 0.77, "cave": 0.18}

def status(img):
    if img is None:
        return "### Waiting for an image\n\nDrop a photo on the left and this panel fills itself in."
    arr = np.asarray(img.convert("L"), dtype=float)
    return ("### Input checked\n\n"
            "| measure | value |\n|---|---|\n"
            f"| size | {img.size[0]}x{img.size[1]} px |\n"
            f"| mean pixel | {arr.mean():.1f} |\n"
            f"| mode | {img.mode} |")

with gr.Blocks(title="Scenery classifier") as demo:
    with gr.Row():
        with gr.Column():
            img_in = gr.Image(type="pil", label="Input image")
            run = gr.Button("Classify", variant="primary")
        with gr.Column():
            board = gr.Markdown(status, inputs=[img_in])   # no .change() anywhere
            out = gr.Label(num_top_classes=2, label="Prediction")
    run.click(classify, img_in, out)

demo.launch(prevent_thread_lock=True)
* Running on local URL:  http://127.0.0.1:7861

Rendered UI (read from /config and from the live DOM on gradio 6.18.0):
- Row, two columns. Left: Image input "Input image" with upload/webcam/clipboard sources, then a primary Button "Classify". Right: a Markdown block, then Label "Prediction" with num_top_classes=2.
- The Markdown block paints no box at all: div.block.padded.hide-container.auto-margin > div[data-testid=markdown-wrapper] > div[data-testid=markdown].prose > span.md > your HTML.
- Registered dependencies: {"targets": [[image, "change"], [blocks, "load"]], "inputs": [image], "outputs": [markdown], "api_name": "status"} and {"targets": [[button, "click"]], "inputs": [image], "outputs": [label], "api_name": "classify"}
- Before any upload the panel reads: "Waiting for an image / Drop a photo on the left and this panel fills itself in."
- Uploading a 96x64 RGB test image redrew it with no click: "Input checked / size 96x64 px / mean pixel 127.2 / mode RGB", innerHTML = <h3>Input checked</h3> plus a real <table> with thead/tbody.
- Clicking Classify then filled the Label with "forest 61% / meadow 27%" (mean 127.2 -> 0.499 -> the middle branch, as written).

The value=callable + inputs= pair is the whole trick. Gradio binds the function to the input's 'change' event and to Blocks.load, so the panel is already filled on first paint. Verified by uploading a real file in headless Chromium and reading the DOM back.

$ import datetime
import gradio as gr

_n = 0

def metrics():
    global _n
    _n += 1
    now = datetime.datetime.now().strftime("%H:%M:%S")
    return (f"### Worker pool — refreshed at {now}\n\n"
            "| queue | running | avg latency |\n|---|---|---|\n"
            f"| {_n % 7} | 2 | {38 + _n % 5} ms |")

with gr.Blocks(title="Ops board") as demo:
    gr.Markdown(metrics, every=1.0)

demo.launch(prevent_thread_lock=True)
* Running on local URL:  http://127.0.0.1:7862

Rendered UI (gradio 6.18.0):
- One Markdown block, no buttons, no inputs, nothing else on the page.
- Dependency registered automatically: {"targets": [[timer, "tick"], [blocks, "load"]], "inputs": [], "outputs": [markdown], "api_name": "metrics"} — gradio creates the Timer component from every=1.0; you never declare it.
- Live check: the h3 text went from "Worker pool — refreshed at 09:47:44" to "Worker pool — refreshed at 09:47:47" between two samples taken 2.5 s apart, and the queue cell incremented on each call.
- Zero Python callbacks and zero browser-side JavaScript in the app file.

every= does nothing when value is a plain string — the docstring says so and the config shows no dependency. It only schedules the callable you passed as value.

$ import gradio as gr

ROWS = [("resnet50", 0.94, 41), ("vit_b32", 0.91, 88), ("efficientnet_b0", 0.87, 22)]

def report(query, top_k):
    rows = "\n".join(f"| {i+1} | `{m}` | {s:.2f} | {ms} ms |" for i, (m, s, ms) in enumerate(ROWS[:int(top_k)]))
    return (f"## Results for **{query}**\n\n"
            "| # | model | score | latency |\n|---|---|---|---|\n"
            f"{rows}\n\n"
            "```python\n"
            f"best = \"{ROWS[0][0]}\"\n"
            "```")

with gr.Blocks(title="Model leaderboard") as demo:
    q = gr.Textbox(value="golden retriever", label="query")
    k = gr.Slider(1, 3, value=3, step=1, label="top k")
    btn = gr.Button("Run benchmark", variant="primary")
    res = gr.Markdown()
    btn.click(report, [q, k], res)

demo.launch(prevent_thread_lock=True)
* Running on local URL:  http://127.0.0.1:7863

Rendered UI (gradio 6.18.0):
- Textbox "query" prefilled with "golden retriever", Slider "top k" (min 1, max 3, step 1, value 3), primary Button "Run benchmark", and an empty Markdown output under them.
- After the click the output's innerHTML contains a real <table> (thead: #, model, score, latency; three tbody rows) and a <pre><code> fence with exactly 1 <button class="copy_code_button">.
- Text read back out of the DOM: "Results for golden retriever / 1 resnet50 0.94 41 ms / 2 vit_b32 0.91 88 ms / 3 efficientnet_b0 0.87 22 ms / best = \"resnet50\"".
- No label ever appears above the block — see the flags table.

Markdown is a first-class output component: any listener can return a string and the block re-renders. The code fence gets its copy button for free; buttons=["copy"] only adds the block-level one.

$ import math
import gradio as gr

TEX = ("# Attention, in one screen\n\n"
       "The scaled dot-product attention of a single head is\n\n"
       "$$\\mathrm{Attention}(Q,K,V)=\\mathrm{softmax}\\!\\left(\\frac{QK^{\\top}}{\\sqrt{d_k}}\\right)V$$\n\n"
       "and the temperature is just a $1/\\sqrt{d_k}$ factor in disguise.\n\n"
       "```python\n"
       "scores = (q @ k.T) / math.sqrt(d_k)\n"
       "```")

with gr.Blocks(title="Paper notes") as demo:
    gr.Markdown(
        TEX,
        latex_delimiters=[
            {"left": "$$", "right": "$$", "display": True},
            {"left": "$", "right": "$", "display": False},
        ],
        header_links=True,
    )
    gr.Markdown(TEX)   # same string, default delimiters

demo.launch(prevent_thread_lock=True)
* Running on local URL:  http://127.0.0.1:7864

Rendered UI (gradio 6.18.0, frontend bundles katex + an auto-render script):
- Top block (delimiters given): 2 KaTeX nodes. $$...$$ became <span class="katex-display"><span class="katex">... (block maths, from display: True); $1/\sqrt{d_k}$ became an inline <span class="katex">.
- header_links=True: the h1 gets id="h-attention-in-one-screen-1" and one <a class="md-header-anchor" href="#h-attention-in-one-screen-1"> containing an inline SVG link icon. The id carries a -1 suffix.
- Bottom block (no latex_delimiters): exactly 1 KaTeX node. The $$...$$ still renders, and $1/\sqrt{d_k}$ stays literal text you can select. Verified: with only the default delimiter the DOM keeps "inline $a=1$ and" as plain text next to one katex-display span.
- The fenced python block renders a <div class="code_wrap"> with a copy button in both blocks, without buttons=["copy"].

The constructor default is [{left: '$$', right: '$$', display: True}] — one delimiter, display mode only. Inline $ math is opt-in, which is exactly what the 3.35.0 changelog says they did on purpose: $ shows up in ordinary prose too often. latex_delimiters=[] turns LaTeX off completely and leaves the dollars visible.

Common flags

value= (str or callable)
A string renders as-is. A function is called to paint the block, on app load, and again whenever a component in inputs= changes.
inputs=[...]
Which components feed that function. No effect unless value is a callable. This is the whole reactive story — one parameter, no listener.
every=1.0
Float or Timer: re-call value on a schedule. Gradio builds the Timer and the tick + load dependency for you.
latex_delimiters=[...]
List of {"left", "right", "display"}. Default renders $$...$$ as display maths only; add a $ ... $ entry for inline; [] disables KaTeX.
sanitize_html=True
Default. Keeps <div style> and <img>, strips <iframe>, <script> and event-handler attributes. False keeps all of it.
line_breaks=False
True turns single newlines into <br> instead of GFM's soft-wrap. Needed whenever you paste multi-line plain text.
buttons=["copy"]
Adds a block-level copy button (aria-label "Copy conversation", a leftover) and enables the .copy() event. Code fences have their own button regardless.

History

Markdown got into Gradio as a paragraph, not a component

gradio 0.1.0 (2019-02-19) shipped exactly two output types — Class and Textbox — and no Markdown anywhere; I checked the wheel. Markdown reached users sideways, as gr.Interface's article= argument: 1.4.0 (2020-12-23) imports markdown2 and runs the article through it, and 2.7.0a101 swaps that for markdown-it-py. The component itself first shows up in the 2.8.0 beta wheels uploaded 2022-02-22, built off the Blocks development branch, and the first stable release carrying it is 2.8.6 (2022-03-02) — the day after the "Gradio Blocks" commit landed on main.

Three parameters, then twenty-four

The 3.12.0 signature reads value, visible, elem_id, plus **kwargs. It grew in visible steps: 8 parameters by 3.50.2 (rtl, latex_delimiters, sanitize_html, line_breaks all in), 12 in 4.0.0 (label, show_label, every, render), 17 in 4.39.0 (inputs, key, header_links, height, show_copy_button), 20 in 5.0.0 (container, max_height, min_height), 22 in 5.49.1 (padding, preserved_by_key), and 24 in 6.18.0, where show_copy_button became the more general buttons. Maths arrived in 3.12.0 — PR #2696, "mathtext" support — five releases after the component itself. The 6.17.0 changelog entry "Add layout sizing args to Markdown and HTML" is the last time its box model changed.

Under the hood

Under the hood: Python trims whitespace, the browser does the rest

gr.Markdown is the cheapest output component in Gradio. Its postprocess does one thing — inspect.cleandoc(value) — and returns; preprocess is a passthrough; api_info() is {"type": "string"}. No HTML is generated in Python: since 3.41.0 (PR #5268) the markdown and LaTeX conversion happens in the browser, where the frontend loads marked for GFM and a KaTeX auto-render script (the wheel bundles assets/MarkdownCode-*.js and assets/katex-*.js; window.MathJax is undefined and there is not a single <mjx-container> in the DOM). Two consequences worth knowing. cleandoc strips the indentation common to every line, so a docstring-style literal works and a 4-space-indented first line still becomes a code block — I fed it " # A\n\n b\n\n\n" and it rendered an h1 plus a paragraph, with no leading blank lines. And because the type is not checked, returning a number raises AttributeError: 'int' object has no attribute 'expandtabs'. Always hand it a str.

Fun facts

Pros & cons

pros

  • + Table stakes documentation for free: headings, pipe tables, lists, blockquotes, highlighted code fences with copy buttons, KaTeX — no HTML or CSS from you.
  • + value=callable + inputs= removes glue code; the block is populated on load as well as on change, and every= covers scheduled refresh.
  • + Almost free at runtime: Python sends a string over SSE and the browser renders it, so a report output costs you nothing per keystroke.

cons

  • − Silent failure modes: a non-string return raises AttributeError, and inline $...$ renders as literal text unless you add the delimiter yourself.
  • − label and show_label are documented as having no effect — you cannot put a title above a Markdown block, you have to fake it with a heading.
  • − Presentation only: the component's event list is [change, copy]. There is no .click(), and nothing it renders is interactive.

Takeaways

  1. 1gr.Markdown(fn, inputs=[other_component]) is a live readout in one line: populated on load, redrawn on change, no listener and no gr.update().
  2. 2Maths needs the delimiter you want: add {"left": "$", "right": "$", "display": False} to latex_delimiters for inline, and pass [] to switch KaTeX off entirely.
  3. 3postprocess is inspect.cleandoc — indentation common to every line is stripped. That makes docstring-style strings work and makes a number raise AttributeError.
  4. 4Set line_breaks=True whenever you paste multi-line plain text; without it GFM collapses single newlines and your list of statuses becomes one sentence.
  5. 5Use it for the empty column in every demo: a small pipe table of what the model received beats another paragraph of instructions.

Related commands

archive entry — written and published by a scheduled job, never editorially reviewed. Kept for reference: out of the site’s search index, and no ads run here. the reviewed library →

← all learning