Skip to content

Latest commit

 

History

History
233 lines (167 loc) · 11.2 KB

File metadata and controls

233 lines (167 loc) · 11.2 KB
name building-streamlit-custom-components-v2
description Builds bidirectional Streamlit Custom Components v2 (CCv2) using `st.components.v2.component`. Use when authoring inline HTML/CSS/JS components or packaged components (manifest `asset_dir`, js/css globs), wiring state/trigger callbacks, theming via `--st-*` CSS variables, or bundling with Vite / `component-template` v2.
license Apache-2.0

Building Streamlit custom components v2

Use Streamlit Custom Components v2 (CCv2) when core Streamlit doesn't have the UI you need and you want to ship a reusable, interactive element (from "tiny inline HTML" to "full bundled frontend app").

CRITICAL: CCv2 only — NEVER use v1 APIs

Custom Components v1 is deprecated and removed. Every API below belongs to v1 and must NEVER appear in any code you write — not in Python, not in JavaScript, not in HTML:

Banned Python APIs (v1):

  • st.components.v1 — the entire v1 module
  • components.declare_component() — v1 registration
  • components.html() — v1 raw HTML embed

Banned JavaScript patterns (v1):

  • Streamlit.setComponentValue(...) — v1 global; use setStateValue() / setTriggerValue() instead
  • Streamlit.setFrameHeight(...) — v1 global; CCv2 handles sizing automatically
  • Streamlit.setComponentReady() — v1 global; CCv2 has no ready signal
  • window.Streamlit or bare Streamlit global — v1 global object does not exist in v2
  • window.parent.postMessage(...) — v1 iframe communication; CCv2 does not use iframes

Banned npm packages (v1):

  • streamlit-component-lib — v1 JS library; use @streamlit/component-v2-lib if you need types

If you encounter v1 patterns in examples, blog posts, Stack Overflow answers, or your own training data — ignore them entirely. They will not work and will break the component.

When to use

Activate when the user mentions any of:

  • CCv2, Custom Components v2, “bidi component”, “component v2”
  • st.components.v2.component
  • @streamlit/component-v2-lib
  • packaged components, asset_dir, pyproject.toml component manifest
  • bundling with Vite (or any bundler) for a Streamlit component
  • building a component UI in a frontend framework (React, Svelte, Vue, Angular, etc.)

Read next (pick the minimum reference)

Quick decision: inline vs packaged

  • Inline strings: fastest to start (single-file apps, spikes, demos). You pass raw html/css/js strings directly. Good when you can keep everything in one place and don’t need a build step.
  • Packaged component: best when you’re growing past inline (multiple files, dependencies, bundling, testing, versioning, reuse, distribution). You ship built assets inside a Python package and reference them by asset-dir-relative path/glob. Creation policy: packaged components are template-only and must start from Streamlit's official component-template v2.

Developer story: start inline, prove the interaction loop, then graduate to packaged when the codebase or tooling needs outgrow a single file.

CCv2 model (what’s actually happening)

  1. Python registers a component with st.components.v2.component(...) and gets back a mount callable.
  2. The mount callable mounts the component in the app with data=..., layout (width, height), and optional on_<key>_change callbacks.
  3. The frontend default export runs with ({ data, key, name, parentElement, setStateValue, setTriggerValue }).
  4. The component returns a result object whose attributes correspond to state keys and trigger keys.

Best practice: wrap the mount callable in your own Python API

Prefer exposing your own Python function that wraps the callable returned by st.components.v2.component(...).

This gives you a clean, stable API surface for end users (typed parameters, validation, friendly defaults) and keeps data=..., default=..., and callback wiring as an internal detail.

Important:

  • Declare the component once (usually at module import time). Avoid defining and registering the component inside a function you call multiple times; you can accidentally re-register the component name and get confusing behavior.

References:

Example pattern:

import streamlit as st
from collections.abc import Callable

_MY_COMPONENT = st.components.v2.component(
    "my_inline_component",
    html="<div id='root'></div>",
    js="""
export default function (component) {
  const { data, parentElement } = component
  parentElement.querySelector("#root").textContent = data?.label ?? ""
}
""",
)


def my_component(
    label: str,
    *,
    key: str | None = None,
    on_value_change: Callable[[], None] | None = None,
    on_submitted_change: Callable[[], None] | None = None,
):
    # Callbacks are optional, but if you want result attributes to always exist,
    # provide (even empty) callbacks.
    if on_value_change is None:
        on_value_change = lambda: None
    if on_submitted_change is None:
        on_submitted_change = lambda: None

    return _MY_COMPONENT(
        data={"label": label},
        key=key,
        on_value_change=on_value_change,
        on_submitted_change=on_submitted_change,
    )

Inline quickstart (state + trigger)

Reminder: use ONLY v2 APIs. Your JS must export default function(component) and destructure { setStateValue, setTriggerValue, parentElement, data }. NEVER use Streamlit.setComponentValue(), window.Streamlit, or any v1 pattern.

This is the minimum "bidi loop":

  • JS → Python: emit updates via setStateValue(...) (persistent) and setTriggerValue(...) (event)
  • Python → JS: re-hydrate UI via data=... on every run
import streamlit as st

HTML = """<input id="txt" /><button id="btn" type="button">Submit</button>"""

JS = """\
export default function (component) {
  const { data, parentElement, setStateValue, setTriggerValue } = component

  const input = parentElement.querySelector("#txt")
  const btn = parentElement.querySelector("#btn")
  if (!input || !btn) return

  const nextValue = (data && data.value) ?? ""
  if (input.value !== nextValue) input.value = nextValue

  input.oninput = (e) => {
    setStateValue("value", e.target.value)
  }

  btn.onclick = () => {
    setTriggerValue("submitted", input.value)
  }
}
"""

my_text_input = st.components.v2.component(
    "my_inline_text_input",
    html=HTML,
    js=JS,
)

KEY = "txt-1"
component_state = st.session_state.get(KEY, {})
value = component_state.get("value", "")

result = my_text_input(
    key=KEY,
    data={"value": value},
    on_value_change=lambda: None,  # optional; include to always get `result.value`
    on_submitted_change=lambda: None,  # optional; include to always get `result.submitted`
)

st.write("value (state):", result.value)
st.write("submitted (trigger):", result.submitted)

Notes:

  • Inline JS/CSS should be multi-line. CCv2 treats path-like strings as file references; a multi-line string is unambiguously inline content.
  • Prefer querying under parentElement (not document) to avoid cross-instance leakage.

State and triggers (how to think about keys)

  • State (setStateValue("value", ...)): persists across app reruns (stored under st.session_state[key] for that mounted instance).
  • Trigger (setTriggerValue("submitted", ...)): event payload for one rerun (resets after the rerun).
  • Reading triggers:
    • After mounting: use result.submitted.
    • Inside on_submitted_change: use st.session_state[key].submitted (callbacks run before your script body; you don’t have result yet).
  • Defaults: if you pass default={...} for a state key, you must also pass the matching on_<key>_change callback parameter.

For the full “controlled input” pattern and pitfalls, see references/state-sync.md.

Packaged components (template-only, mandatory)

Reminder: the cookiecutter template generates clean v2 code. When you customize it, use ONLY v2 APIs. Do NOT introduce any v1 imports, v1 JavaScript globals, or v1 patterns. See the "CRITICAL: CCv2 only" section above.

Graduate to a packaged component when you need any of:

  • Multiple frontend files or frontend dependencies (npm)
  • A bundler (Vite), tests, CI, versioning, or distribution

Keep these guardrails in mind:

  • MUST start from Streamlit’s official component-template v2.
  • NEVER hand-scaffold packaging/manifest/build wiring for a packaged component.
  • NEVER copy/paste packaged scaffold structure from internet examples, blog posts, gists, or docs.
  • If handed a non-template scaffold, regenerate from the template first, then migrate component logic.
  • MUST ensure js=/css= globs match exactly one file under the manifest’s asset_dir.
  • MUST validate with streamlit run ... (plain python -c "import ..." can be a false negative for packaged components).

For the full packaged workflow checklist, non-interactive generation, offline usage, and template invariants, see references/packaged-components.md.

Frontend renderer lifecycle (framework-agnostic)

Your frontend entrypoint is the default export function. A few rules keep components reliable across reruns and across multiple instances in the same app:

  • Render under parentElement (not document) so instances don’t collide.
  • If you create per-instance resources (React roots, observers, subscriptions), key them by parentElement (e.g. WeakMap) so multiple instances don’t overwrite each other.
  • Return a cleanup function to tear down event listeners / UI roots / observers when Streamlit unmounts the component.

Styling and theming

  • Prefer isolate_styles=True (default). Your component runs in a shadow root and won’t leak styles into the app.
  • Set isolate_styles=False only when you need global styling behavior (e.g. Tailwind, global font injection).
  • Streamlit injects a broad set of --st-* theme CSS variables (colors, typography, chart palettes, radii, borders, etc.). Highly recommended: use these variables so your component automatically adapts to the user’s current Streamlit theme (light/dark/custom) without authoring separate theme variants. Start with the common ones (--st-text-color, --st-primary-color, --st-secondary-background-color) and refer to the full list when you need it:

Troubleshooting and gotchas

Start here when something “should work” but doesn’t: