Module 9 — Theme, appearance and usability
Eight modules of mechanics; this one is about the layer the user actually notices. A dashboard is a product, and a product that looks like a hackathon prototype does not get the same trust as one that carries the company's colors and reads like a real internal tool. This module covers the theme file, the small ergonomic gestures that matter, and the split into a multipage application — the moves that take the running example from "it works" to "sales operations opens it every morning".
The config.toml file
Almost every visible setting lives in .streamlit/config.toml, next to
your app.py. Streamlit reads it on startup and applies it to every
page.
# .streamlit/config.toml
[theme]
base = "light" # or "dark"
primaryColor = "#1F4E79" # brand navy, used on buttons and links
backgroundColor = "#FFFFFF"
secondaryBackgroundColor = "#F3F5F9" # sidebar and metric cards
textColor = "#0E1116"
font = "sans serif" # or "serif", "monospace", or a Google Font name
[server]
maxUploadSize = 500 # module 7
headless = true # do not open a browser tab on start
[browser]
gatherUsageStats = false
Three settings do the heavy lifting. primaryColor colors every accent
in the app — buttons flagged type="primary", links, focused inputs,
progress bars, chart annotations that pick up the theme. Set it to the
brand color and half of the "make it look ours" work is done. base
switches between the light and dark palettes; the app follows the user's
system preference by default, but pinning it is fair game for an
internal dashboard where consistency matters more than personalization.
And secondaryBackgroundColor is the color of the sidebar and the
metric cards — a subtle contrast against the page background makes both
easier to scan.
A logo and a favicon
st.set_page_config accepts an emoji or a path for the browser tab
icon. For the logo inside the app, st.logo (added in recent
Streamlit versions) sits above the sidebar and links to a page of your
choice.
import streamlit as st
st.set_page_config(
page_title="Churn dashboard",
page_icon="assets/favicon.png",
layout="wide",
)
st.logo(
image="assets/logo_full.png",
icon_image="assets/logo_mark.png", # smaller, used when the sidebar collapses
link="https://intranet.example.com/dashboards",
)
Two logos, not one: the full lockup fills the expanded sidebar, and the
icon_image — square, high contrast, no text — is what remains when the
user collapses the sidebar. A single wide logo shrinks poorly when the
sidebar is a drawer on mobile, and this is what fixes it.
Help, hints and small usability wins
Almost every widget accepts a help= argument that renders as a small
? tooltip. Use it for the domain question, not for the widget
mechanic.
tenure = st.slider(
"Months as a customer", 0, 72, 12,
help="From the signup date to today. Include the current month.",
)
Three more gestures matter more than they look.
Order the actions by the user's story. A form should read top to bottom in the order the user would explain their workflow: contract identity first, billing next, submit at the bottom. If the button sits above the fields, users click it before filling anything.
Signal what is primary. st.button(..., type="primary") and
st.form_submit_button(..., type="primary") color the button with the
theme's accent. Only one primary button per screen — the one action that
matters. Everything else stays a default button.
Confirm destructive actions. For a "Delete customer" button, wrap it
in st.popover or a two-step confirmation:
with st.popover("Delete customer"):
st.warning("This cannot be undone.")
if st.button("Yes, delete", type="primary"):
delete(customer_id)
An accidental click on a "Delete" button in an internal tool is a real incident. A three-line confirmation stops it.
Feedback: st.toast for background events
st.success, st.info, st.warning and st.error write into the page
and push everything below down. st.toast shows a small transient
notification in the corner — the right tool for "background finished",
"file saved", "note added", where the message does not need to stay
around.
if st.button("Save note"):
save(note)
st.toast("Note saved.", icon="✅")
The multipage application
Past a few hundred lines, a single app.py becomes hard to navigate.
Streamlit supports two multipage layouts. The pages/ folder is
the older convention and works by convention: any .py file under
pages/ becomes a page, ordered by filename. The st.navigation API
is newer and gives you programmatic control — grouping, icons, dynamic
visibility.
# app.py — router with st.navigation
import streamlit as st
score = st.Page("app_pages/score.py", title="Score a customer", icon="🎯", default=True)
batch = st.Page("app_pages/batch.py", title="Batch pipeline", icon="📦")
history = st.Page("app_pages/history.py", title="History", icon="📊")
settings = st.Page("app_pages/settings.py", title="Settings", icon="⚙️")
navigation = st.navigation({
"Predict": [score, batch],
"Explore": [history],
"Admin": [settings],
})
st.set_page_config(page_title="Churn dashboard", page_icon="📉", layout="wide")
st.logo("assets/logo_full.png", icon_image="assets/logo_mark.png")
navigation.run()
Each page file is now a small script that only handles its own concern. Session state and cached functions are shared across pages, so the scoring form's history remains available in the History page without threading it through anything.
# app_pages/score.py
import streamlit as st
from services.model import score
st.title("Score a customer")
# ... form from module 6 ...
if submitted:
result = score(features)
st.session_state.last_result = result
The services/ folder is a convenient home for the caching functions
(module 5), the file parser (module 7) and the scoring helper
(module 8). Streamlit imports it like any Python module, and unit
tests can exercise the functions without a running app.
Right-to-left languages and internationalization
For an Arabic version of this dashboard, the CSS injection needed is tiny:
# app.py
st.markdown("<style>body, .main {direction: rtl; text-align: right;}</style>",
unsafe_allow_html=True)
Streamlit itself does not include a translation layer; the pragmatic
approach is a translations.toml with the strings by locale and a
t("key") helper. For a dashboard that ships in three languages, this
adds a few hundred lines but no framework — the running example does it
in less than one screen of code.
The final version of the running example
The dashboard now has a pages/ split, a theme file, a logo, primary
actions clearly signaled, tooltips on every widget, and a toast for
non-blocking events. The single-file app.py of module 1 is now four
files and a services/ folder, but the total is still under six hundred
lines and every module of this course maps to a small piece of it.
Before pushing a Streamlit app to a live user, take a screenshot of every page at 1440 pixels wide and stare at it. Anything that looks wrong at that size — a chart that stretches too far, a KPI card next to an empty column, a sidebar with only two filters — will look worse on a smaller screen. Fifteen minutes of pruning at this stage save an afternoon of "small changes" requests after the demo.
In summary
.streamlit/config.tomlholds the theme —primaryColoralone goes a long way — plus the server and browser settings.st.logogives you a full logo and a compact icon logo;st.set_page_configsets the browser tab icon;help=on widgets is where the domain answer lives.- Signal one primary action per screen; confirm destructive ones in
a popover; use
st.toastfor messages that should not stay on the page. st.navigationand apages/folder split a longapp.pyinto a real multipage application; session state and caches are shared across pages.
Next module: deployment and access control, the final step that puts the running example in front of the sales team.