BizLayout

型番 process-flow

プロセスフロー(業務手順)

業務・システム導入の手順を一本の連続process laneとして描き、各工程の区間長を所要日数からデータ駆動で計算する。hand-off境界には開始からの累計日数を置き、hover・focusでは同じ工程の区間とレコードだけを穏やかに強調する。最も時間のかかる工程をlane上で自動強調し、補足は図の下で展開する

process · workflow · sequence · data-driven · interactive · semantic-geometry · linked-emphasis

LIVE SAMPLE

プレビュー

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

新規顧客オンボーディングフロー

連続laneの区間長は所要日数に比例。最も時間のかかる工程を自動で強調(2026年度 CS部門・サンプル)

01

STEP 01

契約締結

2日 ・ 累計2

02

STEP 02

初期設定

3日 ・ 累計5

03

STEP 03

データ移行

7日 ・ 累計12最大

04

STEP 04

利用トレーニング

4日 ・ 累計16

05

STEP 05

本稼働開始

1日 ・ 累計17

最も所要日数の長い工程「データ移行」を連続lane上で自動強調

補足を確認5

契約締結

2 ・ 担当 営業

電子契約で完結。締結後すぐにCS部門へ引き継ぐ

初期設定

3 ・ 担当 CS

アカウント発行・権限設定・SSO連携の初期設定を実施

データ移行

7 ・ 担当 CS / 情報システム

既存システムからのデータ移行が最も時間を要する。移行元の形式次第で追加工数が発生しやすい

利用トレーニング

4 ・ 担当 CS

管理者向け・利用者向けの2回に分けて実施

本稼働開始

1 ・ 担当 CS

初月は週次で利用状況をフォローアップ

こういうときに使う

  • オンボーディング・導入手順など業務フローの可視化
  • 承認・発注ワークフローの整理
  • どの工程がボトルネックかを一目で示したい場合

ALTERNATIVES

条件を変えて探す →

データスキーマ

title
string — スライドタイトル。結論を書くメッセージラインを推奨
subtitle
string(任意)— 補足・出典など
steps
{ label, weight, owner?, note? } の配列。weight(所要日数等)の比率で連続laneの区間長を自動計算し、最大の工程をlane上で強調表示。owner/note は図の下の補足欄で展開

サンプルコード

template.tsx371 行
import { useId } from "react";
import type { ProcessFlowProps } from "./schema";

/** weight に比例した区間幅。0以下は可視性を保つ最小値へクランプする。 */
export const stepWidths = (steps: { weight: number }[]): number[] =>
  steps.map((step) => Math.max(0.1, step.weight));

/** 最も weight が大きい工程のindex。同値は先頭を採用する。 */
export const bottleneckIndex = (steps: { weight: number }[]): number => {
  let maxIndex = 0;
  for (let index = 1; index < steps.length; index++) {
    if (steps[index].weight > steps[maxIndex].weight) maxIndex = index;
  }
  return maxIndex;
};

/**
 * 各工程の中央位置(%)。クランプ済み区間幅の累積から求める。
 * ラベル、connector、操作対象を同じ工程断面へ揃えるために使う。
 */
export const stepCenters = (steps: { weight: number }[]): number[] => {
  const widths = stepWidths(steps);
  const total = widths.reduce((sum, width) => sum + width, 0);
  let elapsed = 0;
  return widths.map((width) => {
    const start = elapsed;
    elapsed += width;
    return ((start + elapsed) / 2 / total) * 100;
  });
};

/**
 * 各hand-off時点の生weight累計。クランプ値は描画だけに使い、
 * 表示する日数へ混ぜない。
 */
export const cumulativeDays = (steps: { weight: number }[]): number[] => {
  const result: number[] = [];
  let elapsed = 0;
  for (let index = 0; index < steps.length - 1; index++) {
    elapsed += steps[index].weight;
    result.push(elapsed);
  }
  return result;
};

export type ProcessLaneSegment = {
  originalIndex: number;
  startPct: number;
  endPct: number;
  widthPct: number;
  centerPct: number;
  cumulativeDays: number;
};

/**
 * 独立カードの幅ではなく、一本のprocess lane上の連続区間へ工程を写す。
 * endPctは必ず次工程のstartPctになり、最終工程は100%で終わる。
 */
export function processLaneGeometry(
  steps: readonly { weight: number }[],
): ProcessLaneSegment[] {
  const widths = stepWidths([...steps]);
  const totalWidth = widths.reduce((sum, width) => sum + width, 0);
  let widthElapsed = 0;
  let daysElapsed = 0;

  return widths.map((width, originalIndex) => {
    const startPct = (widthElapsed / totalWidth) * 100;
    widthElapsed += width;
    daysElapsed += steps[originalIndex].weight;
    const endPct = (widthElapsed / totalWidth) * 100;
    return {
      originalIndex,
      startPct,
      endPct,
      widthPct: endPct - startPct,
      centerPct: (startPct + endPct) / 2,
      cumulativeDays: daysElapsed,
    };
  });
}

const HIGHLIGHT_GRADIENT = "bg-linear-to-br from-tertiary to-accent-violet";
const stagger = (index: number) =>
  `calc(var(--stagger-step) * ${index})`;

const formatDays = (value: number) =>
  new Intl.NumberFormat("ja-JP", { maximumFractionDigits: 1 }).format(value);

export default function ProcessFlow({
  title,
  subtitle,
  steps,
}: ProcessFlowProps) {
  const autoId = useId();
  const uid = autoId.replace(/[^a-zA-Z0-9_-]/g, "") || "process";
  const geometry = processLaneGeometry(steps);
  const bottleneck = steps.length > 0 ? bottleneckIndex(steps) : -1;
  const totalDays = steps.reduce((sum, step) => sum + step.weight, 0);
  const notes = steps.filter((step) => step.note);

  const interactionStyles = `
[data-process-instance="${uid}"] [data-process-segment] {
  transition: background-color 140ms ease-out, color 140ms ease-out;
}
[data-process-instance="${uid}"] [data-process-record],
[data-process-instance="${uid}"] [data-process-connector],
[data-process-instance="${uid}"] [data-process-boundary] {
  transition: color 140ms ease-out, border-color 140ms ease-out, opacity 140ms ease-out;
}
[data-process-instance="${uid}"] [data-process-hotspot]:focus-visible {
  outline: none;
}
${geometry
  .map(
    (segment) => `
[data-process-instance="${uid}"]:has([data-process-hotspot~="${segment.originalIndex}"]:is(:hover, :focus)) [data-process-segment][data-step-index="${segment.originalIndex}"]:not([data-priority]) {
  background-color: var(--color-tertiary-container);
  color: var(--color-on-tertiary-container);
}
[data-process-instance="${uid}"]:has([data-process-hotspot~="${segment.originalIndex}"]:is(:hover, :focus)) [data-process-record][data-step-index="${segment.originalIndex}"] {
  color: var(--color-tertiary);
}
[data-process-instance="${uid}"]:has([data-process-hotspot~="${segment.originalIndex}"]:is(:hover, :focus)) [data-process-connector][data-step-index="${segment.originalIndex}"],
[data-process-instance="${uid}"]:has([data-process-hotspot~="${segment.originalIndex}"]:is(:hover, :focus)) [data-process-boundary][data-step-index="${segment.originalIndex}"] {
  border-color: var(--color-tertiary);
  opacity: 1;
}`,
  )
  .join("")}
`;

  return (
    <section
      data-process-instance={uid}
      className="mx-auto max-w-slide bg-surface p-6 font-sans text-on-surface sm:p-12"
    >
      <style>{interactionStyles}</style>
      <header className="mb-8">
        <h1 className="text-h1">{title}</h1>
        {subtitle && (
          <p className="mt-2 text-body-sm text-secondary">{subtitle}</p>
        )}
      </header>

      {steps.length === 0 ? (
        <p className="text-body-sm text-secondary">工程が未設定</p>
      ) : (
        <>
          {/* desktop: 所要日数を横軸にした一本の連続lane */}
          <div
            data-process-lane="desktop"
            className="relative hidden pt-16 pb-16 sm:block"
          >
            <div className="mb-3 flex items-center justify-between font-mono text-label text-secondary">
              <span>START 0日</span>
              <span>TOTAL {formatDays(totalDays)}日</span>
            </div>

            <div className="relative flex h-20 shadow-card">
              {steps.map((step, index) => {
                const segment = geometry[index];
                const isBottleneck = index === bottleneck;
                const labelAbove = index % 2 === 0;
                const edgeLabel =
                  index === 0
                    ? "left-0 text-left"
                    : index === steps.length - 1
                      ? "right-0 text-right"
                      : "left-1/2 -translate-x-1/2 text-center";
                const round =
                  index === 0
                    ? "rounded-l-lg border-l"
                    : index === steps.length - 1
                      ? "rounded-r-lg"
                      : "";

                return (
                  <div
                    key={index}
                    data-process-segment="true"
                    data-process-hotspot={index}
                    data-step-index={index}
                    data-priority={isBottleneck ? "true" : undefined}
                    tabIndex={0}
                    role="group"
                    aria-label={`STEP ${index + 1} ${step.label}、${formatDays(step.weight)}日、累計${formatDays(segment.cumulativeDays)}日${isBottleneck ? "、最も所要日数が長い工程" : ""}`}
                    className={`relative min-w-0 animate-rise cursor-default border-y border-r border-outline ${
                      isBottleneck
                        ? `${HIGHLIGHT_GRADIENT} text-on-tertiary`
                        : "bg-neutral text-on-neutral"
                    } ${round}`}
                    style={{
                      flexGrow: segment.widthPct,
                      flexBasis: 0,
                      animationDelay: stagger(index),
                    }}
                  >
                    <div className="pointer-events-none absolute inset-0 flex items-center justify-center overflow-hidden px-1">
                      <span className="font-mono text-label opacity-70">
                        {String(index + 1).padStart(2, "0")}
                      </span>
                    </div>

                    <span
                      data-process-connector="true"
                      data-step-index={index}
                      aria-hidden="true"
                      className={`absolute left-1/2 h-8 border-l border-dashed border-outline opacity-70 ${
                        labelAbove ? "bottom-full" : "top-full"
                      }`}
                    />
                    <div
                      data-process-record="true"
                      data-step-index={index}
                      className={`absolute w-36 text-on-surface ${edgeLabel} ${
                        labelAbove ? "bottom-full mb-9" : "top-full mt-9"
                      }`}
                    >
                      <p className="font-mono text-label text-secondary">
                        STEP {String(index + 1).padStart(2, "0")}
                      </p>
                      <p className="mt-1 text-body-sm">{step.label}</p>
                      <p className="mt-1 font-mono text-label">
                        {formatDays(step.weight)}日
                        {isBottleneck && (
                          <span className="ml-2 text-tertiary">最大</span>
                        )}
                      </p>
                    </div>

                    {index < steps.length - 1 && (
                      <span
                        data-process-boundary="true"
                        data-step-index={index}
                        data-cumulative-days={segment.cumulativeDays}
                        aria-label={`累計${formatDays(segment.cumulativeDays)}日`}
                        className="absolute inset-y-0 right-0 border-r border-surface/80 opacity-80"
                      >
                        <span className="absolute right-2 bottom-1 whitespace-nowrap font-mono text-label opacity-75">
                          {formatDays(segment.cumulativeDays)}日
                        </span>
                      </span>
                    )}
                  </div>
                );
              })}
            </div>
            <p className="mt-3 text-right text-label text-secondary">
              区間長 = 所要日数 ・ 境界値 = 開始からの累計日数
            </p>
          </div>

          {/* mobile: 同じ連続区間を縦軸へ回し、横overflowを避ける */}
          <div data-process-lane="mobile" className="relative h-128 sm:hidden">
            <div className="absolute inset-y-0 left-0 w-14 shadow-card">
              {steps.map((step, index) => {
                const segment = geometry[index];
                const isBottleneck = index === bottleneck;
                const round =
                  index === 0
                    ? "rounded-t-lg border-t"
                    : index === steps.length - 1
                      ? "rounded-b-lg"
                      : "";
                return (
                  <div
                    key={index}
                    data-process-segment="true"
                    data-process-hotspot={index}
                    data-step-index={index}
                    data-priority={isBottleneck ? "true" : undefined}
                    tabIndex={0}
                    role="group"
                    aria-label={`STEP ${index + 1} ${step.label}、${formatDays(step.weight)}日、累計${formatDays(segment.cumulativeDays)}日${isBottleneck ? "、最も所要日数が長い工程" : ""}`}
                    className={`absolute inset-x-0 animate-rise cursor-default border-x border-b border-outline ${
                      isBottleneck
                        ? `${HIGHLIGHT_GRADIENT} text-on-tertiary`
                        : "bg-neutral text-on-neutral"
                    } ${round}`}
                    style={{
                      top: `${segment.startPct}%`,
                      height: `${segment.widthPct}%`,
                      animationDelay: stagger(index),
                    }}
                  >
                    <span className="pointer-events-none absolute inset-0 flex items-center justify-center overflow-hidden font-mono text-label opacity-70">
                      {String(index + 1).padStart(2, "0")}
                    </span>
                    <span
                      data-process-connector="true"
                      data-step-index={index}
                      aria-hidden="true"
                      className="absolute top-1/2 left-full w-4 border-t border-dashed border-outline opacity-70"
                    />
                    <div
                      data-process-record="true"
                      data-step-index={index}
                      className="absolute top-1/2 left-full ml-5 w-64 -translate-y-1/2 text-on-surface"
                    >
                      <p className="font-mono text-label text-secondary">
                        STEP {String(index + 1).padStart(2, "0")}
                      </p>
                      <p className="text-body-sm">{step.label}</p>
                      <p className="font-mono text-label">
                        {formatDays(step.weight)}日 ・ 累計
                        {formatDays(segment.cumulativeDays)}日
                        {isBottleneck && (
                          <span className="ml-2 text-tertiary">最大</span>
                        )}
                      </p>
                    </div>

                    {index < steps.length - 1 && (
                      <span
                        data-process-boundary="true"
                        data-step-index={index}
                        data-cumulative-days={segment.cumulativeDays}
                        aria-label={`累計${formatDays(segment.cumulativeDays)}日`}
                        className="absolute inset-x-0 bottom-0 border-b border-surface/80 opacity-80"
                      />
                    )}
                  </div>
                );
              })}
            </div>
          </div>

          <p className="mt-4 text-label text-secondary">
            最も所要日数の長い工程「{steps[bottleneck].label}
            」を連続lane上で自動強調
          </p>
        </>
      )}

      {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((step, index) => (
              <article
                key={index}
                data-note-item="true"
                className="rounded-md bg-surface p-4"
              >
                <h3 className="text-label">{step.label}</h3>
                <p className="mt-1 font-mono text-label text-secondary">
                  {formatDays(step.weight)}日
                  {step.owner ? ` ・ 担当 ${step.owner}` : ""}
                </p>
                <p className="mt-2 text-body-sm">{step.note}</p>
              </article>
            ))}
          </div>
        </details>
      )}
    </section>
  );
}
schema.ts23 行
import { z } from "zod";

export const processStepSchema = z
  .object({
    label: z.string(),
    weight: z
      .number()
      .describe("所要日数などの相対量。連続process laneの区間長に使う"),
    owner: z.string().optional(),
    note: z.string().optional().describe("図の下の補足欄で明示的に展開される詳細"),
  })
  .strict();

export const schema = z
  .object({
    title: z.string().describe("スライドタイトル。結論を書くメッセージラインを推奨"),
    subtitle: z.string().optional().describe("補足・出典など"),
    steps: z.array(processStepSchema),
  })
  .strict();

export type ProcessStep = z.infer<typeof processStepSchema>;
export type ProcessFlowProps = z.infer<typeof schema>;
sample-data.json36 行
{
  "title": "新規顧客オンボーディングフロー",
  "subtitle": "連続laneの区間長は所要日数に比例。最も時間のかかる工程を自動で強調(2026年度 CS部門・サンプル)",
  "steps": [
    {
      "label": "契約締結",
      "weight": 2,
      "owner": "営業",
      "note": "電子契約で完結。締結後すぐにCS部門へ引き継ぐ"
    },
    {
      "label": "初期設定",
      "weight": 3,
      "owner": "CS",
      "note": "アカウント発行・権限設定・SSO連携の初期設定を実施"
    },
    {
      "label": "データ移行",
      "weight": 7,
      "owner": "CS / 情報システム",
      "note": "既存システムからのデータ移行が最も時間を要する。移行元の形式次第で追加工数が発生しやすい"
    },
    {
      "label": "利用トレーニング",
      "weight": 4,
      "owner": "CS",
      "note": "管理者向け・利用者向けの2回に分けて実施"
    },
    {
      "label": "本稼働開始",
      "weight": 1,
      "owner": "CS",
      "note": "初月は週次で利用状況をフォローアップ"
    }
  ]
}