First CH Designs
BLK-77Blocks

Pricing IV: Estimate Range & Cost Drivers

A pricing block for project work that resists fixed tiers: rows are cost drivers (page count, features, who supplies copy and photos, data migration, schedule) rather than plans. Each driver is a deviation bar on one shared money axis, grey to the left where the estimate goes down and amber to the right where it goes up. Opening a driver reveals the exact conditions that push it either way, and the list can be sorted by swing or kept in quote order. No total is shown — the block states why the bars must not be summed and hands off to an individual estimate. Narrow widths restack label, value and bar vertically. No images, no dependencies.

Added:
2026-09-25
Dependencies:
None
tags
#pricing #estimate #range #bar-chart #accordion #sort #cta #no-image

Preview

金額は何で動くのか、
先に幅のほうをお見せします

受託の制作費は、同じ「コーポレートサイト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>
  );
}

Add to your project via shadcn CLI

npx shadcn@latest add https://designs.first-ch.com/r/estimate-range-explainer.json