gradio · difficulty ◆◆
gradio ColorPicker — the colour swatch that quietly hands you a string
gr.ColorPicker is 16 parameters wrapped around one str — and nothing in Gradio ever checks that the str is a colour.
You will wire .change() to it, and .change() is the one listener that stays silent while the user is still picking.
$ gr.ColorPicker()What it does
gr.ColorPicker renders a small rounded swatch. Click it and a picker opens: a saturation/brightness gradient, a hue slider, a live preview swatch, Hex / RGB / HSL entry modes, and an eyedropper button (the native EyeDropper API — verified present as window.EyeDropper in Chromium, and the button in the DOM). Your function receives the colour as a plain str, and the component can also be used as an output: hand it '#009245' and the swatch turns that colour. On 6.18.0 the swatch itself is just <button class="dialog-button" aria-label="primary" style="background: rgb(0, 146, 69);">. No <input type="color"> anywhere — I checked the DOM after load and got 0 of them, and 0 input elements in the entire page.
Why it matters
Brand colour, annotation colour, heatmap palette, a 'this detection box should be green' control — a colour is a normal input in ML demo UI, and it is the one input where a text box would be genuinely worse. The reason to spend five minutes on it is the event surface: ColorPicker is unusual in that the value lands in .change() but .submit() fires one commit behind, and .blur() fires before the user is finished. Wire the wrong one and your preview is stale by exactly one interaction. It is also the only core component whose entire Python value-handling is two lines of str() — which is a feature (throw 'tomato' or 'rgb(1,2,3)' at it and it works) and a trap (nothing validates anything).
Example
$ import gradio as gr
def swatch(hex_color: str) -> str:
return (f"<div style='padding:14px 18px;border-radius:10px;"
f"background:{hex_color};color:#fff;font:600 15px sans-serif'>"
f"Run inference</div>")
with gr.Blocks(title="Brand picker") as demo:
gr.Markdown("### Pick the primary colour for your demo")
brand = gr.ColorPicker(value="#009245", label="primary", info="hex, #RRGGBB")
go = gr.Button("Apply")
out = gr.HTML()
go.click(swatch, brand, out)
demo.launch()Running on local URL: http://127.0.0.1:7941
Rendered component tree: Markdown "### Pick the primary colour for your demo" -> ColorPicker (label "primary", info "hex, #RRGGBB", swatch showing green) -> Button "Apply" -> HTML output.
What the browser is told for that picker (read live from /config):
{"value": "#009245", "label": "primary", "info": "hex, #RRGGBB", "show_label": true, "container": true}
The default swatch is a <button class="dialog-button" aria-label="primary" style="background: rgb(0, 146, 69);">. Click it and a .color-picker panel opens containing a .color-gradient (role="slider", aria-label="Saturation and brightness"), a .hue-slider (role="slider", aria-label="Hue", aria-valuemin=0, aria-valuemax=360), a preview .swatch, an eyedropper button, and a Hex / RGB / HSL mode row. In Hex mode the panel holds exactly one <input type="text">.
The handler is trivial because the component does the work -- swatch('#009245') returns:
<div style='padding:14px 18px;border-radius:10px;background:#009245;color:#fff;font:600 15px sans-serif'>Run inference</div>
and swatch('#ff8800') returns the same markup with background:#ff8800. No conversion, no object, no .name attribute -- just the hex string you asked for.All three examples were executed on gradio 6.18.0 / Python 3.13 by launching each app on its own port (7941-7943) and reading the served /config; the DOM lines come from driving the real widget in Chromium via Playwright. The launch banner port is whatever you pass to launch().
$ import gradio as gr
log = []
def note(name):
def fn(v):
log.append(f"{name}:{v}")
return "\n".join(log)
return fn
with gr.Blocks(title="Event order") as demo:
c = gr.ColorPicker(value="#009245", label="accent")
out = gr.Textbox(label="event log", lines=6)
c.focus(note("focus"), c, out)
c.blur(note("blur"), c, out)
c.submit(note("submit"), c, out)
c.change(note("change"), c, out)
c.release(note("release"), c, out)
demo.launch()Running on local URL: http://127.0.0.1:7942
ColorPicker exposes exactly six listeners -- change, input, release, submit, focus, blur -- all wired above. Then I drove the real widget in a Playwright-controlled Chromium: click the swatch, type #ff8800 into the Hex field, press Enter. This is the server-side log, verbatim and in order:
focus args=('#009245',)
blur args=('#009245',)
submit args=('#009245',)
change args=('#ff8800',)
Read that twice. The colour you typed is only in the LAST line. focus, blur and submit all still carry the OLD value '#009245' -- submit fires a full round trip before change does, so a preview wired to .submit() is one interaction stale every single time. And when the handler that consumed .change() ran, it received '#ff8800':
change received ('#ff8800',)
The swatch confirmed it too -- after the commit its inline style read background: rgb(255, 136, 0).Event names and order captured from Python handlers instrumented with a shared log file, driven in a real browser. The old-value-on-submit behaviour is consistent with the 6.6.0 changelog entry 'Fix ColorPicker not firing focus, blur, or submit events after Svelte 5 migration' -- those three events have their own dispatch path.
$ import gradio as gr
def risk(score: float) -> str:
return "#009245" if score > 0.8 else "#ff8800" if score > 0.5 else "#ff0000"
with gr.Blocks(title="Risk board") as demo:
s = gr.Slider(0, 1, value=0.62, label="model confidence")
sw = gr.ColorPicker(label="risk level", interactive=False)
txt = gr.Textbox(label="shipped value")
s.change(risk, s, sw)
sw.change(lambda c: f"swatch now {c}", sw, txt)
demo.launch()Running on local URL: http://127.0.0.1:7943
Rendered component tree: Slider "model confidence" (0 to 1, sitting at 0.62) -> ColorPicker "risk level" (swatch with no initial colour, read-only) -> Textbox "shipped value".
What the browser is told (from /config):
{"label": "risk level", "show_label": true, "container": true, "interactive": false}
interactive=False is what makes it display-only: the swatch renders and can be repainted by your function, but clicking it opens no picker. Feed it a computed colour and postprocess passes the string straight through:
risk(0.62) -> '#ff8800' risk(0.95) -> '#009245' risk(0.10) -> '#ff0000'
And here is the part that surprises people -- the output side validates nothing either. postprocess() on 6.18.0 is literally `return str(value)`, so all of these are accepted and shipped to the client as the swatch colour:
'#009245' -> '#009245' 'tomato' -> 'tomato'
'rgb(1,2,3)' -> 'rgb(1,2,3)' 'not-a-color' -> 'not-a-color'
'#fff' -> '#fff' '' -> '' None -> None
preprocess() is `str(payload)` in the same spirit: gr.ColorPicker().preprocess('not-a-color') happily returns 'not-a-color'. 'tomato' is a valid CSS colour so the swatch goes red; 'not-a-color' is not, so the element falls back to whatever CSS decides -- silently, with no error in the Python log.Both preprocess and postprocess read straight from the installed 6.18.0 source; every value in the table was executed, not reasoned about. If you want a validated colour, do it in your own function -- Gradio will not.
Common flags
- value
- The starting colour, as a str. Omit it and the component's value is None -- the swatch renders blank and the browser gets no value at all, rather than defaulting to black.
- info="hex, #RRGGBB"
- A one-line hint under the label. ColorPicker is one of the components that takes info, which is worth using because nothing else in the UI tells the user which format to paste.
- interactive=False
- Display-only swatch. The picker never opens on click -- it becomes a coloured badge your function repaints, which is how you use it as an output.
- .change(fn, picker, out)
- The listener that carries the committed colour. This is the one to wire. It fires last, with the new value.
- .release(fn, picker, out)
- Fires when the mouse is released, per the 6.18.0 docstring: 'triggered when the user releases the mouse on this ColorPicker.' Continuous, mid-gesture -- the event for live preview, not for saving.
- .blur() / .focus() / .submit()
- All three exist and all three fire -- but in my browser run each carried the stale colour. .submit() in particular round-trips a value before .change() has updated it.
- eyedropper
- No Python parameter for it: the picker panel always ships an eyedropper button that calls the native EyeDropper API. Present in the DOM and window.EyeDropper was a function in my Chromium run; on browsers without it, that button does nothing.
History
It landed in 3.0.25 and was documented a version later
gr.ColorPicker first appears in gradio 3.0.25 (2022-07-16). I confirmed the boundary by untarring the adjacent sdists: 3.0.24 contains the string 'ColorPicker' in zero files, while 3.0.25 has it in __init__.py, components.py and the test suite. It arrived during the big Blocks-era push, when the library was replacing its input/output class pairs with unified components. The component shipped in 3.0.25, but PR 1768 -- 'Add ColorPicker to docs' -- is listed under 3.1, so for a few days it existed in the wild before it existed on the docs site.
Four years later the event plumbing still needed fixing
The colour maths has been stable since day one; the events are what keep breaking. .blur() was added in 3.24.0 (2023-03-30). interactive= arrived only in 3.40.0 (2023-08-10) -- more than a year after the component shipped, which means for its first year a ColorPicker could not be made read-only. Then the Svelte 5 migration broke its events outright: 6.3.0 (2026-01-10) had to re-add focus, blur and submit, and 6.6.0 (2026-02-17) re-fixed the same three after they stopped firing. As recently as 6.21.0 (2026-07-29) there is a fix for ColorPicker dispatching blur while the user is still using the dialog -- that is the stale-value behaviour I measured here, patched two minor versions before the release I tested. A four-line component with a four-year event backlog.
Fun facts
Pros & cons
pros
- + The only core input where a colour does not have to be typed as text -- the picker, the hue slider and the eyedropper come for free
- + Value handling is transparent: a plain hex str in, a plain hex str out, so it drops straight into string building and CSS without a conversion layer
- + Doubles as an output -- interactive=False turns it into a coloured status badge your function can repaint for a risk level or a class label
cons
- − Nothing validates anything, on either side: preprocess and postprocess are each `return str(value)`, so a typo becomes a silently CSS-defaulted swatch
- − Three of its six listeners hand you the previous colour, and .submit() looks like the commit event while behaving like a pre-commit one
- − No Python-side configuration of the dialog -- you cannot hide the eyedropper, set a default mode, or constrain the picker to a palette
Takeaways
- 1Bind `.change()` for the committed colour and expect `.submit()` to be one interaction behind -- in a real browser the submit round trip logs the OLD value before change fires.
- 2Never trust the string: preprocess and postprocess are both `return str(value)`, so validate with a regex if the colour can come from user text.
- 3Set `value="#RRGGBB"` explicitly. Left alone, the component's value is None and the browser gets no colour at all rather than defaulting to black.
- 4Use `.release()` for live drag feedback and `.change()` for anything you act on; release fires mid-gesture, per the docstring.
- 5For an output swatch remember `interactive=False` -- without it the user can click and override the colour your function computed.