Module 3 — Layout: columns, tabs, sidebar
Modules 1 and 2 built widgets that stacked from top to bottom. That works for a proof of concept, but no sales team wants to scroll through a page of thirty inputs before seeing a chart. This module introduces the four layout primitives Streamlit gives you — columns, tabs, the sidebar and expanders — and combines them into the dashboard layout the running example will keep from now on.
Containers: the mental model
Every layout primitive returns a container, and containers behave like miniature scripts: whatever you call inside them appears inside them, whatever you call outside stays outside. Two syntaxes coexist and both are common in real code.
import streamlit as st
# Syntax A: the with-block. Clearer when the block is long.
with st.container():
st.subheader("Contract")
st.text_input("Customer ID")
# Syntax B: attribute access on the returned handle. Compact for a one-liner.
c = st.container(border=True)
c.metric("At-risk customers", "128")
The border=True flag added in recent versions draws a subtle outline
around the container, which is often enough to visually group a KPI
without a heading. st.container(height=300, border=True) even gives you
a scrollable area of a fixed pixel height — useful for a live log without
letting it push the rest of the page below the fold.
Columns
st.columns splits the current container horizontally. It accepts either
a count or a list of weights.
left, right = st.columns(2) # two equal columns
narrow, wide = st.columns([1, 3]) # 25 % / 75 %
a, b, c = st.columns(3, gap="large", vertical_alignment="center")
left.metric("Recovery rate", "22 %")
with right:
st.line_chart(monthly_revenue) # placeholder chart
Three details save time when the design starts to grow. Weights are
relative, so [1, 3] and [2, 6] produce identical widths. The
gap argument accepts "small", "medium" or "large" and is applied
between every pair of columns, not once for the row. And
vertical_alignment="center" on a row of columns aligns their contents
vertically, which is what you want when a metric sits next to a
paragraph of text — otherwise the metric sticks to the top.
Tabs
st.tabs is the natural way to give one page several views without
scrolling. The user pays a click, and you keep the URL clean.
tab_score, tab_pipeline, tab_history = st.tabs(["Score a customer", "Batch pipeline", "History"])
with tab_score:
st.text_input("Customer ID", key="single_customer")
# ... form from module 2 ...
with tab_pipeline:
st.file_uploader("Upload CSV", type=["csv"], key="batch_csv")
with tab_history:
st.dataframe(recent_predictions) # placeholder
Two facts worth internalizing. Streamlit runs the code inside every
tab on every rerun, even the tabs the user is not looking at. If a tab
contains an expensive call, hide it behind a cache (module 5) or a
button, not behind the tab. Also, st.tabs returns the tab handles in
the order of their labels, so pinning the same order in a list makes the
code robust to renaming.
The sidebar
st.sidebar is the ideal home for filters and settings that apply to
every view of the app: date range, segment, region, whether to hide
retained customers. Anything the user should be able to change without
losing their current view goes there.
with st.sidebar:
st.title("Filters")
date_range = st.date_input("Signup between", (date(2023, 1, 1), date(2026, 12, 31)))
segment = st.multiselect("Segment", ["SMB", "Mid-market", "Enterprise"], default=["SMB", "Mid-market"])
threshold = st.slider("At-risk threshold", 0.0, 1.0, 0.60, 0.05)
st.divider()
st.caption("Filters apply to every tab.")
The sidebar can be collapsed by the user, so never put anything the app
needs to function there — a required setting hidden behind a folded
sidebar is a real support ticket. It can also be pinned open by passing
initial_sidebar_state="expanded" to st.set_page_config. On mobile, the
sidebar becomes a drawer that opens on tap, so keep it short: three or
four filters at most, or the drawer becomes its own scrolling page.
Expanders and popovers
st.expander folds a section behind a title. Use it for details a user
occasionally wants to see — the raw JSON payload, the model's
per-feature contribution, a debug panel:
with st.expander("Show the model inputs"):
st.json(payload)
st.popover is a newer, lighter alternative: it opens on click as a
floating panel, without pushing content down the page. It fits secondary
actions and short forms — "add a note", "rename the customer" — better
than an expander.
with st.popover("Add a note"):
note = st.text_area("Free-form note")
st.button("Save")
The dashboard layout of the running example
Here is the third version of the app. The single scrolling page becomes a real dashboard: filters in the sidebar, three tabs for the three ways the sales team uses the tool, columns to put KPIs and a form side by side.
import streamlit as st
import pandas as pd
from datetime import date
st.set_page_config(page_title="Churn dashboard", page_icon="📉", layout="wide",
initial_sidebar_state="expanded")
with st.sidebar:
st.title("Filters")
segment = st.multiselect("Segment", ["SMB", "Mid-market", "Enterprise"],
default=["SMB", "Mid-market"])
threshold = st.slider("At-risk threshold", 0.0, 1.0, 0.60, 0.05)
st.caption("Applies to every tab.")
st.title("Churn dashboard")
k1, k2, k3, k4 = st.columns(4, gap="large")
k1.metric("At-risk customers", "128", delta="+14 vs last month", delta_color="inverse")
k2.metric("Revenue at risk", "USD 42 800")
k3.metric("Recovery rate", "22 %", delta="+3 pp")
k4.metric("Model version", "v3.2")
st.divider()
tab_score, tab_batch, tab_history = st.tabs(
["Score a customer", "Batch pipeline", "History"]
)
with tab_score:
left, right = st.columns([1, 1], gap="large")
with left:
# The scoring form from module 2 lives here.
st.text_input("Customer ID", placeholder="C-01847")
st.slider("Months as a customer", 0, 72, 12)
st.selectbox("Contract type", ["Month-to-month", "One year", "Two years"])
st.button("Score this customer", type="primary")
with right:
st.info("The result appears here after you click **Score this customer**.")
with st.expander("Advanced options"):
st.checkbox("Show per-feature contribution")
with tab_batch:
st.caption("Batch scoring lives in module 7.")
with tab_history:
st.caption("The prediction history table lives in module 4.")
The layout="wide" in set_page_config uses the full browser width
rather than a centered 730 px column: a dashboard with four KPI cards
needs the space. And the top-of-file set_page_config is still the only
place that call is legal — moving it below the sidebar block will crash
the app.
For an Arabic or Hebrew version of your dashboard, Streamlit's layout
primitives work unchanged, but you need direction: rtl on the root of
the page. The .streamlit/config.toml file introduced in module 9 lets
you inject CSS site-wide, and the sidebar swaps to the right of the
screen by that single setting.
In summary
- Every layout primitive is a container; use
withfor long blocks and attribute access for one-liners. - Columns split space horizontally, tabs split it by view, expanders and popovers fold optional details; use them together.
- The sidebar holds filters and settings that cross every view; never put a required setting there — the user can collapse it.
- Code inside a hidden tab still runs on rerun; put expensive work behind a cache (module 5), not behind a tab.
Next module: charts and tables, the two visual pillars any dashboard — including the running example — is built on.