問い合わせ
1,240件 ・ 到達率 100%
Web、紹介、イベント経由の新規問い合わせ
型番 conversion-funnel
同一母集団が段階的に絞られる様子を件数比例の連続漏斗として描く。件名は各断面の上、件数値と主要指標は断面線上へ置いて水平ガイドで結び、hover・focusでは同じ断面レコードだけを穏やかに強調する。最大離脱後の断面を1件常設強調し、同じ漏斗上で到達情報から離脱数・離脱率・順位へ切り替えられる
funnel · conversion · drop-off · pipeline · data-driven · interactive · progressive-filtering · semantic-geometry · svg
LIVE SAMPLE
補足の開閉・表示切替を試せます
ALTERNATIVES
型番 waterfall
起点から終点までの増減要因を累計配置の棒(ブリッジ)で示す。PowerPointのネイティブ滝グラフが自動化する「累計配置」だけでは差別化にならないため、同じデータを寄与度順(|増減|降順)の横棒ランキングに切り替えられる点が核。数値は常時表示し、補足は図の下で展開する
型番 sankey-flow
複数の起点から複数の終点へ流れる量を、合計量比例の端点railと積層曲線として自動描画する。流路を辿ると対応する起点・終点・合計値が連動し、同じデータを流量図から流路ランキングへ切り替えられる
型番 kpi-cards
代表KPIの現在値・前期比・目標達成・推移を一つの主分析面で示し、残りを比較ledgerへ整理する。実データ系列から SVG スパークラインを自動生成し、目標を破線で重ねて達成/未達を自動判定する。解約率のような「下がるほど良い」指標も betterWhen で正しく着色される
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>
);
}
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>;
{
"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": "契約条件と開始日を合意" }
]
}