When no built-in element or community component fits, a custom component adds any web UI to your app.
There are three levels of customization.
Static HTML: st.html(markup) inserts HTML (and CSS in <style> tags) into the page without an iframe; scripts are not executed by default. st.markdown(..., unsafe_allow_html=True) allows inline HTML in Markdown. Good for badges, custom styling and small visual tweaks.
Bidirectional components (v2): st.components.v2.component(name, html=..., css=..., js=...) registers a component and returns a mount function. The JavaScript exports a default function that receives a component object with:
data: values passed from Python when mounting;parentElement: where to render (a shadow root whenisolate_styles=True, the default);setStateValue(key, value): persistent state, available in Python as an attribute of the result and in session state;setTriggerValue(key, value): a one-shot event, like a button click.
In Python, result = my_component(data=..., key="c1", on_clicked_change=callback) returns the component's state; passing an on_<key>_change callback opts into that value. Packaged components can ship asset files declared in pyproject.toml.
Legacy components (v1): components.v1.html renders HTML in a sandboxed iframe, and declare_component with a React or plain-JS front end built separately communicates through Streamlit.setComponentValue. Many community packages still use v1.
Components are trusted code: Streamlit does not sanitize their HTML or JS. Never inject user input, query parameters or LLM output into them without escaping.
Example
import streamlit as st
JS = '''
export default function(component) {
const { data, parentElement, setTriggerValue, setStateValue } = component;
const box = document.createElement("div");
box.innerHTML = data.options
.map(o => `<button data-v="${o}">${o}</button>`).join(" ");
box.querySelectorAll("button").forEach(b => {
b.onclick = () => { setStateValue("choice", b.dataset.v);
setTriggerValue("clicked", b.dataset.v); };
});
parentElement.appendChild(box);
}
'''
picker = st.components.v2.component("choice_picker", js=JS,
css="button { margin: 2px; }")
result = picker(data={"options": ["small", "medium", "large"]}, key="size",
on_choice_change=lambda: None, on_clicked_change=lambda: None)
st.write("Persistent choice:", result.choice)
if result.clicked:
st.toast(f"Clicked {result.clicked}")
st.html("<span style='color:#E8383D'>Static HTML, no iframe</span>")Remember
st.htmlinserts static HTML/CSS without an iframest.components.v2.componentregisters HTML/CSS/JS with state and trigger values- Components are trusted code: escape anything user-provided
Watch out for
- Interpolating user input or LLM output into a component's HTML, creating a cross-site scripting hole.