Back to index

Customer Journey Map (Experience Gap)

Timeline
Generates documents
Customer Journey Map (Experience Gap)

Seen as a design

Take it with you

Copy and paste, or save a file and hand it over. For a machine with no network, one “Everything” file is enough.

Everything

customer-journey-map-everything.md

Everything in this panel, including the contents behind each URL, in one Markdown file

Prompt

1.3 KB
Build an HTML document with "Customer Journey Map (Experience Gap)". This layout runs, so there is no composition to rebuild — what it needs is content.

## What this layout does
Stages along the axis, satisfaction as a pulse line, touchpoints and pain tied to the same stage, and the largest gap to close read off them

## What you have to supply
- The stages and what the customer does
- The touchpoints at each
- Satisfaction and the impact of each pain

## The shape of the input
- `stages` — { id, label, action, touchpoints[], satisfaction, painImpact, note? }[] — 3〜7段階

schema.ts holds the machine-checkable validation. Ask rather than guess when an input is missing.

## When not to use it
- Analysing how a single population falls in count
- Making owners and step durations the subject

## How to build it
Put the input in a JSON file and run:

```bash
node scripts/render.mjs customer-journey-map <input.json> --theme ink -o out.html
```

The output is one self-contained file and fetches nothing at render time.

## The source
- https://design-vault.pages.dev/designs/customer-journey-map/source/template.tsx
- https://design-vault.pages.dev/designs/customer-journey-map/source/schema.ts
- https://design-vault.pages.dev/designs/customer-journey-map/source/sample-data.json
- https://design-vault.pages.dev/designs/customer-journey-map/sample.html

Files

Template, input schema, sample data and metadata. This document was generated from those four. The generated document

template.tsx

11.1 KB

/designs/customer-journey-map/source/template.tsx

Show contents
import { useId } from "react";
import type { CustomerJourneyMapProps, JourneyStage } from "./schema";

const round = (value: number) => Number(value.toFixed(2));

// 2 つの識別子を `-` で繋ぐと単射にならない —— ("a-b","c") と ("a","b-c") が
// 同じ鍵になり、React の key と data 属性の identity が別物どうしで一致する。
// 区切りに使う文字を各辺で逃がしてから繋ぐ。
const joinIds = (...parts: (string | number)[]) =>
  parts.map((p) => String(p).replace(/[_-]/g, (c) => `_${c.charCodeAt(0).toString(16)}_`)).join("-");

export function rankJourneyGaps(stages: JourneyStage[]) {
  return stages
    .map((stage, index) => ({
      ...stage,
      gap: stage.painImpact * (6 - stage.satisfaction),
      inputIndex: index,
    }))
    .sort((a, b) => b.gap - a.gap || a.inputIndex - b.inputIndex);
}

export function journeyPoints(stages: JourneyStage[]) {
  return stages.map((stage, index) => ({
    id: stage.id,
    xPercent: round(4 + (index / Math.max(stages.length - 1, 1)) * 92),
    yPercent: round(((5 - stage.satisfaction) / 4) * 100),
  }));
}

export function journeyGeometry(stages: JourneyStage[]) {
  const nodes = stages.map((stage, index) => ({
    ...stage,
    x: round(80 + (index / Math.max(stages.length - 1, 1)) * 840),
    y: round(70 + ((5 - stage.satisfaction) / 4) * 170),
    radius: round(9 + stage.painImpact * 3.5),
  }));
  const edges = nodes.slice(0, -1).map((node, index) => ({
    id: joinIds(node.id, nodes[index + 1].id),
    source: node,
    target: nodes[index + 1],
  }));
  return { nodes, edges };
}

export default function CustomerJourneyMap({
  title,
  subtitle,
  stages,
  instanceId,
}: CustomerJourneyMapProps) {
  // 既定を固定文字列にすると、id を指定せず 2 つ置いたときに radio の name と
  // SVG marker の id を共有してしまう。他の型と同じく useId() に寄せる。
  const autoId = useId();
  const uid = (instanceId ?? autoId).replace(
      // 逃がし文字(`_`)自身も逃がすので単射。捨てる形にすると `a:b` と `a/b` が
      // 同じ id になる。
      /[^a-zA-Z0-9-]/g,
      (c: string) => `_${c.charCodeAt(0).toString(16)}_`,
    ) || "customer-journey";
  const ranked = rankJourneyGaps(stages);
  const priority = ranked[0];
  const geometry = journeyGeometry(stages);
  const notes = stages.filter((stage) => stage.note);
  const markerId = `${uid}-journey-arrow`;

  return (
    <section
      className="asset group/journey overflow-hidden rounded-xl border border-outline bg-surface p-5 text-primary shadow-card sm:p-8"
      data-layout="customer-journey-map"
    >
      <header className="border-b border-outline pb-5">
        <p className="font-mono text-label text-tertiary">EXPERIENCE PULSE</p>
        <h1 className="mt-2 text-h1 text-balance">{title}</h1>
        {subtitle && <p className="mt-2 text-body text-secondary">{subtitle}</p>}
      </header>

      <div className="mt-5 flex flex-wrap justify-between gap-3">
        <div className="inline-flex rounded-full border border-outline bg-surface-subtle p-1 text-label">
          <label className="rounded-full px-3 py-1.5 has-[:checked]:bg-primary has-[:checked]:text-on-primary">
            <input className="journey-view-map sr-only" type="radio" name={`${uid}-view`} defaultChecked />体験
          </label>
          <label className="rounded-full px-3 py-1.5 has-[:checked]:bg-primary has-[:checked]:text-on-primary">
            <input className="journey-view-gap sr-only" type="radio" name={`${uid}-view`} />改善課題
          </label>
        </div>
        <span className="font-mono text-label text-secondary">Y=SATISFACTION · SIZE=PAIN IMPACT · X=STAGE</span>
      </div>

      <div
        className="mt-5 grid gap-2 border-l-4 border-tertiary bg-gradient-to-r from-tertiary/20 to-transparent px-4 py-3 sm:grid-cols-[auto_1fr_auto] sm:items-center"
        data-journey-default-priority="true"
      >
        <span className="font-mono text-label text-tertiary">DEEPEST DIP</span>
        <strong>{priority.label}</strong>
        <span className="font-mono text-h3">GAP {priority.gap}</span>
      </div>

      <div data-journey-view="map" className="mt-6 group-has-[.journey-view-gap:checked]/journey:hidden">
        <svg
          className="hidden h-80 w-full sm:block"
          viewBox="0 0 1000 350"
          role="img"
          aria-label="顧客体験の満足度推移と痛点影響度を一つの脈線で示すジャーニー"
          data-primary-geometry="customer-journey-map"
          data-geometry-channels="stage-position satisfaction-height pain-radius gap-emphasis"
        >
          <defs>
            <marker id={markerId} viewBox="0 0 10 10" refX="8" refY="5" markerWidth="9" markerHeight="9" markerUnits="userSpaceOnUse" orient="auto">
              <path d="M 0 0 L 10 5 L 0 10 z" className="fill-primary" />
            </marker>
          </defs>

          {[5, 3, 1].map((score) => {
            const y = 70 + ((5 - score) / 4) * 170;
            return (
              <g key={score}>
                <line x1="66" x2="934" y1={y} y2={y} className="stroke-outline" strokeDasharray={score === 3 ? "5 7" : undefined} vectorEffect="non-scaling-stroke" />
                <text x="54" y={y + 4} textAnchor="end" className="fill-secondary font-mono text-label">S{score}</text>
              </g>
            );
          })}

          {geometry.edges.map((edge) => {
            const dx = edge.target.x - edge.source.x;
            const dy = edge.target.y - edge.source.y;
            const distance = Math.hypot(dx, dy) || 1;
            const ux = dx / distance;
            const uy = dy / distance;
            const startX = edge.source.x + ux * (edge.source.radius + 5);
            const startY = edge.source.y + uy * (edge.source.radius + 5);
            const endX = edge.target.x - ux * (edge.target.radius + 10);
            const endY = edge.target.y - uy * (edge.target.radius + 10);
            const touchesPriority = edge.source.id === priority.id || edge.target.id === priority.id;
            return (
              <path
                key={edge.id}
                d={`M ${startX} ${startY} L ${endX} ${endY}`}
                fill="none"
                className={touchesPriority ? "stroke-tertiary" : "stroke-primary"}
                strokeWidth={touchesPriority ? 4 : 2.5}
                vectorEffect="non-scaling-stroke"
                markerEnd={`url(#${markerId})`}
                data-primary-edge={edge.id}
              />
            );
          })}

          {geometry.nodes.map((node, index) => {
            const isPriority = node.id === priority.id;
            const labelY = Math.min(290, node.y + node.radius + 22);
            return (
              <g
                key={node.id}
                tabIndex={0}
                data-journey-stage={node.id}
                data-primary-record={node.id}
                data-geometry-a={node.satisfaction}
                data-geometry-b={node.painImpact}
                data-primary-emphasis={isPriority ? "true" : undefined}
              >
                {isPriority && <circle cx={node.x} cy={node.y} r={node.radius + 13} className="fill-tertiary/15" />}
                <circle cx={node.x} cy={node.y} r={node.radius} className={isPriority ? "fill-tertiary" : "fill-primary"} />
                <circle cx={node.x} cy={node.y} r={Math.max(4, node.radius * 0.3)} className="fill-surface" />
                <title>{node.action}</title>
                <text x={node.x} y="310" textAnchor="middle" className="fill-primary font-mono text-label font-semibold">J{String(index + 1).padStart(2, "0")}</text>
              </g>
            );
          })}
        </svg>

        <div className="journey-desktop-ledger mt-4 hidden divide-y divide-outline border-y border-outline sm:block">
          {stages.map((stage, index) => {
            const gap = ranked.find((item) => item.id === stage.id)!.gap;
            return (
              <article key={stage.id} data-journey-desktop-stage={stage.id} data-journey-stage={stage.id} data-primary-record={stage.id} className="grid grid-cols-[3.5rem_minmax(0,1fr)] gap-3 py-3">
                <span className="font-mono text-label text-tertiary">J{String(index + 1).padStart(2, "0")}</span>
                <div className="min-w-0 break-words">
                  <strong data-primary-label={stage.id}>{stage.label}</strong>
                  <p className="text-label text-secondary">{stage.action}</p>
                  <p className="mt-1 text-label text-secondary">{stage.touchpoints.join(" / ")}</p>
                  <p className="mt-1 font-mono text-label">満足度 {stage.satisfaction} · 痛点影響 {stage.painImpact} · GAP {gap}</p>
                </div>
              </article>
            );
          })}
        </div>

        <div className="relative space-y-3 pl-7 sm:hidden">
          <span className="absolute bottom-2 left-2 top-2 w-px bg-outline" aria-hidden="true" />
          {stages.map((stage, index) => (
            <article key={stage.id} data-journey-stage={stage.id} data-journey-mobile-stage={stage.id} className="relative grid grid-cols-[3rem_1fr_auto] gap-3 border-b border-outline py-3 pl-3">
              <span className={`absolute -left-6 top-4 h-3 w-3 rounded-full ${stage.id === priority.id ? "bg-tertiary ring-4 ring-tertiary/20" : "bg-primary"}`} aria-hidden="true" />
              <span className="font-mono text-label text-tertiary">J{index + 1}</span>
              <div className="min-w-0 break-words"><strong>{stage.label}</strong><p className="text-label text-secondary">{stage.action}</p><p className="mt-1 text-label text-secondary">{stage.touchpoints.join(" / ")}</p></div>
              <span className="font-mono text-label">S{stage.satisfaction} · I{stage.painImpact} · G{ranked.find((item) => item.id === stage.id)?.gap}</span>
            </article>
          ))}
        </div>
      </div>

      <div data-journey-view="gap-ledger" className="mt-6 hidden group-has-[.journey-view-gap:checked]/journey:block">
        <div className="divide-y divide-outline border-y border-outline">
          {ranked.map((stage, index) => (
            <article key={stage.id} data-journey-stage={stage.id} className={`grid grid-cols-[3rem_1fr_auto] items-center gap-3 py-3 ${index === 0 ? "border-l-4 border-tertiary pl-3" : "pl-4"}`}>
              <span className="font-mono text-label text-secondary">{String(index + 1).padStart(2, "0")}</span>
              <div><strong>{stage.label}</strong><p className="text-label text-secondary">影響{stage.painImpact} × 満足不足{6 - stage.satisfaction} · {stage.touchpoints.join(" / ")}</p></div>
              <span className="font-mono text-h3">{stage.gap}</span>
            </article>
          ))}
        </div>
      </div>

      {notes.length > 0 && (
        <details data-inline-disclosure className="mt-6 rounded-lg border border-outline bg-surface-subtle p-4">
          <summary className="cursor-pointer font-semibold">補足を確認({notes.length}件)</summary>
          {notes.map((stage) => <p key={stage.id} data-note-item="true" className="mt-3 text-body text-secondary"><strong className="text-primary">{stage.label}:</strong> {stage.note}</p>)}
        </details>
      )}
    </section>
  );
}

schema.ts

1.1 KB

/designs/customer-journey-map/source/schema.ts

Show contents
import { z } from "zod";
export const journeyStageSchema = z.object({ id: z.string().min(1), label: z.string().min(1), action: z.string().min(1), touchpoints: z.array(z.string().min(1)).min(1).max(4), satisfaction: z.number().int().min(1).max(5), painImpact: z.number().int().min(1).max(5), note: z.string().optional() }).strict();
export const schema = z.object({ title: z.string().min(1), subtitle: z.string().optional(), stages: z.array(journeyStageSchema).min(3).max(7), instanceId: z.string().optional() }).strict().superRefine((value, ctx) => {
  // id は identity として使う(React の key、data 属性、対応強調の突き合わせ)。
  // 重複すると別物どうしが同じ要素を指し、操作が意図しない相手に効く。
  {
    const ids = value.stages.map((item) => item.id);
    if (new Set(ids).size !== ids.length) {
      ctx.addIssue({ code: "custom", path: ["stages"], message: "stages ids must be unique" });
    }
  }
});
export type JourneyStage = z.infer<typeof journeyStageSchema>;
export type CustomerJourneyMapProps = z.infer<typeof schema>;

meta.yaml

1.8 KB

/designs/customer-journey-map/source/meta.yaml

Show contents
id: customer-journey-map
name:
  ja: "カスタマージャーニーマップ(体験ギャップ)"
  en: "Customer Journey Map (Experience Gap)"
purpose:
  ja: "顧客段階を横軸、満足度を脈線、接点とpain impactを同じstageへ結び、最大改善ギャップを導出する"
  en: "Stages along the axis, satisfaction as a pulse line, touchpoints and pain tied to the same stage, and the largest gap to close read off them"
# A5 の裁定で draft として移設し、2026-09-04 に型ごとの承認を取って公開した。
status: published
# 索引カードの図版。pnpm layout:render が原本から描く。
still: ./still.png
category: experience
catalogOrder: 170
jobs:
  - trace-flow
  - prioritize-diagnose
useCases:
  - case-study
  - process
layoutKinds:
  - timeline
tags: [customer-journey, experience, touchpoint, satisfaction, pain-point]
whenToUse:
  - ja: "顧客体験の山谷と改善優先点を一続きで説明したい場合"
    en: "Explaining the peaks and troughs of an experience and what to fix first"
  - ja: "接点横断の体験設計を合意したい場合"
    en: "Agreeing an experience design across touchpoints"
avoidWhen:
  - ja: "同一母集団の件数減少を分析したい場合"
    en: "Analysing how a single population falls in count"
  - ja: "部門担当と工程時間を主題にしたい場合"
    en: "Making owners and step durations the subject"
requiredInputs:
  - ja: "顧客段階と行動"
    en: "The stages and what the customer does"
  - ja: "各段階の接点"
    en: "The touchpoints at each"
  - ja: "満足度とpain impact"
    en: "Satisfaction and the impact of each pain"
relatedLayouts: [conversion-funnel, timeline, heatmap]
dataSchema:
  stages: "{ id, label, action, touchpoints[], satisfaction, painImpact, note? }[] — 3〜7段階"
sources: []

sample-data.json

1.2 KB

/designs/customer-journey-map/source/sample-data.json

Show contents
{
  "title": "申込体験が最大の離脱要因 — 入力負荷と確認待ちを短縮する",
  "subtitle": "法人顧客オンボーディング仮説(満足度・影響度は5段階)",
  "stages": [
    {"id":"discover","label":"情報収集","action":"導入事例と料金を比較する","touchpoints":["Web","資料"],"satisfaction":4,"painImpact":2},
    {"id":"consult","label":"相談","action":"要件と適用範囲を確認する","touchpoints":["営業","オンライン会議"],"satisfaction":4,"painImpact":3},
    {"id":"apply","label":"申込","action":"企業情報と利用条件を入力する","touchpoints":["申込フォーム","メール"],"satisfaction":1,"painImpact":5,"note":"同じ企業情報を複数画面で再入力し、審査状況も確認できない想定"},
    {"id":"setup","label":"初期設定","action":"管理者と権限を登録する","touchpoints":["管理画面","導入支援"],"satisfaction":3,"painImpact":4},
    {"id":"adopt","label":"定着","action":"利用状況を確認し改善する","touchpoints":["ダッシュボード","CS"],"satisfaction":4,"painImpact":3}
  ],
  "instanceId": "b2b-onboarding-journey"
}