Skip to content
fandhe-frontendcore v0.4.3
GitHub

pricing-tiers-comparison

fandhe-frontend-pre-styled-ui の heading / text / badge / card / button / list / icon / table / tabs 部品を合成した、カードと表の 2 段構成の料金セクションの合成例です。Blocks セクションは新規部品を追加するものではなく、既存の Themes/Primitives 部品を組み合わせた実例集であることに注意してください。

Demo は 2 つの形を縦に併記します。

  • 形 A(ロゴ列を挟む形): header → プランカード 3 枚 → 顧客ロゴ列 → 幅広比較表(48rem 以上)/ 狭幅一覧(48rem 未満)
  • 形 B(暗色帯 + 周期切替バリエーション): 見出し・リード文を暗色帯に置き、その下へ月額/年額の周期切替(tabs)を月額選択・年額選択の 2 インスタンスで並記します(無 JS の docs サイトでは両状態を静的に併記する必要があるため)

CTA ボタンは type="button" のまま送信先を持たない静的な合成例であり、<form> は出力しません。プラン名・価格・機能・社名・文言はすべて架空のものです。

Demo

ロゴ列を挟む形

あなたに合うプランを選ぶ

3 つのプランを機能ごとに比較して、必要な範囲を見極められます。

Starter

個人・小規模プロジェクト向け

$9 / 月

Growth

おすすめ

成長中のチーム向け

$29 / 月

Scale

大規模組織向け

$79 / 月

多くのチームに選ばれています

プラン別の機能比較
機能StarterGrowthScale
基本機能
プロジェクト数3 件無制限無制限
ストレージ容量5GB100GB1TB
カスタムドメイン
サポート
優先サポート窓口
専任担当者

Starter

  • プロジェクト数: 3 件
  • ストレージ容量: 5GB
  • カスタムドメイン
  • 優先サポート窓口
  • 専任担当者

Growth

  • プロジェクト数: 無制限
  • ストレージ容量: 100GB
  • カスタムドメイン
  • 優先サポート窓口
  • 専任担当者

Scale

  • プロジェクト数: 無制限
  • ストレージ容量: 1TB
  • カスタムドメイン
  • 優先サポート窓口
  • 専任担当者

暗色帯 + 周期切替バリエーション

月額・年額から選べます

周期を切り替えて、年間契約の割引を確認できます。

月額選択時

Starter

個人・小規模プロジェクト向け

$9 / 月

Growth

おすすめ

成長中のチーム向け

$29 / 月

Scale

大規模組織向け

$79 / 月

年額選択時

Starter

個人・小規模プロジェクト向け

$86 / 年

Growth

おすすめ

成長中のチーム向け

$278 / 年

Scale

大規模組織向け

$758 / 年

使用部品

Rust コード

Rust
use crate::blocks::dummy_assets;
use fandhe_frontend_core::{div, el, p, span, text, Node};
use fandhe_frontend_pre_styled_ui::badge::{self, BadgeProps, BadgeVariant};
use fandhe_frontend_pre_styled_ui::button::{self, ButtonProps, ButtonVariant};
use fandhe_frontend_pre_styled_ui::card::{self, CardProps, CardVariant};
use fandhe_frontend_pre_styled_ui::heading::{self, HeadingLevel, HeadingProps};
use fandhe_frontend_pre_styled_ui::icon::{icon, IconProps};
use fandhe_frontend_pre_styled_ui::list::{self, ListType, ListVariant};
use fandhe_frontend_pre_styled_ui::table::{self, TableProps, TableVariant};
use fandhe_frontend_pre_styled_ui::tabs::{
    self, ActivationMode, Orientation, TabItem, TabsProps, TabsVariant,
};
use fandhe_frontend_pre_styled_ui::text::{self as styled_text, TextProps, TextVariant};
use fandhe_frontend_pre_styled_ui::{ColorPalette, Size};

/// 表の列数(機能名列 + プラン数)を文字列表記した定数。プラン数が
/// [`PLANS`] 固定のため `colspan` 属性値としてリテラルで持つ(ファイル内
/// ユニットテストで `PLANS.len() + 1` との一致を固定)。
const COL_COUNT_STR: &str = "4";

/// 機能可否・値の表現。
enum FeatureValue {
    /// 含まれる(チェックアイコン)。
    Included,
    /// 含まれない(バツアイコン)。
    Excluded,
    /// 文字表示(例: 上限件数)。
    Text(&'static str),
}

/// 料金プラン 1 件分の静的データ(架空)。`price_yearly` は暗色帯の
/// 年額 tabs パネル([`billing_grid`])専用で、月額比較表・狭幅一覧では
/// 使わない。
struct Plan {
    name: &'static str,
    price: &'static str,
    price_yearly: &'static str,
    description: &'static str,
    cta: &'static str,
    recommended: bool,
}

/// 機能比較 1 行分。`values` の長さは [`PLANS`] の件数と一致させる
/// (ファイル内ユニットテストで固定する不変条件)。
struct FeatureRow {
    label: &'static str,
    values: &'static [FeatureValue],
}

/// カテゴリ見出し行 1 件分(機能行のグルーピング単位、`table::body` 1 個に
/// 対応する)。
struct Category {
    label: &'static str,
    rows: &'static [FeatureRow],
}

/// プラン 3 件(中央 Growth を推奨プランとする)。価格は
/// `crate::blocks::dummy_assets::SAMPLE_PRICE_TIERS` と同じ値を直書きする。
const PLANS: [Plan; 3] = [
    Plan {
        name: "Starter",
        price: "$9",
        price_yearly: "$86",
        description: "個人・小規模プロジェクト向け",
        cta: "Starter を選ぶ",
        recommended: false,
    },
    Plan {
        name: "Growth",
        price: "$29",
        price_yearly: "$278",
        description: "成長中のチーム向け",
        cta: "Growth を選ぶ",
        recommended: true,
    },
    Plan {
        name: "Scale",
        price: "$79",
        price_yearly: "$758",
        description: "大規模組織向け",
        cta: "Scale を選ぶ",
        recommended: false,
    },
];

/// カテゴリ 2 件・機能行合計 5 件(架空データ)。
const CATEGORIES: [Category; 2] = [
    Category {
        label: "基本機能",
        rows: &[
            FeatureRow {
                label: "プロジェクト数",
                values: &[
                    FeatureValue::Text("3 件"),
                    FeatureValue::Text("無制限"),
                    FeatureValue::Text("無制限"),
                ],
            },
            FeatureRow {
                label: "ストレージ容量",
                values: &[
                    FeatureValue::Text("5GB"),
                    FeatureValue::Text("100GB"),
                    FeatureValue::Text("1TB"),
                ],
            },
            FeatureRow {
                label: "カスタムドメイン",
                values: &[
                    FeatureValue::Excluded,
                    FeatureValue::Included,
                    FeatureValue::Included,
                ],
            },
        ],
    },
    Category {
        label: "サポート",
        rows: &[
            FeatureRow {
                label: "優先サポート窓口",
                values: &[
                    FeatureValue::Excluded,
                    FeatureValue::Included,
                    FeatureValue::Included,
                ],
            },
            FeatureRow {
                label: "専任担当者",
                values: &[
                    FeatureValue::Excluded,
                    FeatureValue::Excluded,
                    FeatureValue::Included,
                ],
            },
        ],
    },
];

/// 可否を表す自作の抽象チェック/バツ図形。意味を持つアイコンのため
/// `label` を指定する(参照元のアイコン形状・内部識別子は持ち込まない)。
fn value_node(value: &FeatureValue) -> Node {
    match value {
        FeatureValue::Included => icon(
            &IconProps {
                size: Size::Sm,
                label: Some("含まれる"),
                ..IconProps::default()
            },
            vec![("data-blocks-pricing-tiers-comparison-value", "included")],
            vec![el(
                "path",
                vec![
                    ("d", "M5 12.5l4.5 4.5L19 7"),
                    ("fill", "none"),
                    ("stroke", "currentColor"),
                    ("stroke-width", "1.5"),
                    ("stroke-linecap", "round"),
                    ("stroke-linejoin", "round"),
                ],
                vec![],
            )],
        ),
        FeatureValue::Excluded => icon(
            &IconProps {
                size: Size::Sm,
                label: Some("含まれない"),
                ..IconProps::default()
            },
            vec![("data-blocks-pricing-tiers-comparison-value", "excluded")],
            vec![
                el(
                    "path",
                    vec![
                        ("d", "M7 7l10 10"),
                        ("stroke", "currentColor"),
                        ("stroke-width", "1.5"),
                        ("stroke-linecap", "round"),
                    ],
                    vec![],
                ),
                el(
                    "path",
                    vec![
                        ("d", "M17 7l-10 10"),
                        ("stroke", "currentColor"),
                        ("stroke-width", "1.5"),
                        ("stroke-linecap", "round"),
                    ],
                    vec![],
                ),
            ],
        ),
        FeatureValue::Text(label) => span(
            vec![("data-blocks-pricing-tiers-comparison-value", "text")],
            vec![text(*label)],
        ),
    }
}

/// header 領域(見出し・リード文)。
fn header() -> Node {
    div(
        vec![("data-blocks-pricing-tiers-comparison-header", "")],
        vec![
            heading::heading(
                HeadingLevel::H3,
                &HeadingProps::default(),
                vec![],
                vec![text("あなたに合うプランを選ぶ")],
            ),
            styled_text::text(
                &TextProps {
                    variant: TextVariant::Muted,
                    ..TextProps::default()
                },
                vec![],
                vec![text(
                    "3 つのプランを機能ごとに比較して、必要な範囲を見極められます。",
                )],
            ),
        ],
    )
}

/// プランカード 1 件。推奨プラン(Growth)には badge と強調ボーダーを
/// 付ける。`price`/`period` は呼び出し側の billing 状態(月額/年額)に
/// 応じて切り替える([`billing_grid`] 参照)。
fn plan_card(plan: &Plan, price: &'static str, period: &'static str) -> Node {
    let mut heading_children = vec![heading::heading(
        HeadingLevel::H4,
        &HeadingProps::default(),
        vec![],
        vec![text(plan.name)],
    )];
    if plan.recommended {
        heading_children.push(badge::badge(
            &BadgeProps {
                variant: BadgeVariant::Solid,
                ..BadgeProps::default()
            },
            vec![],
            vec![text("おすすめ")],
        ));
    }

    let mut attrs = vec![("data-blocks-pricing-tiers-comparison-card", "")];
    if plan.recommended {
        attrs.push(("data-blocks-pricing-tiers-comparison-recommended", ""));
    }

    card::root(
        CardProps {
            variant: CardVariant::Outline,
            size: Size::Md,
        },
        attrs,
        vec![
            card::header(
                vec![],
                vec![
                    div(
                        vec![("class", "blocks-pricing-tiers-comparison-tier-heading")],
                        heading_children,
                    ),
                    card::description(vec![], vec![text(plan.description)]),
                ],
            ),
            card::body(
                vec![],
                vec![div(
                    vec![("class", "blocks-pricing-tiers-comparison-price")],
                    vec![
                        text(price),
                        span(
                            vec![("class", "blocks-pricing-tiers-comparison-price-period")],
                            vec![text(period)],
                        ),
                    ],
                )],
            ),
            card::footer(
                vec![],
                vec![button::button(
                    &ButtonProps {
                        variant: if plan.recommended {
                            ButtonVariant::Solid
                        } else {
                            ButtonVariant::Outline
                        },
                        ..ButtonProps::default()
                    },
                    vec![],
                    vec![text(plan.cta)],
                )],
            ),
        ],
    )
}

/// 幅広の比較表(`>= 48rem` でのみ表示、[`LAYOUT_CSS`] 参照)。
fn comparison_table() -> Node {
    let mut header_cells = vec![table::column_header(vec![], vec![text("機能")])];
    header_cells.extend(
        PLANS
            .iter()
            .map(|plan| table::column_header(vec![], vec![text(plan.name)])),
    );

    let bodies: Vec<Node> = CATEGORIES
        .iter()
        .map(|category| {
            let mut rows = vec![table::row(
                vec![],
                vec![table::row_header(
                    vec![
                        ("colspan", COL_COUNT_STR),
                        ("data-blocks-pricing-tiers-comparison-category", ""),
                    ],
                    vec![text(category.label)],
                )],
            )];
            rows.extend(category.rows.iter().map(|row| {
                let mut cells = vec![table::row_header(
                    vec![("data-blocks-pricing-tiers-comparison-feature", "")],
                    vec![text(row.label)],
                )];
                cells.extend(
                    row.values
                        .iter()
                        .map(|value| table::cell(vec![], vec![value_node(value)])),
                );
                table::row(vec![], cells)
            }));
            table::body(vec![], rows)
        })
        .collect();

    let mut table_children = vec![
        table::caption(vec![], vec![text("プラン別の機能比較")]),
        table::header(vec![], vec![table::row(vec![], header_cells)]),
    ];
    table_children.extend(bodies);

    let table_node = table::root(
        TableProps {
            variant: TableVariant::Outline,
            ..TableProps::default()
        },
        vec![("data-blocks-pricing-tiers-comparison-table", "")],
        table_children,
    );

    div(
        vec![("data-blocks-pricing-tiers-comparison-view", "table")],
        vec![table::scroll_area(
            vec![
                ("role", "region"),
                ("aria-label", "プラン比較表"),
                ("tabindex", "0"),
                ("data-blocks-pricing-tiers-comparison-scroll", ""),
            ],
            vec![table_node],
        )],
    )
}

/// プラン 1 件分の狭幅一覧(プラン名見出し + 機能一覧)。可否アイコンは
/// 情報を運ぶため `list::indicator`(装飾専用)には収めない
/// (モジュール doc「機能一覧の可否アイコンが意味を持つ理由」節参照)。
fn plan_list(plan_index: usize, plan: &Plan) -> Node {
    let items: Vec<Node> = CATEGORIES
        .iter()
        .flat_map(|category| category.rows.iter())
        .map(|row| {
            let value = &row.values[plan_index];
            let item_children = match value {
                FeatureValue::Text(v) => {
                    vec![span(vec![], vec![text(format!("{}: {v}", row.label))])]
                }
                included_or_excluded => {
                    vec![value_node(included_or_excluded), text(row.label)]
                }
            };
            list::item(
                vec![("class", "blocks-pricing-tiers-comparison-list-item")],
                item_children,
            )
        })
        .collect();

    div(
        vec![("class", "blocks-pricing-tiers-comparison-plan-list")],
        vec![
            heading::heading(
                HeadingLevel::H4,
                &HeadingProps::default(),
                vec![],
                vec![text(plan.name)],
            ),
            list::root(
                ListType::Unordered,
                ListVariant::Plain,
                vec![("data-blocks-pricing-tiers-comparison-features", "")],
                items,
            ),
        ],
    )
}

/// 狭幅(`< 48rem`)で比較表の代わりに表示するプランごとの一覧
/// ([`LAYOUT_CSS`] 参照)。
fn narrow_lists() -> Node {
    div(
        vec![("data-blocks-pricing-tiers-comparison-view", "list")],
        PLANS
            .iter()
            .enumerate()
            .map(|(i, plan)| plan_list(i, plan))
            .collect(),
    )
}

/// 顧客ロゴ列 1 枚分(装飾アイコン + 可視社名)。モジュール doc「顧客
/// ロゴ列(装飾アイコン + 可視社名)」節参照。
fn logo_item(name: &'static str) -> Node {
    list::item(
        vec![("class", "blocks-pricing-tiers-comparison-logo")],
        vec![
            icon(
                &IconProps {
                    size: Size::Sm,
                    label: None,
                    ..IconProps::default()
                },
                vec![],
                vec![el(
                    "path",
                    vec![
                        ("d", "M4 17V7l8-4 8 4v10l-8 4-8-4Z"),
                        ("fill", "none"),
                        ("stroke", "currentColor"),
                        ("stroke-width", "1.5"),
                        ("stroke-linejoin", "round"),
                    ],
                    vec![],
                )],
            ),
            text(name),
        ],
    )
}

/// カードと比較表の間に挟む顧客ロゴ列(対応表 ID R1142)。
/// [`dummy_assets::COMPANY_NAMES`] 先頭 5 件を使う。
fn logo_row() -> Node {
    div(
        vec![("data-blocks-pricing-tiers-comparison-logos-section", "")],
        vec![
            styled_text::text(
                &TextProps {
                    variant: TextVariant::Muted,
                    ..TextProps::default()
                },
                vec![],
                vec![text("多くのチームに選ばれています")],
            ),
            list::root(
                ListType::Unordered,
                ListVariant::Plain,
                vec![("data-blocks-pricing-tiers-comparison-logos", "")],
                dummy_assets::COMPANY_NAMES[..5]
                    .iter()
                    .map(|name| logo_item(name))
                    .collect(),
            ),
        ],
    )
}

/// billing 状態 1 件分(月額 or 年額)のプランカードグリッドを組み立てる
/// (モジュール doc「暗色帯で比較表を繰り返さない理由」節参照。機能比較は
/// 上段の比較表・狭幅一覧が正であり、ここでは価格・期間表記のみを
/// 切り替える)。
fn billing_grid(price_of: fn(&Plan) -> &'static str, period: &'static str) -> Node {
    div(
        vec![("data-blocks-pricing-tiers-comparison-grid", "")],
        PLANS
            .iter()
            .map(|plan| plan_card(plan, price_of(plan), period))
            .collect(),
    )
}

/// billing 状態 2 件(月額/年額)分の `TabItem` 一覧。2 インスタンスで
/// 同一の構成を使うため呼び出し側で複製せず都度生成する
/// (`pricing_tiers_morph::tab_items` と同型)。
fn billing_tab_items() -> Vec<TabItem<'static>> {
    vec![
        TabItem {
            value: "monthly",
            trigger: vec![text("月額")],
            content: vec![billing_grid(|plan| plan.price, " / 月")],
            disabled: false,
        },
        TabItem {
            value: "yearly",
            trigger: vec![text("年額")],
            content: vec![billing_grid(|plan| plan.price_yearly, " / 年")],
            disabled: false,
        },
    ]
}

/// キャプション 1 行(`p`、muted)。
fn caption(label: &'static str) -> Node {
    p(
        vec![("data-blocks-pricing-tiers-comparison-caption", "")],
        vec![text(label)],
    )
}

/// 暗色帯 + 周期切替バリエーション(集約元 R1151)。見出し・リード文は
/// 帯内に 1 回だけ出し、その下へ月額選択・年額選択の 2 インスタンスを
/// 縦に並べる(モジュール doc「暗色帯と周期切替の 2 状態並記」節参照)。
fn billing_band() -> Node {
    let band_header = div(
        vec![("data-blocks-pricing-tiers-comparison-band-header", "")],
        vec![
            heading::heading(
                HeadingLevel::H3,
                &HeadingProps::default(),
                vec![],
                vec![text("月額・年額から選べます")],
            ),
            styled_text::text(
                &TextProps {
                    variant: TextVariant::Muted,
                    ..TextProps::default()
                },
                vec![],
                vec![text("周期を切り替えて、年間契約の割引を確認できます。")],
            ),
        ],
    );

    let monthly_props = TabsProps {
        id: "blocks-pricing-tiers-comparison-billing-monthly",
        selected: "monthly",
        orientation: Orientation::Horizontal,
        activation_mode: ActivationMode::Automatic,
        loop_focus: true,
        indicator: false,
    };
    let yearly_props = TabsProps {
        id: "blocks-pricing-tiers-comparison-billing-yearly",
        selected: "yearly",
        orientation: Orientation::Horizontal,
        activation_mode: ActivationMode::Automatic,
        loop_focus: true,
        indicator: false,
    };

    let monthly_tabs = tabs::tabs(
        TabsVariant::Enclosed,
        Size::Md,
        ColorPalette::Accent,
        &monthly_props,
        billing_tab_items(),
    );
    let yearly_tabs = tabs::tabs(
        TabsVariant::Enclosed,
        Size::Md,
        ColorPalette::Accent,
        &yearly_props,
        billing_tab_items(),
    );

    div(
        vec![("data-blocks-pricing-tiers-comparison-band", "")],
        vec![
            band_header,
            caption("月額選択時"),
            monthly_tabs,
            caption("年額選択時"),
            yearly_tabs,
        ],
    )
}

/// `pricing-tiers-comparison` の Demo 本体。呼び出しごとに同一の `Node` を
/// 返す純関数。形 A(header → プランカード → 顧客ロゴ列 → 幅広比較表 /
/// 狭幅一覧)+ 形 B(暗色帯 + 周期切替の月額/年額 2 状態)の順に並べる
/// (表/一覧の表示切替は [`LAYOUT_CSS`] の `@media` が担う)。
pub fn demo() -> Node {
    let cards = div(
        vec![("data-blocks-pricing-tiers-comparison-grid", "")],
        PLANS
            .iter()
            .map(|plan| plan_card(plan, plan.price, " / 月"))
            .collect(),
    );

    div(
        vec![("class", "blocks-pricing-tiers-comparison-layout")],
        vec![
            caption("ロゴ列を挟む形"),
            header(),
            cards,
            logo_row(),
            comparison_table(),
            narrow_lists(),
            caption("暗色帯 + 周期切替バリエーション"),
            billing_band(),
        ],
    )
}

差分メモ

  • 既存の pricing-comparison-table との主な差: pricing-comparison-table はカテゴリ見出し行付きの幅広表と狭幅カード・select 2 列表を持つのに対し、本 block はカードと表の 2 段構成で、狭幅では表の代わりに「プランごとの機能一覧」を表示する点が異なります。
  • 顧客ロゴ列(対応表 ID R1142): カードと比較表の間に、装飾アイコン + 可視社名(list+icon)の顧客ロゴ列を挟みました。ロゴ画像(image 部品)は使わず、社名を必ず可視テキストとして併記することで、logo-cloud-grid(PR #3244)で指摘された「装飾ロゴだけでは社名がスクリーンリーダーに伝わらない」欠落を構造的に防いでいます。
  • 暗色帯 + 周期切替(集約元 R1151): 見出し・リード文を暗色帯(background: var(--fandhe-color-fg)、pricing-tier-cards の inverted と同型)に置き、tabs で月額/年額の周期を切り替えます。docs サイトは JS ハイドレーションを行わないため、pricing-tiers-morph と同じ「selected が異なる 2 インスタンスの静的併記」で月額選択・年額選択の両状態を可視化しています。
  • 暗色帯で比較表を繰り返さない理由: 暗色帯配下のプランカードは価格・期間表記のみを月額/年額で切り替え、機能比較(比較表・狭幅一覧)は複製していません。表を複製すると scope="row" の件数契約・aria-label region の重複を招くうえ Demo が肥大化するためです。周期切替のクライアント配線(wasm-full の tabs 配線)は行いません(無 JS の静的な合成例、docs/policy/intentional-non-adoption.md §3.25)。

関連情報: Heading / Text / Badge / Card / Button / List / Icon / Table / Tabs