Layout in Gradio is indentation: wrap components in with gr.Row/Column/Tabs blocks and the tree is your UI.
Gradio's layout engine doesn't read CSS from you — it reads your indentation.
Inside gr.Blocks, layout containers are Python context managers you nest with `with`: gr.Row lays children side by side, gr.Column stacks them vertically (both are flexboxes with scale/min_width knobs), gr.Tabs + gr.Tab give click-to-switch panels, gr.Accordion is a collapsible section (open= controls the default), gr.Group glues related components into one visual box, and gr.Sidebar (5.14.0) adds a fixed collapsible panel on the left. Nesting them builds a tree: Rows contain Columns, Tabs contain Tab panels, an Accordion can live inside a Column.
Components alone are a form; layout is what makes a demo feel like a product. The same classifier becomes a settings sidebar, a main results panel and an 'Advanced options' accordion that hides complexity from casual users — without one line of HTML or CSS. Two parameters do most of the work: scale (relative flex weight, 0 = shrink to content) and visible=False (a container and everything inside it disappears, which is the standard trick for multi-step wizards). Every rendered demo on Hugging Face Spaces is this same with-block tree.
# Row + Column + scale: a two-panel workspace
import gradio as gr
with gr.Blocks(title="Echo Studio") as demo:
with gr.Row():
t = gr.Textbox(label="prompt", scale=3)
b = gr.Button("Run", variant="primary", scale=1)
o = gr.Textbox(label="result")
@b.click(inputs=t, outputs=o)
def run(x):
return (x or "").upper()
demo.launch(prevent_thread_lock=True)* Running on local URL: http://127.0.0.1:7860 Rendered UI (verified via /config on 6.27.0): component tree ['row', 'textbox', 'button', 'form', 'textbox', 'form'] — a Row holding the Textbox "prompt" (scale=3, wider) and the primary Button "Run" (scale=1), with the Textbox "result" full-width below. Calling the API end-to-end: POST /gradio_api/call/run with ["hello blocks"] streams back data: ["HELLO BLOCKS"].
Verified by launching on 6.27.0 and reading /config plus calling the event API. scale=3 vs scale=1 splits the row's width 3:1; variant="primary" is what paints the button orange.
# Tabs + Accordion: hide the expert stuff
data = {"2026-09-13": [12, 18, 9], "2026-09-14": [22, 31, 17], "2026-09-15": [15, 24, 11]}
with gr.Blocks(title="Latency Explorer") as demo:
gr.Markdown("## API latency explorer")
with gr.Tabs():
with gr.Tab("Today"):
with gr.Row():
day = gr.Dropdown(choices=list(data), value="2026-09-15", label="day")
stat = gr.Radio(["mean", "max"], value="mean", label="stat")
with gr.Tab("Help"):
gr.Markdown("pick a day, then read the summary panel")
with gr.Row():
with gr.Column(scale=2):
table = gr.Dataframe(label="samples")
with gr.Column(scale=1):
big = gr.Number(label="summary")
with gr.Accordion("raw payload", open=False):
raw = gr.JSON(label="payload")
@day.change(inputs=[day, stat], outputs=[table, big, raw])
def summarize(d, s):
vals = data[d]
return ([[i + 1, v] for i, v in enumerate(vals)],
sum(vals) / len(vals) if s == "mean" else max(vals),
{"day": d, "values": vals, "stat": s})
demo.launch(prevent_thread_lock=True)* Running on local URL: http://127.0.0.1:7860
Rendered UI (verified via /config on 6.27.0): types ['markdown', 'tabs', 'tabitem', 'row', 'dropdown', 'radio', 'form', 'tabitem', 'markdown', 'row', 'column', 'dataframe', 'column', 'number', 'accordion', 'json', 'form'] — tabitem labels ['Today', 'Help'] confirmed in the config. Handler check: summarize("2026-09-14", "mean") -> [[[1, 22], [2, 31], [3, 17]], 23.33..., {"day": "2026-09-14", "values": [22, 31, 17], "stat": "mean"}].
Verified by executing and inspecting /config. The Accordion ships closed (open=False) so the JSON stays out of sight until clicked; the two Columns split 2:1 via scale.
# Switching tabs from Python: return gr.Tabs(selected=...)
with gr.Blocks() as demo:
with gr.Tabs() as tabs:
with gr.Tab("Alpha"):
a = gr.Textbox(label="a")
with gr.Tab("Beta"):
b = gr.Textbox(label="b")
btn = gr.Button("jump to Beta")
@btn.click(outputs=tabs)
def jump():
return gr.Tabs(selected="Beta")
demo.launch(prevent_thread_lock=True)* Running on local URL: http://127.0.0.1:7860
Verified through the event API on 6.27.0: POST /gradio_api/call/jump -> event streams 'event: complete' with data: [{"selected": "Beta", "__type__": "update"}] — the UI switches to the Beta panel. Same pattern for Accordion: a handler returning gr.Accordion(open=True) streams {"open": true, "__type__": "update"} and pops the section open.
Verified by executing and calling the endpoint. Returning gr.Tabs(selected=...) from a listener is the documented way to drive navigation in code — think wizard: validate step 1, then auto-advance to step 2.
# Sidebar + Group: the app shell look
with gr.Blocks(fill_height=True) as demo:
with gr.Sidebar():
gr.Textbox(label="filter")
gr.Checkbox("only new")
with gr.Group():
gr.Markdown("### Settings")
gr.Slider(0, 2, value=0.7, label="temperature")
gr.Textbox(label="content")
demo.launch(prevent_thread_lock=True)* Running on local URL: http://127.0.0.1:7860 Rendered UI (verified via /config on 6.27.0): types ['sidebar', 'textbox', 'checkbox', 'form', 'group', 'markdown', 'slider', 'form', 'textbox', 'form'] — the Sidebar renders as a fixed left panel (default width=320, position="left") holding the filter controls; the Group draws one bordered box around its Markdown + Slider. Sidebar has its own expand/collapse events.
Verified by executing and inspecting /config. Sidebar arrived in 5.14.0 (PR #10435, Jan 30, 2025 per PyPI); on mobile it collapses over the content instead of pushing it.
| Flag | Meaning |
|---|---|
with gr.Row() | Horizontal flexbox; children sit side by side. equal_height=True (the default) stretches them to match, variant="panel" adds a card-style box around the row. |
scale (Row/Column) | Relative flex weight. scale=3 vs scale=1 gives a 3:1 split; scale=0 makes a child shrink-wrap its content. |
min_width (Column/Row children) | Pixel floor for a flex child — stops scale from squeezing a dropdown into unreadability on narrow screens. |
gr.Tabs / gr.Tab(label) | Click-to-switch panels; Tab(label, id=...) and tabs.select let you react, gr.Tabs(selected=...) returned from a listener switches panels programmatically. |
gr.Accordion(label, open=False) | Collapsible section, closed by default so advanced options stay out of the screenshot; toggle from code with gr.Accordion(open=...). |
gr.Group() | Glues adjacent components into one visual unit (shared border, no gaps) — for a value+unit pair or a labeled settings cluster. |
visible=False | On any container: hides it and every descendant. The cheapest multi-step-wizard primitive; flip it back from any event listener. |
gr.Sidebar() | Collapsible left panel (width=320 default, position='left'|'right'); pair with gr.Blocks(fill_height=True) so the main column fills the viewport. |
Gradio 3.0 (PyPI 2022-05-16) replaced the fixed two-column Interface with gr.Blocks, where Row, Column, Tabs, Group and Tab are context managers you nest — the changelog's own 3.0 entries already patch tab internals (PR #1200 wraps tab content in a column). The pitch: your indentation is your layout, no HTML. Accordion followed fast, in Gradio 3.3 (2022-09-08, PR #2208 by aliabid94).
gr.Sidebar landed in 5.14.0 (PR #10435, dawoodkhan82) — a fixed collapsible panel that made single-page app shells possible in pure Gradio. Then Gradio 6 (6.0.0, PyPI 2025-11-21) moved app-wide look-and-feel (theme, css, js) from the Blocks constructor into launch(), leaving the layout constructors to do exactly one thing: structure.
Every container is itself a component in the /config JSON the server emits at launch — row, column, tabs, tabitem, accordion, group, sidebar all appear in the same components array as your widgets, in tree order (my probes read the rendered tree straight from that payload). Layout children never round-trip values; they carry structure and props (scale, visible, open, selected). When a listener returns gr.Tabs(selected='Beta') the backend streams {"selected": "Beta", "__type__": "update"} over SSE and the frontend patches the tabs component's props — same update protocol as any widget, just aimed at a container.