gradio · difficulty ◆◆
gradio HTML — your own markup, CSS, JS and Python callbacks in one component
gr.HTML is the escape hatch: a formatted card first, scoped CSS next, and a browser-to-Python component when nothing else fits.
One Gradio component renders whatever markup you hand it — and since 6.0.0 it also takes a CSS template, a JS hook, and a list of Python functions the browser can call.
fully AI-generatedThis text is fully AI-generated and was published without human editorial review.$ gr.HTML()What it does
gr.HTML(value) drops a string of markup into your layout untouched: a model card, a badge row, an SVG sparkline, kbd-styled shortcuts. Since 6.0.0 it is more than a dumping ground. html_template makes it a template renderer (Handlebars, then a JavaScript template literal), css_template gives it scoped styling, js_on_load runs code the moment the component mounts, head loads libraries before that code, and server_functions exposes Python callables to the browser as async methods. Defaults: value=None, html_template="${value}", show_label=False, container=False, padding=False, no height limits — and a js_on_load that already fires the click event when the block itself is clicked.
Why it matters
Most demos need three things no single component gives them: a formatted header, a visual that isn't an input, and a bit of interactivity the Gradio API doesn't cover. Without gr.HTML people bolt CSS onto unrelated components or fork a custom component. gr.HTML does all three inside Blocks for one line, and because it plugs into the normal event system it shares the queue, the session state and the Python functions with the rest of your app. The trade-off: you're outside the component contracts — errors surface in the browser console rather than your traceback, and the docs are a step ahead of the 6.18.0 implementation in a couple of places.
Example
$ import gradio as gr
CARD = """
<table style="width:100%;border-collapse:collapse">
<tr><th align="left">checkpoint</th><td>efficientnet-b0</td></tr>
<tr><th align="left">params</th><td>5.3M</td></tr>
<tr><th align="left">val accuracy</th><td>77.7%</td></tr>
</table>
"""
with gr.Blocks() as demo:
gr.HTML(CARD, elem_classes=["model-card"], container=True, max_height=120)
gr.Textbox(label="prompt")
demo.launch()* Running on local URL: http://127.0.0.1:7946
* To create a public link, set `share=True` in `launch()`.
DOM, measured in headless Chromium against gradio 6.18.0:
<div class="block model-card padded auto-margin"> <- container=True drops hide-container
<div class="html-container" style="max-height: 120px; overflow-y: auto;">
<div class="prose gradio-style model-card"> <table>…</table> </div>
</div>
</div>
Rendered text: 'checkpoint efficientnet-b0 | params 5.3M | val accuracy 77.7%'
Scroll behaviour: scrollHeight 143 px vs clientHeight 120 px, so the card scrolls inside its box instead of pushing the prompt field down.container=True is what draws the box: white background, 1 px border, 10px 12px padding. max_height lands as an inline style on the inner .html-container together with overflow-y: auto — you don't have to write any CSS for the scroll.
$ import gradio as gr
scores = [{"label": "golden retriever", "score": 0.82},
{"label": "labrador", "score": 0.11},
{"label": "tennis ball", "score": 0.04}]
bars = "{{#each value}}<div class='row'><span>{{this.label}}</span><b>{{this.score}}</b></div>{{/each}}"
css = ".row { display: flex; justify-content: space-between; font: 600 0.9rem/1.6 ui-monospace, monospace; }"
with gr.Blocks() as demo:
gr.HTML(scores, html_template=bars, css_template=css, container=True, elem_id="bars")
gr.HTML(scores, html_template="<p>top-1 = ${JSON.stringify(value[0])}</p>")
demo.launch()Rendered rows (innerText of each .row):
['golden retriever\n0.82', 'labrador\n0.11', 'tennis ball\n0.04']
.row computed style: display: flex
css_template is not inlined into the markup. A separate <style> element shows up in document.head:
#html-oenhgt4qz { .row { display: flex; justify-content: space-between; font: 600 0.9rem/1.6 ui-monospace, monospace; } }
The second component prints:
top-1 = {"label":"golden retriever","score":0.82}`{{#each value}}` re-binds the Handlebars context to the current row, which is why `{{this.label}}` resolves; used outside the each-block the context is the props object and unknown keys render as empty strings. `${JSON.stringify(value[0])}` works because html_template is evaluated a second time as a JS template literal.
$ import gradio as gr
CHIPS_JS = """
element.querySelectorAll('.chip').forEach(function (btn) {
btn.addEventListener('click', function () { trigger('pick', {label: btn.innerText}); });
});
"""
with gr.Blocks() as demo:
chips = gr.HTML(["golden retriever", "labrador", "tennis ball"],
html_template="{{#each value}}<button class='chip'>{{this}}</button>{{/each}}",
css_template=".chip { margin: 4px; padding: 6px 12px; border-radius: 999px; cursor: pointer; }",
js_on_load=CHIPS_JS, container=True)
picked = gr.Textbox(label="picked", elem_id="picked")
def on_pick(evt: gr.EventData):
return evt.label
chips.pick(on_pick, outputs=picked)
demo.launch()The HTML block renders three buttons inside the component: 'golden retriever', 'labrador', 'tennis ball'.
Clicking the third one (verified with Playwright) fired the custom event and the Textbox labelled 'picked' became:
'tennis ball'
So `chips.pick(...)` — an event name that exists nowhere in the Gradio API — behaves exactly like .click(): it runs a Python function, shows in the component's events, and receives gr.EventData with .label set.Any event name you quote inside js_on_load becomes a real Python event (since 6.9.0, PR #12967). Gradio raises a helpful AttributeError if you get the name wrong: "If 'pick' is a custom event, make sure to include 'pick' (enclosed in quotes) in the js_on_load string."
$ import gradio as gr
STATS = {"warehouse/": {"files": 3, "rows": 1240}}
def count_rows(prefix, limit=100):
"""Runs in Python; the browser calls it as server.count_rows()."""
return STATS.get(prefix, {"files": 0, "rows": 0}) | {"limit": limit}
STAT_JS = """
(async () => {
const d = await server.count_rows('warehouse/');
element.querySelector('#stat').textContent =
d.files + ' files, ' + d.rows + ' rows (limit ' + d.limit + ')';
})();
"""
with gr.Blocks() as demo:
gr.HTML("<div id='stat'>loading…</div>", container=True, padding=True,
head="<script src='https://example.com/chart.min.js'></script>",
server_functions=[count_rows], js_on_load=STAT_JS)
demo.launch()Inside the running app (gradio 6.18.0, verified with Playwright):
#stat textContent -> '3 files, 1240 rows (limit 100)'
That number came out of Python. The default argument limit=100 arrived as 100, and the dict returned by count_rows() arrived in the browser as a JS object.
head: two components declared the same <script src>, document.querySelectorAll("script[src*=chart.min]").length was 1 — head scripts are deduplicated by src. The loaded script had already run before js_on_load: window.__headProbe was 'loaded-from-head-7955' even though js_on_load is what reads it.count_rows() runs in the same process as your event listeners, so server_functions is a direct line to your models, SQLite files or queues — no FastAPI route, no gradio_client. Errors inside the JS side only land in the browser console, so wrap the call in try/catch while you build.
Common flags
- value
- The markup, or the data for html_template. Can be a str, list, dict or a callable — with a callable you get every=/inputs= like any other component. A `<script` in it raises a UserWarning since 6.18.0.
- html_template
- Default "${value}". Compiled with Handlebars first, then evaluated as a JS template literal with your other keyword arguments, so {{#each value}}…{{this.field}} and ${some_prop} both work in the same string.
- css_template
- Plain CSS, auto-scoped: Gradio injects it as `#html-<random> { … }` into a <style> in document.head, so bare selectors target the component root and never leak into the rest of the app.
- js_on_load
- JS that runs on mount with element, trigger, props, watch, upload and server in scope. Default is `element.addEventListener('click', function() { trigger('click') })` — that is why a plain gr.HTML can carry a .click() handler with no JS of your own.
- server_functions
- List of Python callables exposed to the browser as async methods: `await server.count_rows('warehouse/')`. Arguments and return values are serialised for you; kwargs work.
- head
- Raw HTML injected into the document <head> before js_on_load runs. Scripts are deduplicated by src and links by href, so ten components can ask for the same library and it loads once.
- container / min_height / max_height
- container=True draws the bordered box (white background, 1 px border, 10px 12px padding). min_height and max_height become inline styles on the inner .html-container with overflow-y: auto; max_height makes long content scroll.
History
2020: one parameter
HTML appears in Gradio 1.1.0 (2020-08-10) as `HTML(label=None)` in gradio/outputs.py — one of six components added in that release, together with Audio, Dataframe, File, HighlightedText and JSON. It had no value argument at all in the Interface era: the function you wired it to returned the markup string.
2025: the custom-component rewrite
Gradio 6.0.0 (2025-11-21, PR #12098, 'gr.HTML custom components') turned it into an authoring surface: html_template, css_template, js_on_load, apply_default_css. 6.7.0 added server_functions, push_to_hub() and the HTML component gallery, plus gr.HTML-as-layout; 6.10.0 added head and the watch() helper; 6.18.0 started warning about <script> in the content; 6.21.0 began stripping <style> and <link> in the frontend sanitizer. Between 5.44.0 and 6.20.0, 17 changelog entries mention gr.HTML — that's roughly one every three releases.
Fun facts
Pros & cons
pros
- + Renders anything: tables, badges, SVG, kbd-styled shortcut cheatsheets — no new component and no custom build step.
- + Turns into a real interactive component when you need one: css_template for scoped styling, js_on_load for behaviour, custom events back into Python.
- + server_functions is a direct Python bridge from the browser: no API route to hand-wire, same process as your models, same queue as your other events.
cons
- − No scripts via innerHTML, and <style>/<link> in the content get sanitised away in the frontend — layout and behaviour belong in head, css_template and js_on_load, which is more moving parts than a normal component.
- − Failures are quiet and client-side: a missing Handlebars helper paints an error card into the component, a bad expression only logs 'Error evaluating template' to the console, and Python never sees a traceback.
- − The docs run ahead of the code in 6.18.0 — the children/@children path didn't render for me, and padding=True made no measurable difference in a four-way container/padding test.
Takeaways
- 1Put `<script>` in head, never in value: innerHTML doesn't execute it, and 6.18.0+ warns you by name.
- 2Style it with container=True to get the bordered card; padding=True on its own changed nothing visible in my 6.18.0 measurements.
- 3Inside html_template, loop first: `{{#each value}}` re-binds the context so `{{this.label}}` resolves — outside it, unknown keys render as empty strings, not errors.
- 4Use `${JSON.stringify(prop)}` for lists and dicts; `${value}` stringifies them into [object Object].
- 5`server_functions=[my_fn]` plus `(async () => { await server.my_fn(…) })()` is a complete browser-to-Python widget — just don't write a bare await on the first line, it's a SyntaxError in 6.18.0.