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 / 月
多くのチームに選ばれています
- Lumenbridge Systems
- Verdant Foundry
- Quill & Meridian
- Trellisworks Co.
- Aurelia Dynamics
| 機能 | Starter | Growth | Scale |
|---|---|---|---|
| 基本機能 | |||
| プロジェクト数 | 3 件 | 無制限 | 無制限 |
| ストレージ容量 | 5GB | 100GB | 1TB |
| カスタムドメイン | |||
| サポート | |||
| 優先サポート窓口 | |||
| 専任担当者 | |||
Starter
- プロジェクト数: 3 件
- ストレージ容量: 5GB
- カスタムドメイン
- 優先サポート窓口
- 専任担当者
Growth
- プロジェクト数: 無制限
- ストレージ容量: 100GB
- カスタムドメイン
- 優先サポート窓口
- 専任担当者
Scale
- プロジェクト数: 無制限
- ストレージ容量: 1TB
- カスタムドメイン
- 優先サポート窓口
- 専任担当者
暗色帯 + 周期切替バリエーション
月額・年額から選べます
周期を切り替えて、年間契約の割引を確認できます。
月額選択時
Starter
個人・小規模プロジェクト向け
$9 / 月
Growth
おすすめ成長中のチーム向け
$29 / 月
Scale
大規模組織向け
$79 / 月
Starter
個人・小規模プロジェクト向け
$86 / 年
Growth
おすすめ成長中のチーム向け
$278 / 年
Scale
大規模組織向け
$758 / 年
年額選択時
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