The idea
By default, Streamlit stacks everything in one column, top to bottom. That's fine for a script, but a real dashboard has structure: filters on the side, headline numbers in a row, different views in tabs, details tucked away until someone needs them. Four layout tools cover nearly every case.
The sidebar: where filters live
Anything written to st.sidebar appears in a collapsible panel on the left:
import streamlit as st
import pandas as pd
sales = pd.read_csv("sales.csv")
completed = sales[sales["status"] == "completed"]
with st.sidebar:
st.header("Filters")
category = st.selectbox("Category", ["All"] + sorted(completed["category"].unique()))
channels = st.multiselect("Channels", ["web", "app"], default=["web", "app"])
rows = completed[completed["channel"].isin(channels)]
if category != "All":
rows = rows[rows["category"] == category]
st.title("Sales dashboard")
st.write(f"{rows['order_id'].nunique()} orders match the filters.")
with st.sidebar: sends everything inside the block to the sidebar. st.sidebar.selectbox(...) does the same for a single element. Putting filters in the sidebar keeps the main area for results, a pattern users recognise instantly.
Columns: things side by side
By default, Streamlit stacks every element in one long column. That's fine on a phone, but on a laptop it wastes space and makes the reader scroll. The classic fix is a row of headline numbers side by side at the top of the page:
import streamlit as st
col1, col2, col3 = st.columns(3)
col1.metric("Revenue", "Rp 11.8M", "12%")
col2.metric("Orders", 26, "4")
col3.metric("Avg order", "Rp 455K", "-3%")
st.columns(3) returns three column objects; call elements on them (col1.metric(...)) or use with col1:. A list of numbers sets relative widths:
import streamlit as st
import pandas as pd
sales = pd.read_csv("sales.csv")
completed = sales[sales["status"] == "completed"]
left, right = st.columns([2, 1])
with left:
st.subheader("Orders")
st.dataframe(completed[["order_date", "product", "revenue"]], hide_index=True)
with right:
st.subheader("By channel")
st.dataframe(completed.groupby("channel", as_index=False)["revenue"].sum(), hide_index=True)
[2, 1] makes the left column twice as wide. On a phone, Streamlit stacks columns vertically automatically.
Tabs: different views of the same data
A dashboard often has several ways of looking at the same data: a summary, a breakdown by product, the raw rows. Putting them all on one page makes it very long. Tabs let the reader switch between views instead:
import streamlit as st
import pandas as pd
sales = pd.read_csv("sales.csv")
completed = sales[sales["status"] == "completed"]
overview, by_product, raw = st.tabs(["Overview", "By product", "Raw data"])
with overview:
st.metric("Revenue", f"Rp {completed['revenue'].sum():,}")
with by_product:
st.dataframe(completed.groupby("product", as_index=False)["revenue"].sum(), hide_index=True)
with raw:
st.dataframe(completed, hide_index=True)
Tabs keep a page short while offering several views. One thing to know: all tabs run on every rerun, even the hidden ones. Tabs organise the page; they don't save computation.
Expanders: details on demand
Some information is useful but not for everyone, like exactly how a number was calculated. An expander hides it behind a click, so it doesn't clutter the page:
import streamlit as st
st.metric("Cancelled orders", 10)
with st.expander("How is this counted?"):
st.write("Orders with status *cancelled*, by order date. "
"Orders cancelled after shipping are counted as returns, not cancellations.")
Expanders are perfect for methodology notes, raw data and "advanced options": present for those who want them, invisible for everyone else.
Containers and placeholders
st.container() groups elements so you can fill them out of order:
import streamlit as st
header = st.container()
choice = st.radio("Period", ["This week", "This month"], horizontal=True)
with header:
st.subheader(f"Report: {choice}")
The subheader appears above the radio, though the code that fills it runs after. st.empty() holds a single element that you can replace later, handy for progress messages.
Putting it together
A typical dashboard skeleton:
import streamlit as st
import pandas as pd
st.set_page_config(page_title="Toko Kita", layout="wide")
sales = pd.read_csv("sales.csv")
completed = sales[sales["status"] == "completed"]
with st.sidebar:
channels = st.multiselect("Channels", ["web", "app"], default=["web", "app"])
rows = completed[completed["channel"].isin(channels)]
st.title("Toko Kita sales")
c1, c2 = st.columns(2)
c1.metric("Revenue", f"Rp {rows['revenue'].sum():,}")
c2.metric("Orders", rows["order_id"].nunique())
tab1, tab2 = st.tabs(["By category", "Raw data"])
with tab1:
st.bar_chart(rows.groupby("category")["revenue"].sum())
with tab2:
st.dataframe(rows, hide_index=True)
st.set_page_config(layout="wide") uses the full browser width. It must be the first Streamlit command in the script.
Why this matters for clean code
Layout code mirrors page structure, so keep it as structured as the page: one with block per region, filters gathered in the sidebar block, display code grouped by tab. When the layout and the code have the same shape, finding "where is that metric computed?" takes seconds.
Recap
with st.sidebar:for filters;st.sidebar.x(...)for single elements.st.columns(n)orst.columns([2, 1])for side-by-side;col.metric(...)orwith col:.st.tabs([...])for alternative views; all tabs run every time.st.expander("...")hides details until opened.st.container()fills a region out of order;st.empty()holds one replaceable element.st.set_page_config(layout="wide")must come first.