gradio · difficulty ◆◆
gradio HighlightedText — colour the words behind a prediction
HighlightedText is the one Gradio output that shows the evidence, not just the verdict: every span carries its own label or score, so nobody has to trust a bare number again.
Your spam model answers 0.99 and you still have no idea which word did it. HighlightedText paints the sentence instead — and the answer is always 'viagra', at full opacity.
$ gr.HighlightedText()What it does
HighlightedText takes one flowing paragraph and splits it into labelled spans. Give it a list of (token, category) tuples, or the {'text', 'entities'} dict that a transformers token-classification pipeline already returns, and it renders each span with a background colour plus an inline category badge, followed by the unlabelled text as plain black. A category of None means 'no highlight'. Swap the category for a float and the component switches into scores mode, where the number becomes the alpha channel of the background — a heat map with no extra work. It's an output component: two events, change and select, and in scores mode (or with interactive=True) the spans are clickable.
Why it matters
Most demo UIs answer the wrong question. 'This review is negative' is unarguable but useless; 'this review is negative because of these eleven words' is what the person in front of the screen is actually trying to understand. Doing that by hand means string slicing, HTML injection and escaping rules — three places to get it wrong, times the number of models you ship. HighlightedText makes it a single component with a documented value shape, and because it accepts the pipeline dict verbatim, an NER demo becomes three lines: call the pipeline, pass its output, launch. It's also the cheapest way to sanity-check a model you don't trust yet: the spans make a confident-but-wrong prediction look wrong immediately.
Example
$ import gradio as gr
segments = [
("The battery dies in three hours and the hinge creaks", "complaint"),
(", but ", None),
("the screen is gorgeous", "praise"),
(".", None),
]
with gr.Blocks(title="Review triage") as demo:
gr.HighlightedText(
value=segments,
label="review breakdown",
color_map={"complaint": "#FF6B6B", "praise": "#4ADE80"},
show_legend=True,
)
demo.launch()$ python app.py
* Running on local URL: http://127.0.0.1:7901
* To create a public link, set `share=True` in `launch()`.
Rendered tree: one block <div data-testid="highlighted-text"> containing
<label data-testid="block-label"> (carbon "highlight" glyph) review breakdown
<div data-testid="highlighted-text:category-legend">
<button class="legend-item" style="background-color: rgba(255, 107, 107, 0.5);">complaint</button>
<button class="legend-item" style="background-color: rgba(74, 222, 128, 0.5);">praise</button>
<div class="textfield"> with four <span class="token-container"> children:
span.token.highlighted[role=button][tabindex=0] style="background-color: rgba(255, 107, 107, 0.5); color: black;"
span.token > span.text.unlabeled ", but "
span.token.highlighted[role=button][tabindex=0] style="background-color: rgba(74, 222, 128, 0.5); color: black;"
span.token > span.text.unlabeled "."
Reading the .text nodes in order gives back the original sentence byte for byte:
"The battery dies in three hours and the hinge creaks, but the screen is gorgeous."Verified on gradio 6.18.0 / Python 3.12 with server_port=7901 (a short-lived launch, output captured as printed). Nothing is split for you when you pass tuples — you own the whitespace, so the spaces have to live inside the spans.
$ import gradio as gr
def attributions(text):
# stand-in for integrated gradients on a spam classifier
weights = {"free": 0.93, "viagra": 0.99, "now": 0.41, "click": 0.62, "here": 0.55}
return [(tok + " ", weights.get(tok.lower(), None)) for tok in text.split()]
with gr.Blocks(title="Why spam?") as demo:
gr.HighlightedText(
value=lambda: attributions("FREE viagra now click here !"),
label="token attribution",
)
demo.launch()$ python app.py
* Running on local URL: http://127.0.0.1:7902
* To create a public link, set `share=True` in `launch()`.
Every scored span gets the extra class "score-token" and the score as the alpha channel
of a fixed red (#EF443C), measured straight off the DOM:
"FREE " class="token score-token ... highlighted" style="background-color: rgba(239, 68, 60, 0.93);"
"viagra " style="background-color: rgba(239, 68, 60, 0.99);"
"now " style="background-color: rgba(239, 68, 60, 0.41);"
"click " style="background-color: rgba(239, 68, 60, 0.62);"
"here " style="background-color: rgba(239, 68, 60, 0.55);"
"! " no background at all, but still role="button" tabindex="0"
No colour_map, no legend, no badge: in scores mode the heat is the whole UI.
Feed it scores outside [0, 1] and you get: 1.0 -> rgb(239, 68, 60) (opaque),
1.75 -> rgb(239, 68, 60) (clamped by the browser, no warning), 0.0 -> rgba(239, 68, 60, 0),
-0.4 -> rgba(128, 90, 213, 0.4) -- violet, because the frontend branches on the sign.Measured on gradio 6.18.0 with headless Chromium reading computed styles (port 7902). Passing a lambda as value is supported: the callable runs on load, so the app can score a live feed without any user action.
$ import gradio as gr
TEXT = "BEV maps Vienna, and Markus signs off."
def ner(text):
# exactly the shape transformers' token-classification pipeline returns
return {
"text": text,
"entities": [
{"entity_group": "ORG", "start": 0, "end": 3},
{"entity_group": "LOC", "start": 9, "end": 15},
{"entity_group": "PERSON", "start": 21, "end": 27},
],
}
with gr.Blocks() as demo:
ht = gr.HighlightedText(
value=ner(TEXT),
label="entities",
color_map={"ORG": "#2563EB", "LOC": "#F59E0B", "PERSON": "#9333EA"},
combine_adjacent=True,
adjacent_separator=" ",
)
out = gr.JSON(label="clicked span")
def on_pick(evt: gr.SelectData):
print(f"[select] index={evt.index} value={evt.value!r}")
return {"token": evt.value[0], "category": evt.value[1], "index": evt.index}
ht.select(on_pick, None, out)
demo.launch()$ python app.py
* Running on local URL: http://127.0.0.1:7905
* To create a public link, set `share=True` in `launch()`.
Six spans, whitespace intact without a single strip() in user code:
"BEV" ORG | " maps " (unlabeled) | "Vienna" LOC | ", and " | "Markus" PERSON | " signs off."
Clicking the first span POSTs to /gradio_api/queue/join:
{"data":[],"event_data":{"index":1,"value":["BEV","ORG"]},"fn_index":0,...}
and the server prints:
[select] index=1 value=['BEV', 'ORG']
The JSON component then shows {"token": "BEV", "category": "ORG", "index": 1}.
So evt.value is the whole (token, category) pair, not the token string.Verified on gradio 6.18.0 (port 7905) with the browser click captured from the network request. One sting: for a dict value whose first entity starts at offset 0, postprocess() prepends an empty "" token that never reaches the DOM — which is why the first clickable span reports index=1. With a plain list of tuples the same click reports index=0.
$ import gradio as gr
CASES = {
"smash": ("The parcel arrived smashed and support never answered.", "complaint"),
"fast": ("Two-day delivery and the box was perfect, thanks!", "praise"),
}
def pick_case(case):
text, label = CASES[case]
return (
gr.HighlightedText( # returning the component re-themes it in place
value=[(text, label)],
color_map={"complaint": "#DC2626", "praise": "#16A34A"},
label=f"tickets classified as {label}",
),
{"label": label, "chars": len(text)},
)
with gr.Blocks() as demo:
dd = gr.Dropdown(list(CASES), value="smash", label="ticket")
ht = gr.HighlightedText(label="why")
js = gr.JSON(label="meta")
dd.change(pick_case, dd, [ht, js])
demo.load(pick_case, dd, [ht, js])
demo.launch()$ python app.py
* Running on local URL: http://127.0.0.1:7909
* To create a public link, set `share=True` in `launch()`.
On load it renders the "smash" ticket; switching the Dropdown to "fast" replaces the
block label and the colour in place. Measured after the switch:
label "tickets classified as praise"
one token style="background-color: rgba(22, 163, 74, 0.5); color: black;"
JSON component {"label": "praise", "chars": 49}
No page reload, no CSS injected by hand: the handler returned a new component and
Gradio patched the props of the existing one.Measured on gradio 6.18.0 (port 7909), driving the Dropdown with headless Chromium. This is the supported way to change a colour map at runtime: construct a new HighlightedText in the handler and return it — the color_map constructor argument was moved to .style() in the component API years ago, but the component-as-update path still carries it.
Common flags
- value
- list[tuple[str, str | float | None]] — a HuggingFace-style {'text', 'entities'} dict — or a callable that returns one, called once at page load.
- color_map
- {"complaint": "#FF6B6B", "praise": "green"} — the span gets the colour at 50% alpha, the inline badge at full strength.
- show_legend
- True moves the category badges into a legend row above the text; the default False prints them inline right after each span.
- combine_adjacent + adjacent_separator
- Merge neighbouring tokens that share a label. Set adjacent_separator=" " when your tokens come from a tokenizer and carry no trailing space.
- show_whitespaces
- False strips every token before display — the fix for double spaces from word-piece tokenizers, at the cost of gluing adjacent spans together.
- show_inline_category
- Only bites when show_legend=False: False drops the little badge and leaves pure colour, which is what you want for diffs and heat maps.
- interactive
- Adds a × button to every labelled span and an inline category editor on click — the fastest build-your-own label-correction demo.
- every / inputs
- Pass a function as value plus every=2 to re-run it on a timer; combine with inputs=[...] to re-score when another component changes.
History
Two parameters, August 2020
HighlightedText shipped in gradio 1.1.0, uploaded to PyPI on 2020-08-10 — exactly one month after 1.0.0, whose gradio/outputs.py has no HighlightedText in it at all. The original class is four lines of substance: def __init__(self, color_map=None, label=None), with a docstring that states the contract as 'Output type: List[Tuple[str, Union[float, str]]]'. No badges, no legend, no click handling — a coloured paragraph and nothing else.
The dict arrived with the package refactor
Gradio 4.0 broke gradio/outputs.py into gradio/components/, and highlighted_text.py exists as its own module at tag gradio@4.0.0 (before that the class lived in gradio/outputs.py). The {'text', 'entities'} input belongs to that era of treating the HF pipeline as the primary caller. Everything since has been trimming: show_inline_category in 4.32.0 (PR #8355), rtl in 5.24.0 (PR #10924), a refactor plus transparent/undefined label support in 6.4.0 (PR #12785), a fix for spurious separators from empty tokens when combine_adjacent=True in 6.21.0 (PR #13606), and selectable unlabelled tokens in 6.27.0 (PR #13815).
Fun facts
Pros & cons
pros
- + One component replaces the string-slicing, HTML-templating and escaping you'd otherwise write for every NER or attribution demo.
- + Accepts the transformers pipeline dict verbatim, so the glue between model output and UI is genuinely zero lines.
- + Scores mode gives a readable heat map without a colour_map, a legend or a custom component.
cons
- − No overlap support: entities are stitched sequentially, so nested or overlapping spans get clipped rather than stacked.
- − Whitespace is entirely your problem — the component inserts nothing, and show_whitespaces=False can glue spans together.
- − On 6.18.0 an unlabelled span is not clickable and is skipped by evt.index, so 'click any word' UIs need 6.27.0+.
Takeaways
- 1Pass tuples when you control the text; pass the pipeline dict when a model produced it — the dict path restores the whitespace between entities for you.
- 2Reach for combine_adjacent=True + adjacent_separator=" " the moment your tokens come from a tokenizer without trailing spaces.
- 3Numeric class_or_confidence is the alpha channel: clamp to [0, 1] yourself, because 1.75 is silently flattened to opaque and a negative flips the hue to violet.
- 4evt.value from .select() is the whole (token, category) pair — index into it, don't treat it as a string.
- 5show_legend=True for two or three categories; inline badges (the default) once you have more than that, or the text starts looking like confetti.