BLK-77Blocks
料金④見積レンジと変動要因(振れ幅バー)
定額表に収まらない受託の金額を、プランではなく「何で動くか」で見せる料金ブロック。行は金額を動かす要因(ページ数・機能・原稿の支給・移行・納期など)で、基準レンジからの振れ幅を共通の1本の軸に置いた偏差バーで示す。下がる側は灰、上がる側はアンバーに塗り分け、要因を押すと「下がる条件/上がる条件」が開く。振れ幅の大きい順と見積書の並び順を切り替えられる。合計は出さず、足し合わせない理由を書いてから個別見積の導線へ渡す。狭幅では名前+数値の行とバーを縦に積み替える。画像素材ゼロ・依存ゼロ。
- 追加日:
- 2026-09-25
- 依存:
- なし
- tags
- #pricing #estimate #range #bar-chart #accordion #sort #cta #no-image
プレビュー
金額は何で動くのか、
先に幅のほうをお見せします
受託の制作費は、同じ「コーポレートサイト1式」でも条件でかなり動きます。そこで定額の表は置かず、基準にしている規模と、そこから金額を動かす要因を並べました。バーは基準から左(下がる)と右(上がる)への振れ幅です。要因の名前を押すと、どんな条件でどちらへ動くのかが開きます。
- 基準にしている規模
- 90〜150万円
- 全6ページ原稿は支給更新機能なし納期3か月
金額を動かす要因 7
並び
濃い縦線が基準です。左へ伸びるほど下がり、右へ伸びるほど上がります(目盛りは60万円ごと)。
この表の数字は足し合わせていません
要因どうしが影響するためです。たとえばページ数が増えると1ページあたりの設計は軽くなり、更新機能を持てば原稿の作業は減ります。単純な加算では実際より高い数字になるので、合計は出しません。条件をうかがってから、この並びのまま内訳をお出しします。
条件を伝えて見積を依頼する
決まっていない項目は空欄のままで構いません。3営業日以内に幅つきの概算をお返しします。
"use client";
import { useEffect, useId, useRef, useState } from "react";
import { Montserrat } from "next/font/google";
const montserrat = Montserrat({
weight: ["500", "600"],
subsets: ["latin"],
display: "swap",
});
/**
* 見積レンジと変動要因(偏差バー・金額の振れ幅)
* 既存の料金ブロックは「プラン3枚」と「プラン×機能の比較表」で、どちらも金額が確定している前提の型だった。
* 本ブロックは列=プランを持たず、行=金額を動かす要因を取り、基準レンジからの振れ幅を左右のバーで見せる。
* 行を開くと「下がる条件/上がる条件」が出るので、比較ではなく自分の案件を当てはめる読み方になる。
* 合計は出さない(足し合わせない理由を明示して個別見積へ渡す)ことが構造上の主張。
* 飽和idiomは英字キッカーと末尾の「→」リンクを外し、角丸もゼロ(直線と余白だけで組む)。
* 掲載している金額・条件はすべて架空のオリジナル。画像素材ゼロ・依存ゼロ。
*
* 汎用技法メモ(web-design-playbook 還流用):
* - **偏差バーは基準線を原点に置く**: 各行の [lo, hi] を共通スケール1本(MIN..MAX)で %に写し、
* left/width で置く。バーの伸びるアニメーションは `transform-origin` を
* 「バー内での0の位置=(pos(0)-pos(lo))/(pos(hi)-pos(lo))」に%で入れると、
* 全行が基準線から左右へ同時に開く(scaleXの原点を要素の左端にすると基準がずれて意味が壊れる)。
* - **目盛りは行ごとに描いて列で揃える**: グリッド線を親に絶対配置すると、ラベル列の幅が
* コンテナクエリで変わった瞬間に目盛りとバーがずれる。行と見出しが同じ grid-template を共有し、
* 目盛りは各行のトラック内に % で置くと、どの幅でも定義上ずれない。
* - **狭幅は縮尺でなく積み方を変える**: ラベルとバーを横に並べると375pxでバーが100px未満になり
* 振れ幅が読めない。ラベル+数値の行とトラックを縦に積み、@lg で `contents` を使って
* 同じDOMのまま3カラムのグリッドへ組み替える(col-start を明示すれば order 指定は不要)。
* - **数値は必ずテキストでも置く**: バーは aria-hidden のまま、各行に「−20〜+120万円」の
* 文字を tabular-nums で持たせる。図を読めない環境でも情報が欠けない。
*/
type Factor = {
key: string;
name: string;
note: string;
/** 基準レンジからの振れ幅(万円・負=下がる / 正=上がる) */
lo: number;
hi: number;
down: string[];
up: string[];
};
/** 共通スケール(万円)。全行がこの1本の軸を共有する */
const MIN = -60;
const MAX = 180;
const TICKS = [-60, 0, 60, 120, 180];
/** 見積書に並ぶ順(既定の並び) */
const factors: Factor[] = [
{
key: "pages",
name: "ページ数",
note: "構成の広さ",
lo: -20,
hi: 120,
down: ["既存の構成をそのまま移設する", "下層ページを第2期にまわす"],
up: ["一覧+詳細のテンプレートを追加する", "独立した特集ページを作る"],
},
{
key: "features",
name: "機能の実装",
note: "更新のしくみ・外部連携",
lo: 0,
hi: 160,
down: ["更新頻度の低い箇所は静的のまま置く"],
up: [
"管理画面から更新できる範囲を広げる",
"多言語で同じ構成を持つ",
"予約・決済など外部サービスと繋ぐ",
],
},
{
key: "assets",
name: "原稿と写真",
note: "ご支給か、こちらで用意するか",
lo: -45,
hi: 70,
down: ["文章と写真を支給いただく", "既存サイトの原稿を流用する"],
up: ["取材して文章から作る", "撮影を手配する"],
},
{
key: "design",
name: "デザインの作り込み",
note: "作る画面の数と図版",
lo: -25,
hi: 90,
down: ["既存のトンマナと部品を踏襲する"],
up: ["主要ページを1枚ずつ個別に設計する", "図解・イラストを描き起こす"],
},
{
key: "migration",
name: "既存データの移行",
note: "記事・商品などの件数",
lo: 0,
hi: 55,
down: ["移行せず新規分から始める"],
up: ["数百件を構造ごと移し替える", "旧URLの転送表を作る"],
},
{
key: "schedule",
name: "公開までの期間",
note: "並行して進める度合い",
lo: -10,
hi: 45,
down: ["工程を直列に組み、余裕を持って進める"],
up: ["設計と実装を並行させて短縮する"],
},
{
key: "operation",
name: "公開後の体制",
note: "運用をどこまで預けるか",
lo: 0,
hi: 40,
down: ["公開後はご自身で更新する"],
up: ["更新代行と計測レポートを月次で持つ"],
},
];
const BASE_LO = 90;
const BASE_HI = 150;
const pos = (value: number) => ((value - MIN) / (MAX - MIN)) * 100;
const signed = (value: number) =>
value > 0 ? `+${value}` : value < 0 ? `−${Math.abs(value)}` : "0";
const rangeText = (factor: Factor) =>
`${signed(factor.lo)} 〜 ${signed(factor.hi)}`;
const swing = (factor: Factor) => factor.hi - factor.lo;
type Order = "ledger" | "swing";
export default function EstimateRangeExplainer() {
const rootRef = useRef<HTMLElement>(null);
const uid = useId().replace(/[^a-zA-Z0-9]/g, "");
const [order, setOrder] = useState<Order>("swing");
const [open, setOpen] = useState<string | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
// 動きを控える設定では「伸び切った状態」を唯一の状態にする
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
root.setAttribute("data-shown", "");
return;
}
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
root.setAttribute("data-shown", "");
observer.unobserve(entry.target);
}
},
{ rootMargin: "0px 0px -12% 0px" }
);
observer.observe(root);
return () => observer.disconnect();
}, []);
const rows =
order === "swing"
? [...factors].sort((a, b) => swing(b) - swing(a))
: factors;
return (
<section
ref={rootRef}
aria-labelledby={`${uid}-title`}
className="@container w-full max-w-5xl bg-white px-5 py-10 text-gray-900 sm:px-8 sm:py-14"
>
<style>{`
.est-bar{opacity:0;transform:scaleX(0);transition:transform 720ms cubic-bezier(.22,.61,.36,1),opacity 320ms ease-out;transition-delay:var(--d)}
[data-shown] .est-bar{opacity:1;transform:none}
.est-detail{animation:est-in 240ms ease-out}
@keyframes est-in{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:none}}
@media (prefers-reduced-motion: reduce){
.est-bar{opacity:1;transform:none;transition:none}
.est-detail{animation:none}
}
`}</style>
<header className="@3xl:grid @3xl:grid-cols-[minmax(0,1fr)_17rem] @3xl:gap-12">
<div>
<h2
id={`${uid}-title`}
className="text-[clamp(1.35rem,3.4vw,1.9rem)] leading-[1.5] font-bold tracking-tight"
>
金額は何で動くのか、
<br />
先に幅のほうをお見せします
</h2>
<p className="mt-5 max-w-lg text-[13px] leading-[2] text-gray-600">
受託の制作費は、同じ「コーポレートサイト1式」でも条件でかなり動きます。そこで定額の表は置かず、基準にしている規模と、そこから金額を動かす要因を並べました。バーは基準から左(下がる)と右(上がる)への振れ幅です。要因の名前を押すと、どんな条件でどちらへ動くのかが開きます。
</p>
</div>
<dl className="mt-8 border-t border-gray-900 pt-4 @3xl:mt-0">
<dt className="text-[11px] font-bold tracking-wide text-gray-500">
基準にしている規模
</dt>
<dd
className={`${montserrat.className} mt-2 text-[1.75rem] leading-none font-semibold tabular-nums`}
>
{BASE_LO}
<span className="mx-1 text-[15px] font-medium text-gray-400">〜</span>
{BASE_HI}
<span className="ml-1 text-[13px] font-medium text-gray-500">万円</span>
</dd>
<dd className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-[11px] leading-[1.9] text-gray-600">
<span>全6ページ</span>
<span>原稿は支給</span>
<span>更新機能なし</span>
<span>納期3か月</span>
</dd>
</dl>
</header>
{/* 並び替え(角丸なしのセグメント。押されている側を反転で示す) */}
<div className="mt-10 flex flex-wrap items-baseline justify-between gap-x-6 gap-y-3 border-b border-gray-900 pb-3">
<p className="text-[11px] font-bold tracking-wide text-gray-500">
金額を動かす要因{" "}
<span className={`${montserrat.className} tabular-nums text-gray-400`}>
{factors.length}
</span>
</p>
<div className="flex items-center gap-3 text-[11px]">
<span id={`${uid}-order`} className="hidden text-gray-500 @sm:inline">
並び
</span>
<div className="flex border border-gray-300" role="group" aria-labelledby={`${uid}-order`}>
{(
[
["swing", "振れ幅の大きい順"],
["ledger", "見積書の並び"],
] as const
).map(([value, label]) => (
<button
key={value}
type="button"
aria-pressed={order === value}
onClick={() => setOrder(value)}
className={`px-2.5 py-1.5 font-bold whitespace-nowrap transition-colors duration-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 motion-reduce:transition-none ${
order === value
? "bg-gray-900 text-white"
: "text-gray-600 hover:text-gray-900"
}`}
>
{label}
</button>
))}
</div>
</div>
</div>
{/* 狭幅は目盛りを置けないので、読み方だけ文字で添える */}
<p className="mt-4 text-[10px] leading-[1.8] text-gray-500 @lg:hidden">
濃い縦線が基準です。左へ伸びるほど下がり、右へ伸びるほど上がります(目盛りは60万円ごと)。
</p>
{/* 軸の見出し(行と同じ grid-template を共有するので目盛りが必ず揃う) */}
<div
aria-hidden="true"
className="mt-4 hidden text-[10px] text-gray-400 @lg:grid @lg:grid-cols-[13rem_minmax(0,1fr)_6.5rem] @lg:items-end @lg:gap-x-5"
>
<span className="col-start-1 text-gray-500">基準からの振れ幅(万円)</span>
<span className="relative col-start-2 block h-4">
{TICKS.map((tick) => (
<span
key={tick}
className={`${montserrat.className} absolute bottom-0 -translate-x-1/2 tabular-nums whitespace-nowrap ${
tick === 0 ? "font-semibold text-gray-700" : ""
}`}
style={{ left: `${pos(tick)}%` }}
>
{tick === 0 ? "基準" : signed(tick)}
</span>
))}
</span>
</div>
<ul className="border-t border-gray-200 @lg:mt-1">
{rows.map((factor, index) => {
const isOpen = open === factor.key;
const left = pos(factor.lo);
const right = pos(factor.hi);
const width = right - left;
// バー内での「基準(0)」の位置。ここを原点に左右へ開かせる
const originPct = ((pos(0) - left) / width) * 100;
return (
<li key={factor.key} className="border-b border-gray-200">
<h3>
<button
type="button"
aria-expanded={isOpen}
aria-controls={`${uid}-${factor.key}`}
onClick={() => setOpen(isOpen ? null : factor.key)}
className="group flex w-full flex-col gap-2.5 py-4 text-left transition-colors duration-200 hover:bg-amber-50/60 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 motion-reduce:transition-none @lg:grid @lg:grid-cols-[13rem_minmax(0,1fr)_6.5rem] @lg:items-center @lg:gap-x-5 @lg:gap-y-0"
>
{/* 狭幅は「名前+数値/注記/バー」の3段。@lg は contents でグリッドへ展開する */}
<span className="flex flex-col gap-1 @lg:contents">
<span className="flex items-baseline justify-between gap-3 @lg:contents">
<span className="col-start-1 row-start-1 text-[14px] leading-[1.6] font-bold @lg:self-end">
{factor.name}
</span>
<span
className={`${montserrat.className} col-start-3 row-start-1 shrink-0 text-[12px] leading-none tabular-nums whitespace-nowrap text-gray-700 @lg:row-span-2 @lg:self-center @lg:text-right`}
>
{rangeText(factor)}
<span className="ml-1 text-[10px] text-gray-400">万円</span>
</span>
</span>
<span className="col-start-1 row-start-2 text-[11px] leading-[1.6] text-gray-500 @lg:self-start">
{factor.note}
</span>
</span>
{/* トラック(目盛り+偏差バー)。図は aria-hidden、値は右の文字が持つ */}
<span
aria-hidden="true"
className="relative col-start-2 row-start-1 block h-7 w-full @lg:row-span-2 @lg:self-center"
>
{TICKS.map((tick) => (
<span
key={tick}
className={`absolute top-0 bottom-0 w-px ${
tick === 0 ? "bg-gray-900" : "bg-gray-200"
}`}
style={{ left: `${pos(tick)}%` }}
/>
))}
<span
className="est-bar absolute top-1/2 flex h-2.5 -translate-y-1/2"
style={{
left: `${left}%`,
width: `${width}%`,
transformOrigin: `${originPct}% 50%`,
["--d" as string]: `${120 + index * 70}ms`,
}}
>
{factor.lo < 0 && (
<span
className="h-full bg-gray-300"
style={{ width: `${((0 - factor.lo) / swing(factor)) * 100}%` }}
/>
)}
{factor.hi > 0 && (
<span
className={`h-full flex-1 transition-colors duration-200 motion-reduce:transition-none ${
isOpen ? "bg-amber-600" : "bg-amber-600/70 group-hover:bg-amber-600"
}`}
/>
)}
</span>
</span>
</button>
</h3>
<div id={`${uid}-${factor.key}`} hidden={!isOpen}>
{isOpen && (
<div className="est-detail pb-6 @lg:grid @lg:grid-cols-[13rem_minmax(0,1fr)] @lg:gap-x-5">
<dl className="col-start-2 grid gap-x-8 gap-y-5 border-l-2 border-amber-600 pl-4 @xl:grid-cols-2">
<div>
<dt className="text-[11px] font-bold text-gray-500">
下がる条件
</dt>
<dd className="mt-2">
<ul className="space-y-1.5 text-[12px] leading-[1.9] text-gray-700">
{factor.down.map((item) => (
<li key={item} className="flex gap-2">
<span
aria-hidden="true"
className="mt-[0.7em] h-px w-2.5 shrink-0 bg-gray-400"
/>
{item}
</li>
))}
</ul>
</dd>
</div>
<div>
<dt className="text-[11px] font-bold text-gray-500">
上がる条件
</dt>
<dd className="mt-2">
<ul className="space-y-1.5 text-[12px] leading-[1.9] text-gray-700">
{factor.up.map((item) => (
<li key={item} className="flex gap-2">
<span
aria-hidden="true"
className="mt-[0.7em] h-px w-2.5 shrink-0 bg-amber-600"
/>
{item}
</li>
))}
</ul>
</dd>
</div>
</dl>
</div>
)}
</div>
</li>
);
})}
</ul>
{/* 合計を出さない理由 → 個別見積への導線(矢印は使わない) */}
<div className="mt-8 border-t-2 border-gray-900 pt-6 @2xl:grid @2xl:grid-cols-[minmax(0,1fr)_15rem] @2xl:gap-10">
<div>
<p className="text-[13px] leading-[1.9] font-bold">
この表の数字は足し合わせていません
</p>
<p className="mt-3 max-w-xl text-[12px] leading-[2] text-gray-600">
要因どうしが影響するためです。たとえばページ数が増えると1ページあたりの設計は軽くなり、更新機能を持てば原稿の作業は減ります。単純な加算では実際より高い数字になるので、合計は出しません。条件をうかがってから、この並びのまま内訳をお出しします。
</p>
</div>
<div className="mt-6 @2xl:mt-0">
<a
href="#"
className="block bg-gray-900 px-5 py-4 text-center text-[13px] font-bold text-white transition-colors duration-200 hover:bg-amber-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 motion-reduce:transition-none"
>
条件を伝えて見積を依頼する
</a>
<p className="mt-3 text-[11px] leading-[1.9] text-gray-500">
決まっていない項目は空欄のままで構いません。3営業日以内に幅つきの概算をお返しします。
</p>
</div>
</div>
</section>
);
}
shadcn CLI でプロジェクトに追加
npx shadcn@latest add https://designs.first-ch.com/r/estimate-range-explainer.json