Skip to main content

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.

Ship a screenshot before you ship a URL

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.toml holds the themeprimaryColor alone goes a long way — plus the server and browser settings.
  • st.logo gives you a full logo and a compact icon logo; st.set_page_config sets 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.toast for messages that should not stay on the page.
  • st.navigation and a pages/ folder split a long app.py into 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.