Accessible color themes for Streamlit apps
Control your app’s color scheme and visual accessibility
Control your app’s color scheme and visual accessibility
One great feature of Streamlit is theming, so you can control your app’s color scheme. But creating a new color scheme can be hard. And making it visually accessible can be even harder.
So I made a color theme editor app with four color parameters of the Streamlit themes which general-purpose color editors/generators don’t even consider: primaryColor
, backgroundColor
, secondaryBackgroundColor
, and textColor
. I also followed the color contrast guidelines from the Web Content Accessibility Guidelines (WCAG) 2.0 standard for the app’s visual accessibility.
In this post, I’ll show you how to build this app in eight steps:
- Define preset colors
- If set, load the theme config
- Manage the edited color parameters in the session state
- Set colors programmatically
- Define a composed widget of a color picker and a slider
- Use WCAG contrast table
- Show generated configs
- Apply the edited theme to the app
👉 You can assess the app here and the source code here. This article is based on the revision [7213dbb]( 7213dbb)
.
5 key components of the color theme editor app
The app consists of five components to help you create and test accessible color themes:
- “Generate a random color scheme” button. Click this button to update the color theme with randomly selected colors that maintain high contrast between foreground and background.
- Color pickers. Use these components to adjust colors and their luminance parameters. Use the luminance slider to adjust contrast while preserving the color’s aesthetic.
- WCAG contrast ratio table. Display a 2x2 matrix of contrast ratios for the selected colors and the corresponding WCAG 2.0 level (AA or AAA) for each ratio to make your color theme accessible.
- Config generator. Generate content for the
.streamlit/config.toml
config file and a shell command with color theme arguments. Just copy/paste it into your project to apply the edited theme. - “Apply theme on this page” checkbox. Check this box to apply the color theme you are editing to the whole application, letting you preview the colors in a real Streamlit app and synchronize the changes.
Step 1. Define preset colors
The app has preset colors that I picked from the Streamlit source code below:
preset_colors: list[tuple[str, ThemeColor]] = [
("Default light", ThemeColor(
primaryColor="#ff4b4b",
backgroundColor="#ffffff",
secondaryBackgroundColor="#f0f2f6",
textColor="#31333F",
)),
("Default dark", ThemeColor(
primaryColor="#ff4b4b",
backgroundColor="#0e1117",
secondaryBackgroundColor="#262730",
textColor="#fafafa",
))
]
Step 2. If set, load the theme config
This is a bit of a hack. You can access the global config object via st._config
to get the theme
config from it and to use it as a preset color:
@st.cache_resource
def get_config_theme_color():
config_theme_primaryColor = st._config.get_option('theme.primaryColor')
config_theme_backgroundColor = st._config.get_option('theme.backgroundColor')
config_theme_secondaryBackgroundColor = st._config.get_option('theme.secondaryBackgroundColor')
config_theme_textColor = st._config.get_option('theme.textColor')
if config_theme_primaryColor and config_theme_backgroundColor and config_theme_secondaryBackgroundColor and config_theme_textColor:
return ThemeColor(
primaryColor=config_theme_primaryColor,
backgroundColor=config_theme_backgroundColor,
secondaryBackgroundColor=config_theme_secondaryBackgroundColor,
textColor=config_theme_textColor,
)
return None
theme_from_initial_config = util.get_config_theme_color()
if theme_from_initial_config:
preset_colors.append(("From the config", theme_from_initial_config))
Step 3. Manage the edited color parameters in the session state
Store the RGB color values in the session state using the same keys as the color picker widgets. This lets you programmatically set the color picker values. It’s useful when updating them with new values from the random color generator. These values are synced with the color picker widgets, so their values must be compatible with the color picker’s #RRGGBB
format.
Aside from managing RGB values, you also maintain the HSL values in the session state to control the slider widgets. Using the HSL format makes it easier to edit colors in a more intuitive way. To synchronize these two formats, create a utility function named set_color
and use it every time you update the color values:
def sync_rgb_to_hls(key: str):
# HLS states are necessary for the HLS sliders.
rgb = util.parse_hex(st.session_state[key])
hls = colorsys.rgb_to_hls(rgb[0], rgb[1], rgb[2])
st.session_state[f"{key}H"] = round(hls[0] * 360)
st.session_state[f"{key}L"] = round(hls[1] * 100)
st.session_state[f"{key}S"] = round(hls[2] * 100)
def set_color(key: str, color: str):
st.session_state[key] = color
sync_rgb_to_hls(key)
if 'preset_color' not in st.session_state or 'backgroundColor' not in st.session_state or 'secondaryBackgroundColor' not in st.session_state or 'textColor' not in st.session_state:
set_color('primaryColor', default_color.primaryColor)
set_color('backgroundColor', default_color.backgroundColor)
set_color('secondaryBackgroundColor', default_color.secondaryBackgroundColor)
set_color('textColor', default_color.textColor)
Step 4. Set colors programmatically
To set the currently edited colors from the selectbox widget with the preset colors, use set_color()
:
def on_preset_color_selected():
_, color = preset_colors[st.session_state.preset_color]
set_color('primaryColor', color.primaryColor)
set_color('backgroundColor', color.backgroundColor)
set_color('secondaryBackgroundColor', color.secondaryBackgroundColor)
set_color('textColor', color.textColor)
st.selectbox("Preset colors", key="preset_color", options=range(len(preset_colors)), format_func=lambda idx: preset_colors[idx][0], on_change=on_preset_color_selected)
You can also implement a random color generator button:
if st.button("🎨 Generate a random color scheme 🎲"):
primary_color, text_color, basic_background, secondary_background = util.generate_color_scheme()
set_color('primaryColor', primary_color)
set_color('backgroundColor', basic_background)
set_color('secondaryBackgroundColor', secondary_background)
set_color('textColor', text_color)
To implement util.generate_color_scheme
, refer to this code. It generates colors with the constraint that the luminance (the "L" in the HSL format) parameters of the colors have enough difference.
Step 5. Define a composed widget of a color picker and a slider
This app provides a pair of a color picker and a slider to control the color’s luminance parameter (to adjust the color contrast while maintaining its original appearance). There are four pairs of components. Each defined by a function called color_picker
to make it reusable.
One trick here is to set the on_change
callback of each component to synchronize the RGB data and the HSL data in the session state:
def color_picker(label: str, key: str, default_color: str) -> None:
col1, col2 = st.columns([1, 3])
with col1:
color = st.color_picker(label, key=key, on_change=sync_rgb_to_hls, kwargs={"key": key})
with col2:
r,g,b = util.parse_hex(default_color)
h,l,s = colorsys.rgb_to_hls(r,g,b)
if f"{key}H" not in st.session_state:
st.session_state[f"{key}H"] = round(h * 360)
st.slider(f"L for {label}", key=f"{key}L", min_value=0, max_value=100, value=round(l * 100), format="%d%%", label_visibility="collapsed", on_change=sync_hls_to_rgb, kwargs={"key": key})
if f"{key}S" not in st.session_state:
st.session_state[f"{key}S"] = round(s * 100)
return color
primary_color = color_picker('Primary color', key="primaryColor", default_color=default_color.primaryColor)
text_color = color_picker('Text color', key="textColor", default_color=default_color.textColor)
background_color = color_picker('Background color', key="backgroundColor", default_color=default_color.backgroundColor)
secondary_background_color = color_picker('Secondary background color', key="secondaryBackgroundColor", default_color=default_color.secondaryBackgroundColor)
Step 6. Use WCAG contrast table
This table layout is created using stacked st.column()
. The content of each cell is encapsulated within a reusable helper function, such as synced_color_picker
or fragments.contrast_summary
:
col1, col2, col3 = st.columns(3)
with col2:
synced_color_picker("Background color", value=background_color, key="backgroundColor")
with col3:
synced_color_picker("Secondary background color", value=secondary_background_color, key="secondaryBackgroundColor")
col1, col2, col3 = st.columns(3)
with col1:
synced_color_picker("Primary color", value=primary_color, key="primaryColor")
with col2:
fragments.contrast_summary("Primary/Background", primary_color, background_color)
with col3:
fragments.contrast_summary("Primary/Secondary background", primary_color, secondary_background_color)
col1, col2, col3 = st.columns(3)
with col1:
synced_color_picker("Text color", value=text_color, key="textColor")
with col2:
fragments.contrast_summary("Text/Background", text_color, background_color)
with col3:
fragments.contrast_summary("Text/Secondary background", text_color, secondary_background_color)
synced_color_picker()
is just a color picker component, but its value is synchronized with the relevant color picker component that appeared above. Its value is also managed in the session state with the value
argument and the on_change
callback:
def synced_color_picker(label: str, value: str, key: str):
def on_change():
st.session_state[key] = st.session_state[key + "2"]
sync_rgb_to_hls(key)
st.color_picker(label, value=value, key=key + "2", on_change=on_change)
fragments.contrast_summary()
renders the WCAG contrast info. See this code for its implementation.
Step 7. Show generated configs
The app shows these ready-to-use code snippets and is done with st.code()
:
st.subheader("Config file (`.streamlit/config.toml`)")
st.code(f"""
[theme]
primaryColor="{primary_color}"
backgroundColor="{background_color}"
secondaryBackgroundColor="{secondary_background_color}"
textColor="{text_color}"
""", language="toml")
st.subheader("Command line argument")
st.code(f"""
streamlit run app.py \\
--theme.primaryColor="{primary_color}" \\
--theme.backgroundColor="{background_color}" \\
--theme.secondaryBackgroundColor="{secondary_background_color}" \\
--theme.textColor="{text_color}"
""")
Step 8. Apply the edited theme to the app
For reviewing purposes, this app can apply the currently edited theme to itself. You can do it with the st._config
object, as shown below:
if st.checkbox("Apply theme to this page"):
st.info("Select 'Custom Theme' in the settings dialog to see the effect")
def reconcile_theme_config():
keys = ['primaryColor', 'backgroundColor', 'secondaryBackgroundColor', 'textColor']
has_changed = False
for key in keys:
if st._config.get_option(f'theme.{key}') != st.session_state[key]:
st._config.set_option(f'theme.{key}', st.session_state[key])
has_changed = True
if has_changed:
st.experimental_rerun()
reconcile_theme_config()
fragments.sample_components("body")
with st.sidebar:
fragments.sample_components("sidebar")
Use st._config.set_option()
to update the configuration values. Reload the app for the changes to take effect by using st.experimental_rerun()
. It was inspired by jrieke/streamlit-theme-generator
(here is the code).
Wrapping up
The Streamlit color theme editor app offers a simple and effective solution for creating visually appealing and accessible color themes for your apps. Thanks to the WCAG 2.0 standard and the real-time preview, your themes will now be attractive and accessible to all users.
Happy app-building! 🧑💻