サイトアイコン アマチュア無線局JS2IIU

【Streamlit】UIを自由に拡張する:st.components.v2.component徹底解説と実践コード

こんにちは、JS2IIUです。
StreamlitはPythonで手軽にWebアプリケーションを構築できるフレームワークとして、多くの機械学習・データ分析エンジニアに支持されています。標準のウィジェットだけでも多くのアプリは構築できますが、「オリジナルのUIを作りたい」「JavaScriptライブラリの機能を使いたい」と感じる場面も少なくありません。

例えば、独自のインタラクティブ可視化が必要なときや、複雑な操作が可能なUIコンポーネントを統合したいとき、あるいはデータサイエンス用の特殊な入力インターフェースを作りたいとき、標準のStreamlit APIだけでは対応が難しいことがあります。

この課題を解決するのが、Streamlitの st.components.v2.component です。この機能を使うことで、HTML・CSS・JavaScript を組み合わせたカスタムコンポーネントを自作し、Streamlitアプリに統合できます。つまり、StreamlitのUIを自分仕様に拡張できる強力な方法です。

本記事では、この st.components.v2.component を基礎から丁寧に解説しながら、最小構成のコンポーネントから、機械学習アプリへ応用できる実践的な例まで紹介します。今回もよろしくお願いします。

st.components.v2.component とは何か

st.components.v2.component – Streamlit Docs

Streamlitのバージョンに注意

Streamlitのバージョンに注意が必要です。この記事を書いている2025年11月現在、Streamlitのバージョンは1.51.0です。インストール済みのStreamlitのバージョンを確認するには、

Bash
streamlit --version

もし古いバージョンを使っている場合は、以下のコマンドでアップデートして下さい。

Bash
python -m pip install --upgrade streamlit

以下のように表示されればアップデート成功です。

Plaintext
Successfully installed streamlit-1.51.0

コンポーネント定義とマウントの概念

st.components.v2.component は、「カスタムコンポーネントを定義するための関数」です。この関数を呼ぶと、コンポーネントをストリームリットアプリ上に配置(マウント)するための「呼び出し可能なオブジェクト」が返ってきます。

つまり、次のような構造になっています。

  1. component(…) で「名前・HTML・CSS・JS」を指定し、コンポーネント定義を作る
  2. 戻り値の callable を使ってアプリ内の好きな場所に表示する

この「定義」と「マウント」が分離している点は重要です。定義をループ中で毎回行ってしまうと、同名コンポーネントの再定義が発生し、意図しない挙動になることがあります。

パラメータの詳細

Python
component(name, *, html=None, css=None, js=None)

主な役割は次のとおりです。

注意点として、html と js の両方を None にすることはできません。少なくともどちらか必要です。

呼び出し時に返されるオブジェクトは BidiComponentResult という双方向データ通信を持つオブジェクトで、JavaScript側から送られるデータやトリガーを受け取れます。これにより、Streamlitアプリ内で高度なインタラクティブUIを構築できます。

基本的な使い方

まずは、最小構成でコンポーネントを作る方法を説明します。ここでは「クリックするとメッセージを返すだけの簡単なコンポーネント」を作って動作を理解します。

最小構成のカスタムコンポーネント(HTMLとJSのみ)

以下のコードでは、ボタンがクリックされると、その情報をStreamlit側に返すコンポーネントを定義しています。

Streamlit 側のコード

Python
import streamlit as st

JS = """
export default function(component) {
    const { setTriggerValue } = component;
    const links = document.querySelectorAll('a[href="#"]');

    links.forEach((link) => {
        link.onclick = (e) => {
            setTriggerValue('clicked', link.innerHTML);
        };
    });
}
"""

my_component = st.components.v2.component(
    "inline_links",
    js=JS,
)

result = my_component(on_clicked_change=lambda: None)

st.markdown(
    "こちらをクリックして下さい [リンク](#)."
)

if result.clicked:
    st.write(f"{result.clicked} がクリックされました。")

コード解説

このように、非常にシンプルな UI でも、HTML と JavaScript を組み合わせることで自由にコンポーネントを作れます。

実践:クリック可能な SVG 図形コンポーネント

ここでは、より実践的な例として「SVG図形をクリックするとどの図形をクリックしたかをPython側に返すカスタムコンポーネント」を作成します。

機械学習アプリでは、モデルの推論結果をSVG上に表示したり、ユーザーが領域をクリックしてアノテーションするなどの用途があります。この仕組みはその基礎として非常に応用しやすいものです。

作るものの概要

コンポーネントのHTML + JavaScript

Python
import streamlit as st


# コンポーネント登録を再実行時に重複させないようキャッシュ
@st.cache_resource
def _get_clickable_svg():
    return st.components.v2.component(
        name="clickable_svg",
        html="""
            <svg width="200" height="100">
                <circle id="circle" cx="50" cy="50" r="30" fill="lightblue"></circle>
                <rect id="rect" x="120" y="20" width="60" height="60" fill="lightgreen"></rect>
            </svg>
        """,
        js="""
            // Improved v2 component: handle cases where Streamlit passes a component object
            // as the first argument (it contains setTriggerValue and parentElement).
            export default function(rootOrComponent, props) {
                try { console.log('[component] mounted. arg0=', rootOrComponent, 'props=', props); } catch (e) {}

                let component = null;
                let root = null;
                let trigger = null;

                // If first arg is the component object provided by Streamlit v2
                if (rootOrComponent && typeof rootOrComponent === 'object' && typeof rootOrComponent.setTriggerValue === 'function') {
                    component = rootOrComponent;
                    trigger = component.setTriggerValue.bind(component);
                    // mount into the provided parentElement if available
                    root = component.parentElement || document.getElementById('clickable_svg_root') || document.body;
                    console.log('[component] detected component object, parentElement=', component.parentElement);
                } else {
                    // otherwise first arg is the root DOM element
                    root = rootOrComponent || document.getElementById('clickable_svg_root') || document.body;
                    if (props && typeof props.setTriggerValue === 'function') trigger = props.setTriggerValue.bind(props);
                }

                function sendClicked(name) {
                    try {
                        console.log('[component] sendClicked', name, 'using trigger=', !!trigger);
                        if (trigger) {
                            trigger('clicked', name);
                            console.log('[component] used trigger/setTriggerValue');
                            return;
                        }
                        if (typeof sendMessage === 'function') {
                            sendMessage({clicked: name});
                            console.log('[component] used global sendMessage');
                            return;
                        }
                        if (props && typeof props.sendMessage === 'function') {
                            props.sendMessage({clicked: name});
                            console.log('[component] used props.sendMessage');
                            return;
                        }
                        if (window && window.parent && typeof window.parent.postMessage === 'function') {
                            window.parent.postMessage({type: 'streamlit:component', value: {clicked: name}}, '*');
                            console.log('[component] used postMessage fallback');
                            return;
                        }
                        console.log('[component] no send method available');
                    } catch (e) { console.error('[component] sendClicked error', e); }
                }

                // find elements inside the root (root should be a DOM node)
                let circle = null;
                let rect = null;
                try {
                    if (root && typeof root.querySelector === 'function') {
                        circle = root.querySelector('#circle');
                        rect = root.querySelector('#rect');
                    }
                } catch (e) { console.error(e); }
                if (!circle) circle = document.getElementById('circle');
                if (!rect) rect = document.getElementById('rect');

                console.log('[component] found elements:', {circle, rect});

                function markClicked(el) {
                    try {
                        el.style.transition = 'fill 0.15s';
                        const prev = el.getAttribute('fill');
                        el.setAttribute('fill', '#ffcc00');
                        setTimeout(() => el.setAttribute('fill', prev), 200);
                    } catch (e) {}
                }

                if (circle) circle.addEventListener('click', (e) => { markClicked(circle); sendClicked('circle'); });
                if (rect) rect.addEventListener('click', (e) => { markClicked(rect); sendClicked('rect'); });

                // cleanup
                return () => {
                    try {
                        if (circle) circle.replaceWith(circle.cloneNode(true));
                        if (rect) rect.replaceWith(rect.cloneNode(true));
                    } catch (e) {}
                };
            }
        """
    )


clickable_svg = _get_clickable_svg()

result = clickable_svg(on_clicked_change=lambda: None)

st.markdown("以下の SVG 図形をクリックして下さい。")
if result and result.clicked:
    st.write(f"{result.clicked} がクリックされました。")

ポイント解説

Streamlit 側での呼び出しと PyTorch 推論と組み合わせる例

以下は PyTorch の簡単な分類モデルと組み合わせた例です。

Python
import streamlit as st
import torch
import torch.nn as nn

# PyTorchの簡易モデル
class SimpleModel(nn.Module):
    def __init__(self):
        super().__init__()
        self.fc = nn.Linear(2, 2)  # circle/rect を 2クラス分類するためのダミー

    def forward(self, x):
        return self.fc(x)

model = SimpleModel()

# コンポーネント登録を再実行時に重複させないようキャッシュ
@st.cache_resource
def _get_clickable_svg():
    return st.components.v2.component(
        name="clickable_svg",
        html="""
            <svg width="200" height="100">
                <circle id="circle" cx="50" cy="50" r="30" fill="lightblue"></circle>
                <rect id="rect" x="120" y="20" width="60" height="60" fill="lightgreen"></rect>
            </svg>
        """,
        js="""
            // Improved v2 component: handle cases where Streamlit passes a component object
            // as the first argument (it contains setTriggerValue and parentElement).
            export default function(rootOrComponent, props) {
                try { console.log('[component] mounted. arg0=', rootOrComponent, 'props=', props); } catch (e) {}

                let component = null;
                let root = null;
                let trigger = null;

                // If first arg is the component object provided by Streamlit v2
                if (rootOrComponent && typeof rootOrComponent === 'object' && typeof rootOrComponent.setTriggerValue === 'function') {
                    component = rootOrComponent;
                    trigger = component.setTriggerValue.bind(component);
                    // mount into the provided parentElement if available
                    root = component.parentElement || document.getElementById('clickable_svg_root') || document.body;
                    console.log('[component] detected component object, parentElement=', component.parentElement);
                } else {
                    // otherwise first arg is the root DOM element
                    root = rootOrComponent || document.getElementById('clickable_svg_root') || document.body;
                    if (props && typeof props.setTriggerValue === 'function') trigger = props.setTriggerValue.bind(props);
                }

                function sendClicked(name) {
                    try {
                        console.log('[component] sendClicked', name, 'using trigger=', !!trigger);
                        if (trigger) {
                            trigger('clicked', name);
                            console.log('[component] used trigger/setTriggerValue');
                            return;
                        }
                        if (typeof sendMessage === 'function') {
                            sendMessage({clicked: name});
                            console.log('[component] used global sendMessage');
                            return;
                        }
                        if (props && typeof props.sendMessage === 'function') {
                            props.sendMessage({clicked: name});
                            console.log('[component] used props.sendMessage');
                            return;
                        }
                        if (window && window.parent && typeof window.parent.postMessage === 'function') {
                            window.parent.postMessage({type: 'streamlit:component', value: {clicked: name}}, '*');
                            console.log('[component] used postMessage fallback');
                            return;
                        }
                        console.log('[component] no send method available');
                    } catch (e) { console.error('[component] sendClicked error', e); }
                }

                // find elements inside the root (root should be a DOM node)
                let circle = null;
                let rect = null;
                try {
                    if (root && typeof root.querySelector === 'function') {
                        circle = root.querySelector('#circle');
                        rect = root.querySelector('#rect');
                    }
                } catch (e) { console.error(e); }
                if (!circle) circle = document.getElementById('circle');
                if (!rect) rect = document.getElementById('rect');

                console.log('[component] found elements:', {circle, rect});

                function markClicked(el) {
                    try {
                        el.style.transition = 'fill 0.15s';
                        const prev = el.getAttribute('fill');
                        el.setAttribute('fill', '#ffcc00');
                        setTimeout(() => el.setAttribute('fill', prev), 200);
                    } catch (e) {}
                }

                if (circle) circle.addEventListener('click', (e) => { markClicked(circle); sendClicked('circle'); });
                if (rect) rect.addEventListener('click', (e) => { markClicked(rect); sendClicked('rect'); });

                // cleanup
                return () => {
                    try {
                        if (circle) circle.replaceWith(circle.cloneNode(true));
                        if (rect) rect.replaceWith(rect.cloneNode(true));
                    } catch (e) {}
                };
            }
        """
    )


clickable_svg = _get_clickable_svg()

# SVGコンポーネントをマウント
result = clickable_svg(key="svg1")

st.write("図形をクリックしてください。")

if result and result.get("clicked"):
    shape = result["clicked"]
    st.write(f"{shape} がクリックされました。")

    # クリックされた図形に応じてTensorを作成
    x = torch.tensor([[1.0, 0.0]]) if shape == "circle" else torch.tensor([[0.0, 1.0]])

    # 推論
    with torch.no_grad():
        output = model(x)
        st.write("PyTorchモデルの出力:", output.numpy())

コードの流れ

このように、カスタムUIとPyTorchモデルを自然に組み合わせられるのが st.components.v2.component の強みです。

応用:機械学習アプリへの応用アイデア

コンポーネントを使うと Streamlit アプリの表現力が大きく広がります。以下は応用可能な例の一部です。

独自インタラクティブ可視化コンポーネント

PyTorch と連携したアプリ例

Streamlit の標準機能では難しい UI が、HTML/CSS/JS を組み合わせることで自由に構築できます。

まとめ

本記事では、Streamlit の st.components.v2.component を活用し、独自のUIコンポーネントを作成する方法を丁寧に解説しました。

ポイントを整理すると以下の通りです。

最後まで読んでいただきありがとうございました。

モバイルバージョンを終了