BizLayout

型番 conversion-funnel

コンバージョンファネル(段階離脱分析)

同一母集団が段階的に絞られる様子を件数比例の連続漏斗として描く。件名は各断面の上、件数値と主要指標は断面線上へ置いて水平ガイドで結び、hover・focusでは同じ断面レコードだけを穏やかに強調する。最大離脱後の断面を1件常設強調し、同じ漏斗上で到達情報から離脱数・離脱率・順位へ切り替えられる

funnel · conversion · drop-off · pipeline · data-driven · interactive · progressive-filtering · semantic-geometry · svg

LIVE SAMPLE

プレビュー

補足の開閉・表示切替を試せます

案件転換ファネル — 問い合わせから有効商談への離脱が最大

2026年4–6月 新規案件1,240件(重複・既存顧客更新を除外)

問い合わせ 1,240件、有効商談 820件、提案 510件、PoC 280件、稟議通過 118件、契約 96件。最大離脱は問い合わせから有効商談の420件

問い合わせ

1,240

有効商談

820

提案

510

PoC

280

稟議通過

118

契約

96

100%

66.1%

目標 72%

62.2%

目標 68%

54.9%

目標 60%

42.1%

目標 50%

81.4%

目標 75%

補足を確認6

問い合わせ

1,240 ・ 到達率 100%

Web、紹介、イベント経由の新規問い合わせ

有効商談

820 ・ 到達率 66.1%

予算・決裁者・導入時期の3条件を確認。420件が対象外

提案

510 ・ 到達率 62.2%

課題仮説と概算費用を顧客へ提示

PoC

280 ・ 到達率 54.9%

主要ユースケースで技術・業務適合性を検証

稟議通過

118 ・ 到達率 42.1%

投資対効果と導入体制を顧客社内で承認

契約

96 ・ 到達率 81.4%

契約条件と開始日を合意

こういうときに使う

  • 営業・採用・オンボーディングの段階別転換を説明する場合
  • 問い合わせ処理やデータ移行で、どの段階から対象が減るかを示す場合
  • 加減算要因ではなく、同一母集団が順番に絞られる構造を扱う場合
  • 最大の改善余地がある段階間を決めたい場合

ALTERNATIVES

条件を変えて探す →

データスキーマ

title
string — スライドタイトル。最大離脱区間を結論として書く
subtitle
string(任意)— 対象期間、母集団、出典など
unit
string(任意)— 件、人、社など
stages
{ label, count, targetRate?, note? }[] — 3〜7件。countは後段ほど同じか小さくする

サンプルコード

template.tsx600 行
import { useId } from "react";
import type { ConversionFunnelProps, FunnelStage } from "./schema";

export type FunnelRow = FunnelStage & {
  originalIndex: number;
  conversionRate: number;
  dropOff: number;
  lossRate: number;
};

export type FunnelStageGeometry = FunnelRow & {
  y: number;
  width: number;
  left: number;
  right: number;
};

export type FunnelTransitionGeometry = {
  fromIndex: number;
  toIndex: number;
  labelY: number;
  dropOff: number;
  lossRate: number;
  conversionRate: number;
  rank: number;
  isPriority: boolean;
};

export type FunnelGeometry = {
  stages: FunnelStageGeometry[];
  transitions: FunnelTransitionGeometry[];
};

const STREAM_CENTER_X = 46;
const STREAM_MAX_WIDTH = 34;
const STREAM_TOP_Y = 10;
const STREAM_BOTTOM_Y = 90;

export function funnelRows(stages: readonly FunnelStage[]): FunnelRow[] {
  return stages.map((stage, originalIndex) => {
    const previous = stages[originalIndex - 1];
    if (!previous) {
      return {
        ...stage,
        originalIndex,
        conversionRate: 100,
        dropOff: 0,
        lossRate: 0,
      };
    }
    const conversionRate =
      previous.count === 0 ? 0 : (stage.count / previous.count) * 100;
    const dropOff = Math.max(0, previous.count - stage.count);
    return {
      ...stage,
      originalIndex,
      conversionRate,
      dropOff,
      lossRate: 100 - conversionRate,
    };
  });
}

export function rankedLeaks(stages: readonly FunnelStage[]) {
  return funnelRows(stages)
    .slice(1)
    .sort(
      (a, b) => b.dropOff - a.dropOff || a.originalIndex - b.originalIndex,
    );
}

export function biggestLeak(stages: readonly FunnelStage[]) {
  return rankedLeaks(stages)[0] ?? null;
}

export function stageWidthPct(count: number, firstCount: number) {
  return firstCount === 0
    ? 0
    : Math.max(0, Math.min(100, (count / firstCount) * 100));
}

export function funnelGeometry(
  stages: readonly FunnelStage[],
): FunnelGeometry {
  const rows = funnelRows(stages);
  const firstCount = stages[0]?.count ?? 0;
  const ySpan = STREAM_BOTTOM_Y - STREAM_TOP_Y;
  const lastIndex = Math.max(1, rows.length - 1);
  const ranked = rankedLeaks(stages);
  const rankByStage = new Map(
    ranked.map((row, index) => [row.originalIndex, index + 1]),
  );
  const priorityIndex = ranked[0]?.originalIndex;

  const stageGeometry = rows.map((row, index) => {
    const width =
      (stageWidthPct(row.count, firstCount) / 100) * STREAM_MAX_WIDTH;
    const y = STREAM_TOP_Y + (index / lastIndex) * ySpan;
    return {
      ...row,
      y,
      width,
      left: STREAM_CENTER_X - width / 2,
      right: STREAM_CENTER_X + width / 2,
    };
  });

  const transitions = stageGeometry.slice(1).map((stage, index) => {
    return {
      fromIndex: index,
      toIndex: index + 1,
      labelY: stage.y,
      dropOff: stage.dropOff,
      lossRate: stage.lossRate,
      conversionRate: stage.conversionRate,
      rank: rankByStage.get(stage.originalIndex) ?? 0,
      isPriority: stage.originalIndex === priorityIndex,
    };
  });

  return { stages: stageGeometry, transitions };
}

const coordinate = (value: number) => String(Number(value.toFixed(3)));

export function streamPath(stages: readonly FunnelStageGeometry[]) {
  if (stages.length === 0) return "";

  let path = `M ${coordinate(stages[0].left)} ${coordinate(stages[0].y)}`;
  for (let index = 1; index < stages.length; index += 1) {
    const previous = stages[index - 1];
    const current = stages[index];
    const middleY = (previous.y + current.y) / 2;
    path += ` C ${coordinate(previous.left)} ${coordinate(middleY)}, ${coordinate(current.left)} ${coordinate(middleY)}, ${coordinate(current.left)} ${coordinate(current.y)}`;
  }

  const last = stages.at(-1)!;
  path += ` L ${coordinate(last.right)} ${coordinate(last.y)}`;

  for (let index = stages.length - 2; index >= 0; index -= 1) {
    const current = stages[index + 1];
    const previous = stages[index];
    const middleY = (previous.y + current.y) / 2;
    path += ` C ${coordinate(current.right)} ${coordinate(middleY)}, ${coordinate(previous.right)} ${coordinate(middleY)}, ${coordinate(previous.right)} ${coordinate(previous.y)}`;
  }

  return `${path} Z`;
}

const formatNumber = (value: number) =>
  new Intl.NumberFormat("ja-JP", { maximumFractionDigits: 1 }).format(value);
const stagger = (index: number) => `calc(var(--stagger-step) * ${index})`;

export default function ConversionFunnel({
  title,
  subtitle,
  unit = "件",
  stages,
  instanceId,
}: ConversionFunnelProps) {
  const autoId = useId();
  const uid =
    (instanceId ?? autoId).replace(/[^a-zA-Z0-9_-]/g, "") || "funnel";
  const gradientId = `${uid}-cross-section-gradient`;
  const geometry = funnelGeometry(stages);
  const priority = geometry.transitions.find(
    (transition) => transition.isPriority,
  );
  const transitionByStage = new Map(
    geometry.transitions.map((transition) => [
      transition.toIndex,
      transition,
    ]),
  );
  const notes = geometry.stages.filter((stage) => stage.note);
  const stageLabel = (index: number) => geometry.stages[index]?.label ?? "";
  const stageHitAreaPct = Math.min(
    14,
    72 / Math.max(1, geometry.stages.length - 1),
  );
  const interactionStyles = `
[data-funnel-instance="${uid}"] [data-funnel-stage-connector] {
  transition: stroke-opacity 140ms ease-out, stroke-width 140ms ease-out;
}
[data-funnel-instance="${uid}"] [data-funnel-cross-section] {
  transition: stroke 140ms ease-out, stroke-width 140ms ease-out;
}
[data-funnel-instance="${uid}"] [data-funnel-stage-primary] {
  transition: color 140ms ease-out;
}
[data-funnel-instance="${uid}"] [data-funnel-stage-hotspot]:focus-visible {
  outline: none;
}
${geometry.stages
  .map(
    (stage) => `
[data-funnel-instance="${uid}"]:has([data-funnel-stage-hotspot~="${stage.originalIndex}"]:is(:hover, :focus)) [data-funnel-stage-connector][data-stage-index="${stage.originalIndex}"] {
  stroke-opacity: 0.95;
  stroke-width: 1.5;
}
[data-funnel-instance="${uid}"]:has([data-funnel-stage-hotspot~="${stage.originalIndex}"]:is(:hover, :focus)) [data-funnel-cross-section][data-stage-index="${stage.originalIndex}"] {
  stroke-width: ${transitionByStage.get(stage.originalIndex)?.isPriority ? "5" : "3"};
}
[data-funnel-instance="${uid}"]:has([data-funnel-stage-hotspot~="${stage.originalIndex}"]:is(:hover, :focus)) [data-funnel-cross-section][data-stage-index="${stage.originalIndex}"]:not([data-highlight]) {
  stroke: var(--color-outline);
}
[data-funnel-instance="${uid}"]:has([data-funnel-stage-hotspot~="${stage.originalIndex}"]:is(:hover, :focus)) [data-funnel-stage-primary][data-stage-index="${stage.originalIndex}"] {
  color: var(--color-tertiary);
}`,
  )
  .join("")}
`;

  return (
    <section
      data-funnel-instance={uid}
      className="group/funnel mx-auto max-w-slide bg-surface p-6 font-sans text-on-surface sm:p-12"
    >
      <style>{interactionStyles}</style>
      <input
        id={`${uid}-funnel`}
        name={`${uid}-view`}
        type="radio"
        defaultChecked
        className="funnel-shape-view sr-only"
      />
      <input
        id={`${uid}-leaks`}
        name={`${uid}-view`}
        type="radio"
        className="funnel-leak-view sr-only"
      />

      <header className="mb-7 flex flex-wrap items-end justify-between gap-4">
        <div className="max-w-3xl">
          <h1 className="text-h1">{title}</h1>
          {subtitle && (
            <p className="mt-2 text-body-sm text-secondary">{subtitle}</p>
          )}
        </div>
        <div className="flex rounded-sm border border-outline bg-surface-subtle p-1 shadow-card">
          <label
            htmlFor={`${uid}-funnel`}
            className="cursor-pointer rounded-sm px-4 py-1.5 text-label text-secondary group-has-[.funnel-shape-view:checked]/funnel:bg-primary group-has-[.funnel-shape-view:checked]/funnel:text-on-primary"
          >
            ファネル
          </label>
          <label
            htmlFor={`${uid}-leaks`}
            className="cursor-pointer rounded-sm px-4 py-1.5 text-label text-secondary group-has-[.funnel-leak-view:checked]/funnel:bg-primary group-has-[.funnel-leak-view:checked]/funnel:text-on-primary"
          >
            離脱分析
          </label>
        </div>
      </header>

      <div
        data-funnel-visual="true"
        className="relative h-96 overflow-hidden border-y border-outline bg-surface-subtle"
      >
        <p className="sr-only">
          {geometry.stages
            .map(
              (stage) =>
                `${stage.label} ${formatNumber(stage.count)}${unit}`,
            )
            .join("、")}
          。最大離脱は
          {priority
            ? `${stageLabel(priority.fromIndex)}から${stageLabel(priority.toIndex)}の${formatNumber(priority.dropOff)}${unit}`
            : "ありません"}
          。
        </p>

        <div
          aria-hidden="true"
          className="absolute inset-y-0 left-1/4 border-l border-outline"
        />
        <div
          aria-hidden="true"
          className="absolute inset-y-0 right-1/4 border-r border-outline"
        />

        <svg
          viewBox="0 0 100 100"
          preserveAspectRatio="none"
          className="absolute inset-0 h-full w-full"
          aria-hidden="true"
        >
          <defs>
            <linearGradient
              id={gradientId}
              gradientUnits="userSpaceOnUse"
              x1="0"
              y1="0"
              x2="100"
              y2="0"
            >
              <stop offset="0%" stopColor="var(--color-tertiary)" />
              <stop offset="100%" stopColor="var(--color-accent-violet)" />
            </linearGradient>
          </defs>

          {geometry.stages.flatMap((stage) => [
            <line
              key={`${stage.originalIndex}-left`}
              data-funnel-stage-connector="true"
              data-side="left"
              data-stage-index={stage.originalIndex}
              x1="25"
              x2={stage.left}
              y1={stage.y}
              y2={stage.y}
              className="stroke-outline"
              strokeWidth="1"
              strokeOpacity="0.65"
              strokeDasharray="1.5 1.5"
              strokeLinecap="round"
              vectorEffect="non-scaling-stroke"
            />,
            <line
              key={`${stage.originalIndex}-right`}
              data-funnel-stage-connector="true"
              data-side="right"
              data-stage-index={stage.originalIndex}
              x1={stage.right}
              x2="75"
              y1={stage.y}
              y2={stage.y}
              className="stroke-outline"
              strokeWidth="1"
              strokeOpacity="0.65"
              strokeDasharray="1.5 1.5"
              strokeLinecap="round"
              vectorEffect="non-scaling-stroke"
            />,
          ])}

          <path
            data-funnel-stream="true"
            d={streamPath(geometry.stages)}
            className="animate-rise fill-neutral stroke-outline"
            strokeWidth="1"
            vectorEffect="non-scaling-stroke"
          />

          {geometry.stages.map((stage) => {
            const transition = transitionByStage.get(stage.originalIndex);
            const highlighted = transition?.isPriority ?? false;
            return (
              <line
                key={stage.originalIndex}
                data-funnel-cross-section="true"
                data-stage-index={stage.originalIndex}
                data-highlight={highlighted ? "true" : undefined}
                data-drop-off={transition?.dropOff}
                x1={stage.left}
                x2={stage.right}
                y1={stage.y}
                y2={stage.y}
                stroke={highlighted ? `url(#${gradientId})` : undefined}
                className={highlighted ? "animate-rise" : "stroke-surface"}
                strokeWidth={highlighted ? "4" : "2"}
                strokeLinecap="round"
                vectorEffect="non-scaling-stroke"
                style={{ animationDelay: stagger(stage.originalIndex) }}
              />
            );
          })}
        </svg>

        <div
          data-testid="funnel-shape"
          className="absolute inset-0 block group-has-[.funnel-leak-view:checked]/funnel:hidden"
        >
          {geometry.stages.map((stage, index) => (
            <div
              key={stage.originalIndex}
              data-funnel-stage="true"
              data-stage-index={stage.originalIndex}
              className="absolute left-0 w-1/4 text-right"
              style={{
                top: `${stage.y}%`,
                animationDelay: stagger(index),
              }}
            >
              <p
                data-funnel-stage-label="true"
                className="absolute right-3 bottom-4 whitespace-nowrap text-label sm:right-5"
              >
                {stage.label}
              </p>
              <p
                data-funnel-stage-count="true"
                data-funnel-stage-primary="true"
                data-stage-index={stage.originalIndex}
                className="absolute top-0 right-3 -translate-y-1/2 whitespace-nowrap font-mono text-h3 sm:right-5"
              >
                {formatNumber(stage.count)}
                <span className="ml-1 text-label text-secondary">{unit}</span>
              </p>
            </div>
          ))}

          {geometry.stages.map((stage) => {
            const transition = transitionByStage.get(stage.originalIndex);
            const meetsTarget =
              stage.targetRate === undefined ||
              stage.conversionRate >= stage.targetRate;
            return (
              <div
                key={stage.originalIndex}
                data-funnel-stage-metric="true"
                data-stage-index={stage.originalIndex}
                className="absolute right-0 w-1/4"
                style={{ top: `${stage.y}%` }}
              >
                <p
                  data-funnel-stage-rate="true"
                  data-funnel-stage-primary="true"
                  data-stage-index={stage.originalIndex}
                  className="absolute top-0 left-3 -translate-y-1/2 whitespace-nowrap font-mono text-label text-secondary sm:left-5"
                >
                  {transition ? (
                    <>
                      <span className="sm:hidden">
                        {formatNumber(transition.conversionRate)}%
                      </span>
                      <span className="hidden sm:inline">
                        前段比 {formatNumber(transition.conversionRate)}%
                      </span>
                    </>
                  ) : (
                    <>
                      <span className="sm:hidden">100%</span>
                      <span className="hidden sm:inline">母数 100%</span>
                    </>
                  )}
                </p>
                {stage.targetRate !== undefined && (
                  <p
                    className={`absolute top-4 left-3 whitespace-nowrap font-mono text-label sm:left-5 ${
                      meetsTarget ? "text-success" : "text-error"
                    }`}
                  >
                    目標 {formatNumber(stage.targetRate)}%
                  </p>
                )}
              </div>
            );
          })}
        </div>

        <div
          data-testid="funnel-leaks"
          className="absolute inset-0 hidden group-has-[.funnel-leak-view:checked]/funnel:block"
        >
          {geometry.stages.map((stage) => (
            <div
              key={stage.originalIndex}
              data-stage-index={stage.originalIndex}
              className="absolute left-0 w-1/4 text-right"
              style={{ top: `${stage.y}%` }}
            >
              <p className="absolute right-3 bottom-4 whitespace-nowrap text-label text-secondary sm:right-5">
                {stage.label}
              </p>
              <p
                data-funnel-diagnostic-count="true"
                data-funnel-stage-primary="true"
                data-stage-index={stage.originalIndex}
                className="absolute top-0 right-3 -translate-y-1/2 whitespace-nowrap font-mono text-label sm:right-5"
              >
                {formatNumber(stage.count)}
                {unit}
              </p>
            </div>
          ))}

          {geometry.stages.map((stage) => {
            const transition = transitionByStage.get(stage.originalIndex);
            return (
              <div
                key={stage.originalIndex}
                data-funnel-stage-diagnostic="true"
                data-stage-index={stage.originalIndex}
                data-priority={
                  transition?.isPriority ? "true" : undefined
                }
                className="absolute right-0 w-1/4"
                style={{ top: `${stage.y}%` }}
              >
                {transition ? (
                  <>
                    <p
                      className={`absolute bottom-4 left-3 whitespace-nowrap font-mono text-label sm:left-5 ${
                        transition.isPriority
                          ? "text-tertiary"
                          : "text-secondary"
                      }`}
                    >
                      {String(transition.rank).padStart(2, "0")}
                      {transition.isPriority && (
                        <span className="ml-2 hidden sm:inline">最大離脱</span>
                      )}
                    </p>
                    <p
                      data-funnel-diagnostic-value="true"
                      data-funnel-stage-primary="true"
                      data-stage-index={stage.originalIndex}
                      className="absolute top-0 left-3 -translate-y-1/2 whitespace-nowrap font-mono text-h3 sm:left-5"
                    >
                      −{formatNumber(transition.dropOff)}
                      <span className="ml-1 text-label text-secondary">
                        {unit}
                      </span>
                    </p>
                    <p className="absolute top-4 left-3 whitespace-nowrap font-mono text-label text-secondary sm:left-5">
                      <span className="sm:hidden">
                        {formatNumber(transition.lossRate)}%
                      </span>
                      <span className="hidden sm:inline">
                        離脱率 {formatNumber(transition.lossRate)}%
                      </span>
                    </p>
                  </>
                ) : (
                  <p
                    data-funnel-diagnostic-value="true"
                    data-funnel-stage-primary="true"
                    data-stage-index={stage.originalIndex}
                    className="absolute top-0 left-3 -translate-y-1/2 whitespace-nowrap font-mono text-label text-secondary sm:left-5"
                  >
                    起点
                  </p>
                )}
              </div>
            );
          })}
        </div>

        {geometry.stages.map((stage) => {
          const transition = transitionByStage.get(stage.originalIndex);
          const stageSummary = transition
            ? `${stage.label}、${formatNumber(stage.count)}${unit}、前段比${formatNumber(transition.conversionRate)}%、離脱${formatNumber(transition.dropOff)}${unit}`
            : `${stage.label}、${formatNumber(stage.count)}${unit}、起点`;
          return (
            <div
              key={stage.originalIndex}
              data-funnel-stage-hotspot={stage.originalIndex}
              data-stage-index={stage.originalIndex}
              tabIndex={0}
              role="group"
              aria-label={stageSummary}
              className="absolute inset-x-0 z-10 -translate-y-1/2 cursor-default focus:outline-none"
              style={{
                top: `${stage.y}%`,
                height: `${stageHitAreaPct}%`,
              }}
            />
          );
        })}
      </div>

      {notes.length > 0 && (
        <details
          data-inline-disclosure="true"
          className="group mt-6 rounded-lg border border-outline bg-surface-subtle"
        >
          <summary className="flex cursor-pointer list-none items-center px-4 py-3 text-label text-secondary focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tertiary">
            <span>補足を確認</span>
            <span className="ml-auto font-mono">{notes.length}件</span>
            <span
              aria-hidden="true"
              className="ml-2 text-tertiary transition-transform group-open:rotate-45"
            >
              +
            </span>
          </summary>
          <div className="grid gap-3 border-t border-outline p-4 sm:grid-cols-2">
            {notes.map((stage) => (
              <article
                key={stage.originalIndex}
                data-note-item="true"
                className="rounded-md bg-surface p-4"
              >
                <h3 className="text-label">{stage.label}</h3>
                <p className="mt-1 font-mono text-label text-secondary">
                  {formatNumber(stage.count)}
                  {unit} ・ 到達率 {formatNumber(stage.conversionRate)}%
                </p>
                <p className="mt-2 text-body-sm">{stage.note}</p>
              </article>
            ))}
          </div>
        </details>
      )}
    </section>
  );
}
schema.ts30 行
import { z } from "zod";

export const funnelStageSchema = z
  .object({
    label: z.string().min(1).describe("段階名"),
    count: z.number().nonnegative().describe("その段階へ到達した件数。後段ほど同じか小さくする"),
    targetRate: z.number().min(0).max(100).optional().describe("前段からの目標転換率(%)"),
    note: z.string().optional().describe("離脱要因、定義、改善策などの補足"),
  })
  .strict();

export const schema = z
  .object({
    title: z.string().describe("スライドタイトル。最大離脱と判断を結論として書く"),
    subtitle: z.string().optional().describe("対象期間、母集団、出典など"),
    unit: z.string().optional().describe("件、人、社などの表示単位"),
    stages: z.array(funnelStageSchema).min(3).max(7).describe("先頭から終点までの段階(3〜7件)"),
    instanceId: z.string().min(1).optional().describe("表示切替radioの識別子"),
  })
  .strict()
  .superRefine((data, ctx) => {
    data.stages.slice(1).forEach((stage, index) => {
      if (stage.count > data.stages[index].count) {
        ctx.addIssue({ code: "custom", message: "後段のcountは前段以下にすること", path: ["stages", index + 1, "count"] });
      }
    });
  });

export type FunnelStage = z.infer<typeof funnelStageSchema>;
export type ConversionFunnelProps = z.infer<typeof schema>;
sample-data.json13 行
{
  "title": "案件転換ファネル — 問い合わせから有効商談への離脱が最大",
  "subtitle": "2026年4–6月 新規案件1,240件(重複・既存顧客更新を除外)",
  "unit": "件",
  "stages": [
    { "label": "問い合わせ", "count": 1240, "note": "Web、紹介、イベント経由の新規問い合わせ" },
    { "label": "有効商談", "count": 820, "targetRate": 72, "note": "予算・決裁者・導入時期の3条件を確認。420件が対象外" },
    { "label": "提案", "count": 510, "targetRate": 68, "note": "課題仮説と概算費用を顧客へ提示" },
    { "label": "PoC", "count": 280, "targetRate": 60, "note": "主要ユースケースで技術・業務適合性を検証" },
    { "label": "稟議通過", "count": 118, "targetRate": 50, "note": "投資対効果と導入体制を顧客社内で承認" },
    { "label": "契約", "count": 96, "targetRate": 75, "note": "契約条件と開始日を合意" }
  ]
}