Skip to main content

Module 2 — Input and display components

Module 1 established the execution model. This module introduces the vocabulary Streamlit gives you to build a page: the widgets that ask the user for something, and the primitives that show a result. Everything the sales team will interact with is assembled from these calls.

Text, numbers and toggles

Every widget follows the same pattern: it is a function that returns the current value. There is no onchange callback in the classical sense — each interaction reruns the script, so the return value is enough.

import streamlit as st

customer_id = st.text_input("Customer ID", placeholder="e.g. C-01847", max_chars=12)
tenure = st.number_input("Months as a customer", min_value=0, max_value=120, value=12, step=1)
monthly_charge = st.number_input("Monthly bill (USD)", min_value=0.0, value=70.0, step=5.0, format="%.2f")
paperless = st.toggle("Paperless billing", value=True)

Four points of habit that pay off later. placeholder= is invisible until the field is empty and does not become the value on submit, unlike a default. min_value and max_value on a number_input are enforced by the widget itself, so you do not have to re-validate. The format string follows Python's percent syntax and controls display, not storage. And st.toggle reads better than st.checkbox for a binary business setting like "paperless billing", although both return a bool.

Sliders, ranges and choices

For continuous inputs, sliders give a better sense of scale than a text field. Streamlit supports single values, ranges and even date ranges with the same call.

tenure = st.slider("Months as a customer", 0, 72, value=12)
revenue_band = st.slider("Monthly bill range (USD)", 0.0, 200.0, value=(30.0, 90.0), step=5.0)
signup_window = st.slider(
"Signed up between",
value=(pd.Timestamp("2023-01-01"), pd.Timestamp("2024-12-31")),
)

For discrete choices, three widgets cover almost every case. st.selectbox returns one value from a list. st.multiselect returns a list of values. st.radio shows the same content as a selectbox but keeps every option visible, which is preferable when the list is short and the choice matters. st.segmented_control, added in recent versions, is a compact alternative to radio for two or three options.

contract = st.selectbox("Contract type", ["Month-to-month", "One year", "Two years"], index=0)
services = st.multiselect(
"Active services",
["Internet", "TV", "Phone", "Support+", "Cloud backup"],
default=["Internet"],
)
payment = st.radio("Payment method", ["Card", "Bank transfer", "Check"], horizontal=True)

The horizontal=True flag on st.radio lays the options side by side instead of stacked, which keeps the form compact when the labels are short.

Dates and time

st.date_input and st.time_input return Python datetime objects, and st.date_input accepts a tuple for a range picker. Both respect the browser locale, so a user in France sees 05/09/2026 where a user in the United States sees 09/05/2026, without any code change on your side.

from datetime import date

signup_date = st.date_input("Signup date", value=date(2024, 1, 15))
review_window = st.date_input(
"Contract review window",
value=(date(2026, 1, 1), date(2026, 3, 31)),
)

Displaying text and results

Streamlit distinguishes calls that show text (title, header, subheader, caption) from the generic st.write, which is a "do the right thing" dispatcher. Given a string it writes Markdown; given a DataFrame it renders a table; given a Matplotlib figure it draws the chart. This is convenient in a notebook, but in a real page you should prefer the explicit calls: they are faster, they document your intent, and they behave the same regardless of the argument's type.

For numeric summaries, st.metric gives a first-class KPI card with an optional delta:

col_a, col_b, col_c = st.columns(3)
col_a.metric("At-risk customers", "128", delta="+14 this month", delta_color="inverse")
col_b.metric("Recovery rate", "22 %", delta="+3 pp")
col_c.metric("Revenue at risk", "USD 42 800")

Two settings deserve attention. The delta is a string, so format it yourself; passing a raw number will render as-is without a plus sign. delta_color="inverse" swaps green and red for metrics where "up is bad" — churn count, error rate, response time. Getting this right is the difference between a dashboard the sales team trusts and one they mentally override.

For tables, st.dataframe renders an interactive Arrow-backed grid with sorting, resizing and CSV export in the top-right menu. st.table renders a static HTML table that fits a report better but does not scale beyond a few dozen rows.

The scoring form of the running example

Here is the second version of the app, which asks the sales team every piece of information the churn model needs and displays a clean result.

import streamlit as st

st.set_page_config(page_title="Churn dashboard", page_icon="📉", layout="centered")
st.title("Churn dashboard — score a customer")

st.subheader("Contract")
c1, c2 = st.columns(2)
with c1:
customer_id = st.text_input("Customer ID", placeholder="C-01847")
tenure = st.slider("Months as a customer", 0, 72, 12)
with c2:
contract = st.selectbox("Contract type", ["Month-to-month", "One year", "Two years"])
payment = st.radio("Payment method", ["Card", "Bank transfer", "Check"], horizontal=True)

st.subheader("Billing")
c3, c4 = st.columns(2)
with c3:
monthly_charge = st.number_input("Monthly bill (USD)", 0.0, 500.0, 70.0, 5.0)
paperless = st.toggle("Paperless billing", value=True)
with c4:
services = st.multiselect(
"Active services",
["Internet", "TV", "Phone", "Support+", "Cloud backup"],
default=["Internet"],
)

if st.button("Score this customer", type="primary"):
# The real model call arrives in module 8; a placeholder for now.
score = 0.5 - 0.005 * tenure + 0.002 * monthly_charge
score += {"Month-to-month": 0.15, "One year": 0.0, "Two years": -0.10}[contract]
score = max(0.0, min(1.0, score))
st.divider()
left, right = st.columns([1, 2])
left.metric("Churn probability", f"{score:.0%}", delta_color="inverse")
right.progress(score, text="Higher is riskier.")
st.caption(f"Scored customer {customer_id or 'unknown'} with {len(services)} active services.")

The type="primary" flag turns the button into the theme's accent color so the user can see it from across the room; it is a small detail that signals what the "next step" is on a busy page. st.divider inserts a thin line between the form and the result, and st.caption writes small grey text, both of which help scannability without adding a heading.

Do not gate a heavy call on a button alone

Because the whole script reruns, code outside the if st.button block also runs on every interaction. Loading a 200 MB model in module scope will therefore make every keystroke lag — the button will not save you. The right pattern is @st.cache_resource (module 5), and only then a button to trigger the prediction.

In summary

  • Widgets are functions that return the current value; there is no callback because the whole script re-runs on every interaction.
  • Prefer st.selectbox, st.multiselect and st.radio for discrete inputs; st.slider and st.number_input for continuous ones.
  • Use st.metric for KPIs and remember delta_color="inverse" for metrics where a rise is bad news; st.dataframe for interactive tables, st.table for a printable report.
  • The scoring form of the running example is complete; module 3 turns it into a real layout with a sidebar and tabs.

Next module: columns, tabs and sidebar, the three layout primitives that carry the dashboard from a scrolling page to a genuine tool.