pricing-tiers-morph
fandhe-frontend-pre-styled-ui の tabs / card / badge / button 部品(+ border_beam の opt-in 装飾)を合成した、Motion+ sections/pricing- sections に相当する料金プランの合成例です。Blocks セクションは新規部品を追加するものではなく、既存の Themes/Primitives 部品を組み合わせた実例集であることに注意してください。
本 Demo は静的な表示例であり、<form> 要素を持たず、送信処理・決済処理・契約処理を一切行いません。月額/年額の billing 切替は tabs(data-state + クロスフェード)で表現し、CTA ボタンは type="button" のまま送信先を持たない静的なボタンです。実際の料金プラン切替・決済処理を実装する場合は、利用者自身の Rust コードで書いてください(docs/policy/intentional-non-adoption.md §3.25 の責務境界: UI コンポーネント層はアプリケーションロジックを内包しません)。
Demo
Monthly
Starter
個人・小規模プロジェクト向け
$9/mo
- プロジェクト 1 件
- コミュニティサポート
- 基本アナリティクス
Growth
おすすめ成長中のチーム向け
$29/mo
- プロジェクト 10 件
- 優先サポート
- 詳細アナリティクス
- カスタムドメイン
Enterprise
大規模組織向け
$99/mo
- プロジェクト無制限
- 専任サポート
- SSO・監査ログ
- SLA 保証
Starter
個人・小規模プロジェクト向け
$86/yr
- プロジェクト 1 件
- コミュニティサポート
- 基本アナリティクス
Growth
おすすめ成長中のチーム向け
$278/yr
- プロジェクト 10 件
- 優先サポート
- 詳細アナリティクス
- カスタムドメイン
Enterprise
大規模組織向け
$950/yr
- プロジェクト無制限
- 専任サポート
- SSO・監査ログ
- SLA 保証
Yearly
Starter
個人・小規模プロジェクト向け
$9/mo
- プロジェクト 1 件
- コミュニティサポート
- 基本アナリティクス
Growth
おすすめ成長中のチーム向け
$29/mo
- プロジェクト 10 件
- 優先サポート
- 詳細アナリティクス
- カスタムドメイン
Enterprise
大規模組織向け
$99/mo
- プロジェクト無制限
- 専任サポート
- SSO・監査ログ
- SLA 保証
Starter
個人・小規模プロジェクト向け
$86/yr
- プロジェクト 1 件
- コミュニティサポート
- 基本アナリティクス
Growth
おすすめ成長中のチーム向け
$278/yr
- プロジェクト 10 件
- 優先サポート
- 詳細アナリティクス
- カスタムドメイン
Enterprise
大規模組織向け
$950/yr
- プロジェクト無制限
- 専任サポート
- SSO・監査ログ
- SLA 保証
使用部品
Rust コード
Rust
use fandhe_frontend_core::{div, li, p, span, text, ul, Node};
use fandhe_frontend_pre_styled_ui::badge::{self, BadgeProps, BadgeVariant};
use fandhe_frontend_pre_styled_ui::border_beam::BORDER_BEAM_CLASS;
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::tabs::{
self, ActivationMode, Orientation, TabItem, TabsProps, TabsVariant,
};
use fandhe_frontend_pre_styled_ui::{ColorPalette, Size};
/// 料金ティア 1 件分の静的データ(架空のプラン名・価格・機能一覧)。
struct Tier {
name: &'static str,
tagline: &'static str,
price_monthly: &'static str,
price_yearly: &'static str,
features: &'static [&'static str],
cta: &'static str,
recommended: bool,
}
/// 3 段のティア定義。中央(index 1)を `recommended: true` とする。
const TIERS: &[Tier] = &[
Tier {
name: "Starter",
tagline: "個人・小規模プロジェクト向け",
price_monthly: "$9",
price_yearly: "$86",
features: &[
"プロジェクト 1 件",
"コミュニティサポート",
"基本アナリティクス",
],
cta: "Get started",
recommended: false,
},
Tier {
name: "Growth",
tagline: "成長中のチーム向け",
price_monthly: "$29",
price_yearly: "$278",
features: &[
"プロジェクト 10 件",
"優先サポート",
"詳細アナリティクス",
"カスタムドメイン",
],
cta: "Get started",
recommended: true,
},
Tier {
name: "Enterprise",
tagline: "大規模組織向け",
price_monthly: "$99",
price_yearly: "$950",
features: &[
"プロジェクト無制限",
"専任サポート",
"SSO・監査ログ",
"SLA 保証",
],
cta: "Contact sales",
recommended: false,
},
];
/// ティア 1 件の `card` を組み立てる。`price`/`period_suffix` は呼び出し側
/// の billing 状態(月額/年額)に応じて切り替える。
fn tier_card(tier: &Tier, price: &'static str, period_suffix: &'static str) -> Node {
let mut heading_children = vec![card::title(vec![], vec![text(tier.name)])];
if tier.recommended {
heading_children.push(badge::badge(
&BadgeProps {
variant: BadgeVariant::Solid,
..BadgeProps::default()
},
vec![],
vec![text("おすすめ")],
));
}
let heading = div(
vec![("class", "blocks-pricing-tiers-morph-tier-heading")],
heading_children,
);
let price_row = div(
vec![("class", "blocks-pricing-tiers-morph-price")],
vec![
text(price),
span(
vec![("class", "blocks-pricing-tiers-morph-price-period")],
vec![text(period_suffix)],
),
],
);
let feature_list = ul(
vec![("class", "blocks-pricing-tiers-morph-features")],
tier.features
.iter()
.map(|feature| li(vec![], vec![text(*feature)]))
.collect(),
);
let card_node = card::root(
CardProps {
variant: CardVariant::Outline,
size: Size::Md,
},
vec![("data-blocks-pricing-tiers-morph-tier", "")],
vec![
card::header(
vec![],
vec![heading, card::description(vec![], vec![text(tier.tagline)])],
),
card::body(vec![], vec![price_row, feature_list]),
card::footer(
vec![("data-blocks-pricing-tiers-morph-footer", "")],
vec![button::button(
&ButtonProps {
variant: if tier.recommended {
ButtonVariant::Solid
} else {
ButtonVariant::Outline
},
..ButtonProps::default()
},
vec![],
vec![text(tier.cta)],
)],
),
],
);
if tier.recommended {
let featured_class = format!("{BORDER_BEAM_CLASS} blocks-pricing-tiers-morph-featured");
div(vec![("class", featured_class.as_str())], vec![card_node])
} else {
card_node
}
}
/// billing 状態 1 件分(月額 or 年額)のティア群グリッドを組み立てる。
fn billing_panel(price_of: fn(&Tier) -> &'static str, period_suffix: &'static str) -> Node {
div(
vec![("data-blocks-pricing-tiers-morph-grid", "")],
TIERS
.iter()
.map(|tier| tier_card(tier, price_of(tier), period_suffix))
.collect(),
)
}
/// billing 状態 1 件(月額/年額)分の `TabItem` 一覧を組み立てる。2 インス
/// タンスで同一の構成を使うため呼び出し側で複製せず都度生成する。
fn tab_items() -> Vec<TabItem<'static>> {
vec![
TabItem {
value: "monthly",
trigger: vec![text("Monthly")],
content: vec![billing_panel(|tier| tier.price_monthly, "/mo")],
disabled: false,
},
TabItem {
value: "yearly",
trigger: vec![text("Yearly")],
content: vec![billing_panel(|tier| tier.price_yearly, "/yr")],
disabled: false,
},
]
}
/// `pricing-tiers-morph` の Demo 本体。呼び出しごとに同一の `Node` を返す
/// 純関数。docs サイトは JS ハイドレーションを行わないため、`selected`
/// が異なる 2 個の `tabs` インスタンス(月額選択/年額選択)を縦に並べて
/// 静的に併記する(`sidebar-07` の expanded/collapsed 2 インスタンス併記
/// と同型の対処、モジュール doc「無 JS(docs サイト)での『2 状態併記』」
/// 参照)。
pub fn demo() -> Node {
let monthly_props = TabsProps {
id: "blocks-pricing-tiers-morph-monthly",
selected: "monthly",
orientation: Orientation::Horizontal,
activation_mode: ActivationMode::Automatic,
loop_focus: true,
indicator: false,
};
let yearly_props = TabsProps {
id: "blocks-pricing-tiers-morph-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,
tab_items(),
);
let yearly_tabs = tabs::tabs(
TabsVariant::Enclosed,
Size::Md,
ColorPalette::Accent,
&yearly_props,
tab_items(),
);
div(
vec![("data-blocks-pricing-tiers-morph-stack", "")],
vec![
p(
vec![("data-blocks-pricing-tiers-morph-caption", "")],
vec![text("Monthly")],
),
monthly_tabs,
p(
vec![("data-blocks-pricing-tiers-morph-caption", "")],
vec![text("Yearly")],
),
yearly_tabs,
],
)
}shadcn / Motion+ 側との構成上の判断
segment_groupではなくtabsを使う: headless-uisegment_groupはfandhe-frontend-wasm-fullに配線が一切無く、選択状態の変化に対応する表示切替はアプリ側の実装が前提です。tabsは wasm-full の既定 on スコープ featuretabsで実配線されており、contentパートは SSR 時点で選択されていない側にhidden+data-state="inactive"を、選択側にdata-state="active"(hiddenなし)を出力します。- 「モーフ」はクロスフェード(opacity/transform)のみ:
tabsのcontentパートには pre-styled-ui 側の recipe でpresence_transition(イシュー #2497)が適用されていないため、本 block は同関数と同型の宣言(opacity/transform+transition-behavior: allow-discrete+@starting-style)を block 固有 CSS に手書きで再現し、.blocks-pricing- tiers-morph [data-scope="tabs"][data-part="content"]へスコープ限定で適用しています(pre-styled-ui 自体は変更していません)。duration はvar(--fandhe-motion-duration-normal)のトークン参照のままのため、prefers-reduced-motion: reduceの既定縮退がそのまま効きます。共有レイアウト遷移(FLIP・layoutId相当)は別 issue(#2536)の担当であり、本 block では扱いません。 - 無 JS(docs サイト)での 2 状態併記: docs サイトは JS ハイドレーションを一切行わないため、単一の
tabsインスタンスへ両方の billing 状態を詰め込むだけでは、非選択側(年額)パネルは SSR 出力に存在してもhidden属性(+[hidden] { display: none })で閉じたまま操作できず実際には閲覧できません。そのため本 block はselectedが異なる 2 個のtabsインスタンス(月額選択/年額選択)を縦に並べて静的に併記し、月額・年額どちらの選択済み(可視)状態も実際にページ上へ現れるようにしています(sidebar-07の expanded/collapsed 2 インスタンス併記と同型の対処)。 - border-beam は opt-in 装飾: 「おすすめ」ティア(中央)の
card::rootをborder_beam::BORDER_BEAM_CLASS付きの素の<div>でラップして視覚的なフォーカスを与えています。cardはCardVariant::Outline(shadow ありの variant はoverflow: hiddenで影が切り取られるため回避)を使っています。 - プラン名・価格・機能一覧は架空: 実企業名・実クレデンシャル・PII を含みません。