NTTドコモR&Dの技術ブログです。

コピペで使える!UX向上のためのStreamlitのCSSをカスタマイズするテクニック

もくじ

1. はじめに

こんにちは、ドコモ・テクノロジ*1の小泉です。私はドコモグループにおけるデータ活用促進に関わる内製開発や、新たな機械学習技術の商用化に関わる研究開発を行っています。一昨年、昨年とStreamlitそのものの活用に関するノウハウを紹介してきましたが、今年もまたより使い込んできた経験を踏まえたテクニックを紹介できれば思います。

さて、ドコモグループでは、Snowflake/Streamlitを用いたデータ活用の民主化を進める活動が推進されています。Streamlitは、データ分析・データサイエンスに関するスキルを強みとする技術者が、アプリの作成者という立場も兼ね備えてスピーディに自身の成果を共有できるフレームワークとして活用され、世間からも一定の評価を得ています。しかし、ことデザインやUI/UXを意識した細かいカスタマイズついては、Web系技術の有識者からすると、標準のままでは機能が限定的なのが現状かと思います。そこで、今年はCSSを活用してUI/UXを改善するいくつかのTipsを紹介したいと思います。安心してください。HTML/CSSに詳しくなくとも、Streamlitの特長そのままにPythonコードだけで完結できます。本記事に掲載したPython関数をst.html()の中に入れてあげるだけで、アプリのUIに直ぐ反映することができます。

本記事は、巷のWebサイトでよく紹介されている入門的なStreamlitアプリの実装からもう一方踏み込んで、よりUI/UXにも気を遣ったWebアプリやデータ活用アプリを作れるようになりたい、と考えているPythonユーザを対象としています。

2. Streamlitでスタイルをカスタマイズする方法

Streamlitは開発者がHTML/CSSを全く意識せずとも統一感のあるシンプルなデザインでアプリを実装できるように設計されています。一方で、必要に応じてその書式やスタイルをCSSをカスタマイズできることをご存知でしょうか。Chromeの『デベロッパーツール』やEdgeの『開発者ツール』(英名はどちらも『DevTools』)でHTMLのソースを確認するとわかりますが、Streamlitアプリを構成するUIのHTML構造を確認すると、CSSをカスタマイズするのに便利な属性値がいくつも散りばめられています。参考として図1にChromeでDevToolsを開く手順を示します(Edgeもほぼ同様)。

図2. ChromeでDevToolsを開く手順とDevToolsの画面構成

具体的な属性値としてはdata-testiddata-basewebkindroleなどです。これらの属性値を指定してCSSを記述することで、狙ったUI要素の種類に限定したカスタマイズができます。また、Streamlitのウィジェット関数で引数keyを指定してあげることで、当該ウィジェットのHTMLタグのclass属性にst-key-{引数keyに指定した値}という属性値が付与される仕組みになっているため、同じ種類のウィジェットでも一つ一つ狙い撃ちでもカスタマイズできます。さらに、引数keyは、他のあらゆるUIを収容できるコンテナst.container()でも指定できるため、StreamlitのあらゆるウィジェットやUI構成要素に対する狙い撃ちが可能です。「CSS」と聞くと「詳しくないから無理かも」と感じられる方もいるかもしれませんが、本記事では、CSSカスタマイズ機能をPython関数として記述しているため、あまりCSSに関する学習コストをかけること無く、コピー&ペーストで利用できるように配慮しましたので、ぜひ気軽に興味本位で読んでもらえると嬉しいです。

ここから少しだけ細かい話ですので、興味が無ければ読み飛ばしてください。CSSの詳しい書き方や値の意味については、MDNのWebサイトに全て載っています。最新のCSS3の仕様では、HTML同様に親子関係を入れ子で記述できるようになっており、一旦CSSの仕組みを理解してしまえば比較的簡単に自由度高くカスタマイズできますので、興味が湧いた方は調べてみるとよいと思います。

さて、前置きが長くなってしまいましたが、次のセクションから具体的なテクニックを紹介して行きます。

3. アプリ全体の見た目をカスタマイズする

まずは、Streamlitアプリ全体のレイアウト変更に関わるトピックを3つ紹介します。

3.1. 余白を減らしてコンテンツを配置できる面積を増やす

Streamlit標準の余白の設定は、標準で提供されているst.set_page_config(layout="wide")を指定してもなお、かなり余裕をもった余白(padding)が設定されています。しかし、限られた画面領域を効率よく使いたいということもあるでしょう。まずはじめにこれを実現するCSSを紹介します。

余白を減らすCSSのPython関数

def reduce_outer_margin_css() -> str:
    _css_rows = [
        # 余白を減らす(上 左右 下)
        '[data-testid="stMainBlockContainer"] { padding: 0 1rem 1rem }',
        # ヘッダの背景を透明化
        '[data-testid="stHeader"] { background: transparent }',
    ]
    return "\n".join(_css_rows)

図3-1. 余白を減らすCSS適用Before/After

ウィンドウの上下左右にあった余白が詰まって、コンテンツがウィンドウいっぱいに表示されるようになりました。

3.2. スクロールレスの1ページUIにする

一人一台以上のスマートフォンを持つのが当たり前になった昨今、Webページの大半は垂直スクロールを前提として構成されています。しかし、画面領域に余裕のあるPCで利用してもらうことを想定したデータ活用ツールの場合、全てのUIが収まりよく1ページの中に配置されていた方が、ユーザにとって分かり易いUIになる場合もあるかと思います。そこで、2番目にスクロールを伴わずに1ページ完結して表示するUIを実現するCSSを紹介します。ただし、必ずしも垂直スクロールができなくなるわけではなく、ビュー遷移用の操作UIをウィンドウ下部に固定表示できることが最も重要な特長ですので、中央のメインコンテンツを表示する部分に絞っての垂直スクロールは可能です。

1ページUIにするCSSのPython関数

def fit_on_one_page_css(key: str) -> str:
    """フッタエリアをウィンドウ下端に固定するCSSを記述する
    Args:
        key     (str)   : メインエリアのコンテナのキー
    Returns:
        (str)   : styleタグのCSSの記述
    """
    _css_rows = [
        # 高さをウィンドウ一杯に固定する
        '[data-testid="stMainBlockContainer"] { min-height: 100%; & > div { height: 100% } }',
        # メインコンテンツが溢れたら垂直スクロールにする
        f":has(> .st-key-{key}) " + "{ flex: 1; overflow-y: scroll; }",
        # ヘッダのテキストがメインメニューを開くボタンと重ならないようにst.headerの右側を2文字分開ける
        '[data-testid="stHeadingWithActionElements"] { padding-right: 2rem }',
        # サイドバーが隠蔽されている場合に限り、サイドバーを表示するボタンとの重なりを回避するために、コンテンツ冒頭のst.headerの左側を2文字分開ける
        'header:has([data-testid="stExpandSidebarButton"]) + section '
        '[data-testid="stMainBlockContainer"] > div > :first-child '
        '[data-testid="stHeadingWithActionElements"] { padding-left: 2rem }',
    ]
    return "\n".join(_css_rows)

図3-2. 1ページUIにするCSS適用Before/After

図ではコンテンツが最後まで見えていないのでわかり辛いですが、ヘッダ部分とコンテンツの一番最後にあったボタン群がウィンドウ下端に固定表示され、中央のコンテンツだけを縦スクロールで表示するようになりました。

3.3. 日付入力UIを和訳する

Streamlitで日付入力UIと言えばst.date_input()です。しかしこのst.date_input()、使い易いカレンダーUIにはなっているものの全てが英語表記です。これを全て日本語表記にできれば、より幅広いユーザに提供するアプリとして便利に使っていただけるでしょう。そこでこの和訳のCSSを紹介します。

日付入力UIを和訳するCSSのPython関数

def japanese_calendar_css() -> str:
    """カレンダーUIを日本語化するCSSを記述する"""
    dow = "Sun,Mon,Tue,Wed,Thu,Fri,Sat".split(",")
    months = "Jan,Feb,Mar,Apr,May,Jun,Jul,Aug,Sep,Oct,Nov,Dec".split(",")
    periods = "1週間,1か月,3か月,6か月,1年,2年".split(",")
    _css_rows = [
        '[data-baseweb="calendar"] {',
        # (1) カレンダーの年選択と月選択の順序を入れ替えと文字の付加
        "    button:nth-child(1) { order: 1; }  /* 前の月へ移動するボタン */",
        '    button:nth-child(3) { order: 2; & > span::before { content: "年" } }  /* 年選択ボタン */',
        '    button:nth-child(2) { order: 3; & > span::before { content: "月" } }  /* 月選択ボタン */',
        "    button:nth-child(4) { order: 4; }  /* 次の月へ移動するボタン */",
        "    button > span::before { margin: 0 0.5rem 0 -0.5rem; font-size: 12pt; }  /* 位置とフォントサイズを指定 */",     
        # (2) カレンダーの日付文字の色を変更
        '    [role="row"] {',
        "        > :first-child { color: red }  /* 日曜日 */",
        "        > :last-child { color: blue }  /* 土曜日 */",
        '        > [aria-label*="Selected"] { color: white }  /* 選択中 */',
        "    }",
        '    [role="presentation"] {',
        # (3) カレンダーの曜日表示を日本語表記に置換
        "        > div { font-size: 0; &::before { font-size: 12pt; } }  /* フォントサイズで元のテキストを消して代替テキストを表示 */",
        *[
            f'        > [alt^="{e}"]::before {{ content: "{j}" }}  /* {e[:2]} */'
            for e, j in zip(dow, list("日月火水木金土"))
        ],
        '        > [alt^="Sun"]::before { color: red }',
        '        > [alt^="Sat"]::before { color: blue }',
        "    }",
        # (4) カレンダーの月表示を日本語表記に置換
        "    button:nth-child(2) { font-size: 0; &::before { font-size: 12pt; } }  /* フォントサイズで元のテキストを消して代替テキストを表示 */",
        # カレンダー部分の日付文字の要素を参照して置換方法を分岐
        *[
            f'    &:has([aria-label*="{s}"]) button:nth-child(2)::before {{ content: "{n}" }}'
            for n, s in enumerate(months, start=1)
        ],
        # (5) ラベル『Choose a date range』を日本語化
        '    label[data-baseweb="form-control-label"] {',
        "        font-size: 0;  /* 元のテキストをフォントサイズゼロにして消す */",
        '        &::before { font-size: 12pt; content: "日付期間を選択"; }',
        "    }",
        # (6) Choose a date rangeの無選択状態のNoneを日本語化
        '    [data-baseweb="select"] > div > div > div:nth-child(2) {',
        "        font-size: 0;  /* 元のテキストをフォントサイズゼロにして消す */",
        # 何も選択していない状態に限って「未選択」と表示するために擬似クラスの:notと:hasを組み合わせて利用
        '        &:not(:has(input))::before { font-size: 12pt; content: "未選択"; }',
        "    }",
        # (7) 『日付期間を選択』のセレクトボックスの選択状態を日本語に置換
        '    :has(>[value^="Past "]) { padding: 0.5rem; }',
        '    [value^="Past "] { font-size: 0; &::before { font-size: 12pt; } }',
        *[
            f'    [value^="Past {a}"]::before {{ content: "過去{b}" }}'
            for a, b in zip(list("WM36Y2"), periods)
        ],
        "}",
        # (8) 『期間を選択』のセレクトボックスの選択肢を日本語表記に置換
        'ul:not(:has(li[id$="6"])):has(li[id$="5"]) > li {',
        "    font-size: 0; &::before { font-size: 12pt; }    /* フォントサイズで元のテキストを消して代替テキストを表示 */",
        *[
            f'    &:nth-child({n})::before {{ content: "過去{s}" }}'
            for n, s in enumerate(periods, start=1)
        ],
        "}",
        # (9) 月選択のセレクトボックスの選択肢を日本語表記に置換
        'ul:not(:has(li[id$="12"])):has(li[id$="11"]) > li {',
        "    /* アルゴリズム:年選択およびウィンドウ右上のハンバーガーメニューのドロップダウンがマッチしないように擬似クラス:has()を活用 */",
        "    font-size: 0; &::before { font-size: 12pt }  /* フォントサイズで元のテキストを消して代替テキストを表示 */",
        *[
            f'    &:nth-child({n})::before {{ content: "{n}"; }}'
            for n, _ in enumerate(range(12), start=1)
        ],
        "}",
    ]
    return "\n".join(_css_rows)

図3-3. 日付入力UIを翻訳するCSS適用Before/After

月の選択や曜日の部分を日本人に馴染みのある表記にすることができましたし、土曜日と日曜日に色を付けることもできました。

少しだけ技術的な話をメモです。和訳を実現するCSSや一つ前の1ページUIのCSSでは、擬似クラス:not():has()、および、擬似要素::before::afterを使った少々高度なターゲット指定方法を駆使していますので、興味が湧いた方はググってみていただければと思います。もう一点、カレンダーUIは通常のHTMLのツリー構造からは分離され、ポップオーバー用のツリー構造の中で記述されており、先に紹介した引数keyを使った狙い撃ちができない例外的な要素になっています。そこで、keyが使えない中でもターゲット以外の類似のUI要素に影響が及ばないように、擬似クラス:not():has()を使う工夫を施しています。

3.4. サンプルアプリ

これらのCSSが効いた様子をBefore/Afterで確認できるサンプルアプリのソースコードおよび画面イメージを載せておきます。先に示したPython関数と併せてコピー&ペーストして試してみてください。

サンプルアプリのソースコード

import streamlit as st

def footer_divider_css(key: str = "footer_divider") -> str:
    """メインエリアとフッタエリアの境界線に用いるst.headerもしくはst.subheaderを境界線のみにするCSSを記述する"""
    _css_rows = [
        f".st-key-{key} {{",
        #    ----「フッタとの境界線」というテキストを隠蔽する----
        "    h2, h3 { display: none }",
        #    ----dividerの上側のマージンを調整して本来のヘッダのdividerと位置合わせする----
        '    [data-testid="stHeadingDivider"] { margin-top: 1rem }',
        "}",
    ]
    return "\n".join(_css_rows)

DUMMY_CONTENTS = "  \n".join([s * 40 for s in ("あいうえおかきくけこさしすせそたちつてとなにぬねのはひふへほまみむめもやゆよらりるれろわをん")])

with st.sidebar:
    css = []
    if reduce_outer_margin := st.checkbox("❶余白を減らす"):
        css.append(_rdc_css := reduce_outer_margin_css())
        css.append(_dvd_css := footer_divider_css())
    if fit_on_one_page := st.checkbox("❷1ページUIにする"):
        css.append(_fit_css := fit_on_one_page_css("main_content"))
    if japanese_calendar := st.checkbox("❸日付入力を和訳する"):
        css.append(_jpn_css := japanese_calendar_css())
    st.html("<style>" + "\n".join(css) + "</style>")

# アプリ本体====================================================================
st.subheader("3.アプリ全体の見た目を変える", divider="rainbow")

with st.container(key="main_content"):  # メインコンテンツ
    st.markdown(DUMMY_CONTENTS)
    if reduce_outer_margin:
        st.markdown("##### <❶余白を減らすCSS>")
        st.code(_rdc_css, language="css", line_numbers=True)
        st.markdown("##### <フッタとの境界線に使用したCSS>")
        st.code(_dvd_css, language="css", line_numbers=True)
    if fit_on_one_page:
        st.markdown("##### <❷1ページUIにするCSS>")
        st.code(_fit_css, language="css", line_numbers=True)
    if japanese_calendar:
        st.markdown("##### <❸日付入力を和訳するCSS>")
        st.code(_jpn_css, language="css", line_numbers=True)

st.container(key="footer_divider").subheader("フッタとの境界線", divider="rainbow")
with st.container(horizontal=True):
    st.button("リセット", width=100)
    st.date_input("日付入力", label_visibility="collapsed")
    st.date_input("期間入力", ("2025-01-01", "today"), label_visibility="collapsed")
    st.button("前へ戻る", type="primary", width=100)
    st.button("次へ進む", type="primary", width=100)

4. マウスホバーによるアクションを追加する

次は、動きのあるUX向上策としてマウス操作に伴うアクションである「ツールチップ」を追加するCSSを紹介します。

ユーザに使い易いと感じてもらうには、どこをどんな手順でどう操作すれば何が達成できるのかが直感的にわかることが大事だと思います。そのためには、文字数をできる限り抑え、レウアウトもできる限りシンプルに保つことが必要でしょう。しかし、同じUIでもユーザによってその捉え方や理解度はさまざまであり、文字数を減らし過ぎることで分かり辛くなってしまい、アプリ作成者が別途用意した説明書を見ながらでないと使い熟せないアプリになってしまうこともあるでしょう。そして、それを恐れてUI中の文字数を増やしてしまっては本末転倒です。そこで活躍するのがツールチップです。ウィンドウ内に配置されたUIをユーザが操作しようとマウスホバーしたときに限って説明文を表示してあげることでウィンドウ全体のレイアウトを犠牲にすること無くUXを向上させることができるでしょう。

では早速、任意のUI構成要素にツールチップを充てる事例を紹介します。

4.1. 単体のツールチップ

まずは最もシンプルなウィジェット1個に対して1個のツールチップを充てる例で、スライダーUIであるst.slider()を取り上げます。どのように指定するとどのように位置が決まるかは下記コードでサンプルアプリを起動して確認してみてください。ボタン操作だけで位置合わせやテキストの変更が可能です。

スライダーUIにツールチップを付加するCSSのPython関数

def tooltip_for_slider_css(tt: str, key: str, vv: str, vn: float, hh: str, hn: float, freeze: bool = False) -> str:
    """st.sliderにツールチップを付加するCSSテキストを生成する
    Args:
        tt  (str)   : ツールチップのテキスト
        key (str)   : st.sliderのキー
        vv  (str)   : 縦位置の基準("top" or "bottom")
        vn  (float) : 縦位置の数値(rem単位)
        hh  (str)   : 横位置の基準("left" or "right")
        hn  (float) : 横位置の数値(rem単位)
        freeze_flag (bool, optional)    : ツールチップを常時表示にするかどうか(動作検証用)
    Returns:
        (str)   : styleタグのCSSの記述
    """
    _css_rows = [
        f'.st-key-{key} [data-testid="stSlider"] ' + "{",
        "    &::before {",
        #        ----見た目のスタイル----
        "        background: rgb(255 255 236);  /* st.warningの背景色を不透明で再現 */",
        "        color: rgb(146 108 5);         /* st.warningの文字色 */",
        "        border-radius: 8px;            /* 角の丸み */",
        "        font-size: 10pt;               /* フォントサイズ */",
        "        white-space: nowrap;           /* 改行を抑止 */",
        "        padding: 4px 8px;              /* 内側の余白(上下 左右) */",
        "        z-index: 1;                    /* 他の要素より前面に表示 */",
        #        ----表示位置----
        "        position: absolute;  /* 通常の描画フローから除外することを宣言 */",
        f"        {vv}: {vn}rem;  /* 垂直位置 */",
        f"        {hh}: {hn}rem;  /* 水平位置 */",
        #        ----通常は非表示にしておく----
        *["" if freeze else "        opacity: 0; visibility: hidden;         /* 不透明度:0、表示:隠す */"],
        *["" if freeze else "        transition: opacity 1s, visibility 1s;  /* ホバー時に滑らかに表示 */"],
        '        content: "' + tt + '";  /* ツールチップのテキスト */',
        "    }",
        #    ========特にマウスホバーしたときの設定========
        *["" if freeze else "    &:hover::before {"],
        *["" if freeze else "        opacity: 1; visibility: visible;  /* 不透明度:1、表示:する */"],
        *["" if freeze else "    }"],
        "}",
    ]
    return "\n".join(_css_rows)

少しだけ技術的な話です。contentにツールチップの中に入るテキストを指定し、topもしくはbottomleftもしくはrightを使ってツールチップを表示する位置を指定します。必要に応じてbackgroundcolorで色を変更することもできますし、他にも調べていただければ細かいカスタマイズが可能です。

4.2. 選択UIの各選択肢へのツールチップ

次に、複数の選択肢をもつUIに対して選択肢個別のツールチップを表示する例で、st.pills()のケースです。この方法は、ほぼ同じ目的を達成できるウィジェットであるst.segmented_control()st.radio()にも適用できます。使用するCSSは以下の通りです。

ピルズUIにツールチップを付加するCSSのPython関数

def tooltip_for_pills_css(tts: list[str], key: str, vv: str, vn: float, hh: str, hn: float, freeze: bool = False) -> str:
    """st.pillsの各選択肢にツールチップを付加するCSSテキストを生成する
    Args:
        tts (list[str]) : 各選択肢に対応するツールチップのテキスト一覧
        key (str)       : st.pillsのキー
        vv  (str)       : 縦位置の基準("top" or "bottom")
        vn  (float)     : 縦位置の数値(rem単位)
        hh  (str)       : 横位置の基準("left" or "right")
        hn  (float)     : 横位置の数値(rem単位)
        freeze_flag (bool, optional)    : ツールチップを常時表示にするかどうか(動作検証用)
    Returns:
        (str)   : styleタグのCSSの記述
    """
    _css_rows = [
        f'.st-key-{key} [data-testid^="stBaseButton-pills"] {{',
        #    ========全ての選択肢で共通の設定========
        "    &::before {",
        #        ----見た目のスタイル----
        "        background: rgb(255 255 236);  /* st.warningの背景色を不透明で再現 */",
        "        color: rgb(146 108 5);         /* st.warningの文字色 */",
        "        border-radius: 8px;            /* 角の丸み */",
        "        font-size: 10pt;               /* フォントサイズ */",
        "        white-space: nowrap;           /* 改行を抑止 */",
        "        padding: 4px 8px;              /* 内側の余白(上下 左右) */",
        "        z-index: 1;                    /* 他の要素より前面に表示 */",
        #        ----表示位置----
        "        position: absolute;  /* 通常の描画フローから除外することを宣言 */",
        f"        {vv}: {vn}rem;  /* 垂直位置 */",
        f"        {hh}: {hn}rem;  /* 水平位置 */",
        #        ----通常は非表示にしておく----
        *["" if freeze else "        opacity: 0; visibility: hidden;         /* 不透明度:0、表示:隠す */"],
        *["" if freeze else "        transition: opacity 1s, visibility 1s;  /* ホバー時に滑らかに表示 */"],
        "    }",
        #    ========選択肢ごと個別の設定========
        *[
            f'    &:nth-child({i})::before {{ content: "{s}" }}  /* {i}個目の選択肢 */'
            for i, s in enumerate(tts, start=1)
        ],
        #    ========特にマウスホバーしたときの設定========
        *["" if freeze else "    &:hover::before {"],
        *["" if freeze else "        opacity: 1; visibility: visible;  /* 不透明度:1、表示:する */"],
        *["" if freeze else "    }"],
        "}",
    ]
    return "\n".join(_css_rows)

少しだけ技術的な話です。選択肢ごとに異なるテキストをツールチップ内に割り当てたいので、CSSでも個別に記述する必要があります。今回の例では、どの選択肢のツールチップも共通の位置に出力するようにしていますが、表示位置に関する設定を選択肢ごと個別に記載することで、各選択肢に対して相対的に同じ位置にツールチップを表示させることもできますし、必要に応じて色遣いも個別に指定することもできます。

4.3. サンプルアプリ

これらのCSSが効いた様子をBefore/Afterで確認できるサンプルアプリのソースコードおよび画面イメージを載せておきます。先に示したPython関数と併せてコピー&ペーストして試してみてください。

サンプルアプリのソースコード

import streamlit as st
from streamlit import session_state as ss

def pane_4directional_pad(hk: str, vk: str):
    """位置調整用のボタンUIを構成する
    Args:
        hk  (str)   : 水平方向の位置を保持する状態変数のキー
        vk  (str)   : 垂直方向の位置を保持する状態変数のキー
    """
    _key = f"{hk}-{vk}_4"
    st.html(f"<style>.st-key-{_key} {{ gap: 0; }}</style>")
    xt = st.container(key=_key)
    with xt.container(horizontal_alignment="center", width=120):
        st.button("⬆︎", width=40, on_click=lambda: ss.update({vk: ss[vk] + 1}))
    with xt.container(horizontal=True, width=120, gap=None):
        st.button("⬅︎", width=40, on_click=lambda: ss.update({hk: ss[hk] - 1}))
        st.space(size="medium")
        st.button("➡︎", width=40, on_click=lambda: ss.update({hk: ss[hk] + 1}))
    with xt.container(horizontal_alignment="center", width=120):
        st.button("⬇︎", width=40, on_click=lambda: ss.update({vk: ss[vk] - 1}))

def segmented_control_css(key: str) -> str:
    _css_rows = [
        f".st-key-{key} " + "{",
        #    ----選択中の選択肢はクリック不可にして、無選択状態を防止する----
        '    [kind$="Active"] { pointer-events: none }',
        #    ----ボタン内の余白を削減する----
        '    [kind^="segmented_control"] { padding: 0 0.5rem }',
        "}",
    ]
    return "\n".join(_css_rows)

# 設定UI========================================================================
if "VN" not in ss:
    ss.update({"VN": 0, "HN": 0})
with st.sidebar:
    with st.container(horizontal=True):
        customize = st.checkbox("CSS適用", False)
        freeze = st.checkbox(
            "常時表示",
            help="本来`False`だが、表示位置の確認の際に`True`にするとよい。",
            value=False,
            disabled=(not customize),
        )
    with st.container(horizontal=True, vertical_alignment="center", gap=None):
        with st.container():
            st.segmented_control("縦位置の基準", ["top", "bottom"], default="top", key="VV")
            st.segmented_control("横位置の基準", ["left", "right"], default="right", key="HH")
            st.html("<style>" + "\n".join([segmented_control_css(k) for k in ("VV", "HH")]) + "</style>")
        with st.container(horizontal_alignment="center"):
            pane_4directional_pad("HN", "VN")
            st.text(f"(x, y) = ({ss.HN}, {ss.VN})")
    st.text_input("スライダーのツールチップ", value="ツールチップ", key="TT")
    with st.container(horizontal=True, vertical_alignment="center"):
        st.text_input("ピル🍎のツールチップ", "あかいいろですねー。🍎", key="OPT0")
        st.text_input("ピル🍌のツールチップ", "きいろですかー。🍌", key="OPT1")
        st.text_input("ピル🥝のツールチップ", "みどりです。🥝", key="OPT2")
        st.text_input("ピル🍊のツールチップ", "橙色だ。🍊", key="OPT3")
css1 = tooltip_for_slider_css(ss.TT, "STSLIDER", ss.VV, ss.VN, ss.HH, ss.HN, freeze)
css2 = tooltip_for_pills_css([ss[f"OPT{i}"] for i in range(4)], "STPILLS", ss.VV, ss.VN, ss.HH, ss.HN, freeze)

# アプリ本体====================================================================
st.subheader("4.ツールチップを付加する", divider="rainbow")
lt, rt = st.columns(2)

with lt:
    st.markdown("##### 単体のツールチップ@`st.slider()`")
    with st.echo("below"):
        st.slider("スライダー", 0, 500, (150, 350), key="STSLIDER")
        st.caption("<Pythonコード>")
    if customize:
        st.caption("<CSS>")
        st.code(css1, language="css", line_numbers=True)
        st.html("<style>" + css1 + "</style>")

with rt:
    st.markdown("##### 選択肢ごと個別のツールチップ@`st.pills()`")
    with st.echo("below"):
        st.pills("ピルズ", ["🍎りんご", "🍌バナナ", "🥝キウイ", "🍊みかん"], key="STPILLS")
        st.caption("<Pythonコード>")
    if customize:
        st.caption("<CSS>")
        st.code(css2, language="css", line_numbers=True)
        st.html("<style>" + css2 + "</style>")

図4. ツールチップのCSS適用(左:スライダー、右:ピルズ)

5. 複数ボタンUIを応用する

Streamlit Ver.1.40で導入された複数ボタンUIst.segmented_control()は、比較的応用の幅が広いウィジェットであり、本セクションではそのCSSカスタマイズ例を2つ紹介します。

5.1. 独立して実行させるタブレイアウト

一般的なタブレイアウトの特徴は、ヘッダ部分で選択したタブのコンテンツだけがその直ぐ下に表示される、というもので、Streamlitではst.tabs()が提供されています。st.tabs()の特徴は、各タブのコンテンツが表示されている状態か裏に隠れている状態かに依らず、ユーザのUI操作に伴うPythonコードのリランの度に毎回全てのタブの処理が実行される点になります。ただ、この特徴はユースケースにより一長一短です。良い点としては、リランがかかった時点で全てのタブの処理が実行されますから、ユーザがヘッダを操作してタブを切り替えた際にユーザに待ち時間を強いることがありません。一方で、仮に各タブのコンテンツを生成する処理が比較的重たいと、全てのタブのデータ処理が完了するまでユーザを待たせてしまうという弊害があります。そこで活躍するのがここで紹介するst.segmented_control()の応用例です。タブを切り替えたタイミングで初めてその切り替え後のタブのデータ処理が行われます。公式サイト(https://doc.streamlit.io/develop/api-reference/layout/st.tabs)のNoteにもまさに同様の文言がありました。しかし、標準のst.segmented_control()の外見はタブレイアウトを連想させるものではないため、ユーザがその挙動を直感的に理解するのは難しく、これを解決するのが本セクションで紹介するCSSです。

独立実行させるタブレイアウトのCSSのPython関数

def independent_tabs_css(head: str, body: str) -> str:
    """タブごとの独立実行を実現するタブレイアウトを構成するCSSのstyleタグを生成する
    Args:
        head    (str)   : タブレイアウトのヘッダ部分のキー
        body    (str)   : タブレイアウトのボディ部分のキー
    """
    _rows_css = [
        # ========タブレイアウトのヘッダ部分========
        f'.st-key-{head}' + "{",
        #    ----横幅一杯を指定して個々のヘッダを常にストレッチ可能にする(『width: fit-content』の打ち消し)----
        "    width: 100%;  /* ストレッチ可能にする */",
        "    label { display: none }  /* ラベルを非表示にする */",
        #    ----常に横幅を占有するための設定(defaultの『flex: 10000 1 0%』の打ち消し)----
        "    ::after { flex: none }  /* 最後の1行も横幅を占有する */",
        #    ----非活性のヘッダの背景色を指定----
        '    button[kind="segmented_control"] { background: rgba(128 128 128 / 0.1) }',
        f'    [data-baseweb="button-group"] ' + "{ gap: 0 }",
        #    ----活性のヘッダをクリック不可にして無選択状態にならないようにする----
        '    button[kind$="Active"] { pointer-events: none; p { font-weight: bold } }',
        #    ----タブヘッダの形状を指定----
        "    button {",
        "        height: 1.75rem;      /* 高さを指定(1/2) */",
        "        min-height: 1.75rem;  /* 高さを指定(2/2) */",
        "        padding: 0.375rem 0.5rem 0.125rem;  /* パディングを指定(上 左右 下) */",
        "        border-radius: 0.5rem 0.5rem 0 0 !important;  /* 角丸の半径を指定(左上 右上 右下 左下) */",
        "        border-bottom: none;  /* 下側の境界線を消去 */",
        "        flex: 1 1 auto;  /* 幅一杯にストレッチする設定 */",
        "    }",
        "}",
        # ========タブレイアウトのボディ部分========
        f'.st-key-{body}' + "{",
        "    padding: 0.5rem;  /* パディングを削減 */",
        "    border-top: none;  /* 上側の境界線を消去 */",
        "    border-radius: 0 0 0.5rem 0.5rem;  /* 角丸の半径を指定(左上 右上 右下 左下) */",
        "}",
        # ----ボディ部分をヘッダ部分に繋げる----
        f':has(> .st-key-{body}) ' + "{ margin-top: -1rem }",
    ]
    return "\n".join(_rows_css)

5.2. タブレイアウトのサンプルアプリ

これらのCSSが効いた様子をBefore/Afterで確認できるサンプルアプリのソースコードおよび画面イメージを載せておきます。先に示したPython関数と併せてコピー&ペーストして試してみてください。

サンプルアプリのソースコード

import streamlit as st
from streamlit import session_state as ss

# 設定UI========================================================================
with st.sidebar:
    customize = st.checkbox("CSS適用")
    with st.container(horizontal=True):
        num_chars = st.slider("文字数", 2, 20, 8, 2, width=100)
        num_tabs = st.slider("タブ数", 2, 20, 8, 1, width=100)
    names = [s * num_chars for s in "あいうえおかきくけこさしすせそたちつてと"[:num_tabs]]
    css = independent_tabs_css("page_head", "page_body")

# アプリ本体====================================================================
st.subheader("5❶.独立して実行させるタブレイアウト", divider="rainbow")
with st.echo("below"):
    st.segmented_control("ヘッダ", names, default=names[0], key="page_head")
    for tab in names:
        if ss.page_head == tab:
            ct = st.container(key="page_body", border=True)
            ct.markdown(f"ここに『{tab}』タブのコンテンツを表示します。  \n" * 6)
    st.caption("<Pythonコード>")
if customize:
    st.caption("<CSS>")
    st.code(css, language="css", line_numbers=True)
    st.html("<style>" + css + "</style>")

図5-1. タブレイアウトのCSS適用Before/After

5.3. 進捗をわかりやすく提示するプログレスバー

もう一つのst.segmented_control()の応用例として「プログレスバー」を紹介します。ここで言う「プログレスバー」とは、PowerPointなどのプレゼンテーション形式のスライド資料において、ストーリーの流れとその中での現在地点を示す以下のような図面を指すこととします。

図5-1. プログレスバーのイメージ

ユーザがインタラクティブに操作できるというStreamlit(Webアプリ)の特長を活かし、このプログレスバーの各ステップにアプリの各作業ステップにジャンプする機能を持たせることでUXを向上できるでしょう。先に画面イメージをBefore/Afterで示しますと以下の通りです。

図5-2. プログレスバーのCSS適用Before/After

アプリ全体の作業の各ステップとその流れ、かつ、現在その中でどのステップにいるのかが一目瞭然かと思います。プログレスバー自体がユーザが操作可能なウィジェットですので、各ステップのボタンをクリックすることでジャンプできるように実装できます。CSSは以下の通りです。

プログレスバーのCSSのPython関数

def progress_css(valid: list, STEP: dict, key: str, unit: int | float = 2) -> str:
    """st.segmented_controlをプログレスバーにするCSSを記述する
    Args:
        valid   (list)  : 有効なステップ
        STEP    (dict[View, Step])  : ステップ一覧
        key     (str)   : プログレスバーに見立てるst.segmented_controlのキー
        unit    (int | float, optional) : UIのサイズを決めるスケール [px]
    Returns:
        (str)   : styleタグのCSSの記述
    """
    unit = _i if (_i := int(unit)) == unit else unit

    invalid_children = ", ".join([
        f":nth-child({v.num})"
        for k, v in STEP.items()
        if k not in valid
    ])  # 未到達ステップに相当する擬似セレクタ群

    _css_rows = [
        f":has(> .st-key-{key})" + "{ margin-bottom: -1rem }  /* 直下のUIとの間隔を削減 */",
        f".st-key-{key} " + "{",
        #    ========st.segmented_control本体のカスタマイズ========
        "    width: 100%;  /* 横幅一杯を使用する */",
        "    label { display: none }  /* label_visibility='collapsed'相当 */",
        #    ========ボタン配置のカスタマイズ========
        '    [data-baseweb="button-group"] {',
        "        max-width: none;  /* 横幅一杯で表示 */",
        f"        margin: 0 {unit * 4}px;      /* ボタン群外側の余白(上下 左右) */",
        f"        gap: {unit * 2}px {unit}px;  /* ボタン同士の間隔(垂直 水平) */",
        f"        & > :last-child {{ margin-right: -{unit * 4}px }}",
        "    }",
        #    ========全ボタン共通の設定========
        '    button[data-testid^="stBaseButton-segmented_control"] {',
        "        border-radius: 0 !important;  /* 角丸の半径を強制的にゼロにする */",
        "        flex: 1 1 auto;    /* 横幅一杯でストレッチするための設定 */",
        "        max-width: none;   /* 1個のボタンが1行を占有してしまう問題対策(defaultの『max-width: 100%』を打ち消す) */",
        f"        margin: 0 {-unit * 4}px;    /* ボタン同士の水平方向の間隔を狭める(上下 左右) */",
        f"        height: {unit * 16}px;      /* ボタンの高さ(1/2) */",
        f"        min-height: {unit * 16}px;  /* ボタンの高さ(2/2) */",
        f"        padding: 0 {unit * 12}px;   /* 矢印両端の形状のための余白の設定(上下 左右) */",
        "        p {" + f" font-size: {unit * 10}px " + "}  /* フォントサイズ */",
        "        clip-path: polygon(0% 0%"
                    f", {unit * 8}px 50%"
                    ", 0% 100%"
                    f", calc(100% - {unit * 8}px) 100%"
                    ", 100% 50%"
                    f", calc(100% - {unit * 8}px) 0%"
                ");  /* 矢羽の形状(6点の座標)を指定 */",
        "    }",
        #    ========ボタンごとの色やクリック可否に関するカスタマイズ========
        #    ----現在いない(非選択状態の)ステップのボタン----
        '    [data-testid="stBaseButton-segmented_control"] {',
        "        background: rgba(224 0 51 / 0.2);",
        "        color: rgb(128 128 128);",
        "    }",
        #    ----現在いる(選択中状態の)ステップのボタン----
        '    [data-testid$="Active"] {',
        "        background: rgba(224 0 51 / 0.8);",
        "        color: rgb(255 255 255);",
        "        pointer-events: none;  /* クリック不可にする */",
        "    }",
        #    ----未到達ステップ(無効なステップ)のボタン----
        f'    [data-testid="stBaseButton-segmented_control"]:is({invalid_children}) ' + "{",
        "        background: rgba(128 128 128 / 0.8);",
        "        color: rgb(128 128 128);",
        "        pointer-events: none;  /* クリック不可にする */",
        "    }",
        "}",
    ]
    return "\n".join(_css_rows)

5.4. プログレスバーのサンプルアプリ

これらのCSSが効いた様子を先に示した画面イメージの通りBefore/Afterで確認できるサンプルアプリのソースコードは以下です。先に示したPython関数と併せてコピー&ペーストして試してみてください。

サンプルアプリのソースコード

from dataclasses import dataclass
import streamlit as st
from streamlit import session_state as ss

step = [f"ステップ{i+1:02}" for i in range(6)]

@dataclass(slots=True, frozen=True)
class Step:
    num: int
    title: str

step_dict = {
    step[0]: Step(1, "⚾️ ステップ1 ⚾️"),
    step[1]: Step(2, "🎾 ステップ2 🎾"),
    step[2]: Step(3, "🏀 ステップ3 🏀"),
    step[3]: Step(4: "⚽️ ステップ4 ⚽️"),
    step[4]: Step(5: "🏐 ステップ5 🏐"),
    step[5]: Step(6: "🎱 ステップ6 🎱"),
}

# 設定UI========================================================================
with st.sidebar:
    customize = st.checkbox("CSS適用", True)
    with st.container(horizontal=True):
        height = st.slider("太さ(サイズ)", 16, 64, 32, 4, width=100)
    css = progress_css(step, step_dict, "page_view", height / 16)

# アプリ本体====================================================================
st.subheader("5❷.プログレスバー", divider="rainbow")
with st.echo("below"):
    st.segmented_control("プログレスバー", step, default=step[0], key="page_view")
    if ss.page_view == step[0]:
        st.subheader(step_dict[step[0]].title, divider="blue")
    elif ss.page_view == step[1]:
        st.subheader(step_dict[step[1]].title, divider="green")
    elif ss.page_view == step[2]:
        st.subheader(step_dict[step[2]].title, divider="orange")
    elif ss.page_view == step[3]:
        st.subheader(step_dict[step[3]].title, divider="red")
    elif ss.page_view == step[4]:
        st.subheader(step_dict[step[4]].title, divider="violet")
    elif ss.page_view == step[5]:
        st.subheader(step_dict[step[5]].title, divider="yellow")
    else:
        st.subheader("🏉 その他 🏉", divider="gray")
    st.caption("<Pythonコード>")

if customize:
    st.caption("<プログレスバーのCSS>")
    st.code(css, language="css", line_numbers=True)
    st.html("<style>" + css + "</style>")

6. まとめ

今年の私からのStreamlit紹介記事は以上です。

データ分析スキルを強みとするエンジニアが自身のアウトプットを素早く周囲に展開するツールとしてユーザ数を増やしてきたStreamlitですが、精力的な月一回ペースのバージョンアップを経て、CSSの応用を含め、Pythonコードの工夫次第ではシンプルなデータ出力・可視化ツールに留まらずに、比較的多機能な社内ツールとして多くのユーザが使い易く便利に活用するのに十分な機能を持ち合わせてきた印象を受けています。データ活用の民主化を成し遂げるには、通常データ活用で要求されるPythonやSQL、データベースなどの技術に関する知識や実践的な活用スキル、といったものをいかにしてツール(アプリ)の工夫でマスクしてあげるかが大きな鍵になると感じています。本記事で紹介したUX向上のための(些細ですが)有益なテクニック(Python関数)が、皆さんのお仕事におけるデータ活用の促進〜民主化を後押しする手段となれば幸いです。

*1:ドコモ・テクノロジ株式会社は株式会社NTTドコモの機能分担会社で、ドコモグループの研究開発を支えている企業です。https://www.docomo-tech.co.jp/