型番 raci-matrix
RACIマトリクス(責任分担・統制診断)
活動と役割の交点へR/A/C/Iを配置し、最終責任者Aの不在・重複と実行責任者Rの不在をデータから自動検出する。責任分担表、統制課題ledger、mobile activity ledgerが同じ活動・役割順・記号を共有し、診断を元の根拠へ戻して読める
raci · governance · responsibility · operating-model · data-driven · interactive
LIVE SAMPLE
プレビュー
補足の開閉・表示切替を試せます
こういうときに使う
- プロジェクト・運用・移管体制の責任境界を明確にする場合
- 承認者や実行担当の不在・重複を診断したい場合
- 選択肢の比較ではなく、活動ごとの役割分担を示す場合
- 数値スコアではなくRACI記号の整合性を確認する場合
ALTERNATIVES
関連する型
型番 comparison-table
比較表(製品・ベンダー選定)
製品・ベンダーを評価軸ごとに比較する表。○の数が最も多い列をデータから自動判定し、見出し・各評価の加点/非加点/参考・集計を一続きの推奨spineで示す。390pxでは評価軸ごとの全候補ledgerへ転置し、補足は表の下で明示的に展開できる
型番 process-flow
プロセスフロー(業務手順)
業務・システム導入の手順を一本の連続process laneとして描き、各工程の区間長を所要日数からデータ駆動で計算する。hand-off境界には開始からの累計日数を置き、hover・focusでは同じ工程の区間とレコードだけを穏やかに強調する。最も時間のかかる工程をlane上で自動強調し、補足は図の下で展開する
型番 heatmap
ヒートマップ(業務課題診断)
業務領域と評価軸の交点をスコアの濃淡で示し、最高スコアを行・列の座標照準として1件強調する。同じcell identityと座標locatorを保ったまま全体分布から重点課題上位5件へ切り替えられ、補足は図を覆わず下で確認できる
データスキーマ
- title
- string — スライドタイトル。最優先の責任欠陥を結論として書く
- subtitle
- string(任意)— 対象組織、時点、RACI定義など
- roles
- { id, label }[] — 役割・部門(2〜6件)
- activities
- { label, assignments, note? }[] — 3〜8件。assignmentsはrolesと同順・同数のR/A/C/I/-
サンプルコード
template.tsx525 行
import { useId } from "react";
import type {
RaciActivity,
RaciMark,
RaciMatrixProps,
} from "./schema";
export type RaciIssueCode =
| "missing-accountable"
| "multiple-accountable"
| "missing-responsible";
export type RaciIssue = {
activityIndex: number;
activityLabel: string;
code: RaciIssueCode;
};
export function activityAuditSummary(activity: {
assignments: readonly RaciMark[];
}) {
return {
accountable: activity.assignments.filter((mark) => mark === "A").length,
responsible: activity.assignments.filter((mark) => mark === "R").length,
};
}
export function auditActivities(
activities: readonly {
label: string;
assignments: readonly RaciMark[];
}[],
): RaciIssue[] {
return activities.flatMap((activity, activityIndex) => {
const issues: RaciIssue[] = [];
const { accountable, responsible } = activityAuditSummary(activity);
if (accountable === 0) {
issues.push({
activityIndex,
activityLabel: activity.label,
code: "missing-accountable",
});
}
if (accountable > 1) {
issues.push({
activityIndex,
activityLabel: activity.label,
code: "multiple-accountable",
});
}
if (responsible === 0) {
issues.push({
activityIndex,
activityLabel: activity.label,
code: "missing-responsible",
});
}
return issues;
});
}
export function issueEvidenceRoleIndexes(
activity: { assignments: readonly RaciMark[] },
issue: RaciIssue,
) {
if (issue.code !== "multiple-accountable") return [];
return activity.assignments.flatMap((mark, roleIndex) =>
mark === "A" ? [roleIndex] : [],
);
}
export function issueLabel(code: RaciIssueCode) {
return (
{
"missing-accountable": "A不在",
"multiple-accountable": "A重複",
"missing-responsible": "R不在",
} as const
)[code];
}
const issueDescription = (code: RaciIssueCode) =>
(
{
"missing-accountable": "最終説明責任を負う役割が定義されていない",
"multiple-accountable": "最終判断者が複数で、意思決定が競合する",
"missing-responsible": "実行責任を担う役割が定義されていない",
} as const
)[code];
const MARK_STYLES: Record<RaciMark, string> = {
R: "bg-tertiary text-on-primary",
A: "bg-primary text-on-primary",
C: "bg-neutral text-secondary",
I: "border border-outline bg-surface text-secondary",
"-": "text-outline",
};
const stagger = (index: number) =>
`calc(var(--stagger-step) * ${index})`;
function Mark({
mark,
activityIndex,
roleLabel,
evidence = false,
}: {
mark: RaciMark;
activityIndex: number;
roleLabel: string;
evidence?: boolean;
}) {
return (
<span
tabIndex={0}
data-raci-hotspot={activityIndex}
data-raci-evidence={evidence ? "true" : undefined}
aria-label={`${roleLabel}: ${mark === "-" ? "割当なし" : mark}`}
className={`inline-flex size-8 items-center justify-center rounded-full font-mono text-label outline-none transition-[color,background-color,box-shadow,opacity] focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 ${MARK_STYLES[mark]} ${evidence ? "ring-2 ring-tertiary ring-offset-2" : ""}`}
>
{mark === "-" ? "·" : mark}
</span>
);
}
function ActivityIssueLabels({ issues }: { issues: readonly RaciIssue[] }) {
if (issues.length === 0) return null;
return (
<span className="ml-2 inline-flex flex-wrap gap-1 align-middle">
{issues.map((issue) => (
<span
key={issue.code}
className="border-l-2 border-tertiary pl-1.5 font-mono text-label text-tertiary"
>
{issueLabel(issue.code)}
</span>
))}
</span>
);
}
function MobileActivityRecord({
activity,
activityIndex,
roles,
issues,
isDefaultPriority,
}: {
activity: RaciActivity;
activityIndex: number;
roles: RaciMatrixProps["roles"];
issues: readonly RaciIssue[];
isDefaultPriority: boolean;
}) {
return (
<article
data-raci-activity-record={activityIndex}
data-raci-surface={activityIndex}
data-raci-default-priority={isDefaultPriority ? "true" : undefined}
className={`animate-rise border-b px-1 py-4 transition-[background-color,box-shadow,opacity] first:border-t ${
isDefaultPriority
? "border-tertiary bg-gradient-to-r from-tertiary/10 to-surface"
: "border-outline"
}`}
style={{ animationDelay: stagger(activityIndex) }}
>
<div
tabIndex={0}
data-raci-hotspot={activityIndex}
className="flex items-start gap-2 outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<span className="pt-0.5 font-mono text-label text-secondary">
{String(activityIndex + 1).padStart(2, "0")}
</span>
<h2 className="min-w-0 text-body-sm">
{activity.label}
<ActivityIssueLabels issues={issues} />
</h2>
</div>
<div className="mt-3 grid grid-cols-2 gap-x-4 gap-y-2 pl-7">
{activity.assignments.map((mark, roleIndex) => (
<div
key={roles[roleIndex].id}
data-raci-role-index={roleIndex}
data-raci-mark={mark}
className="flex min-w-0 items-center justify-between gap-2 border-t border-outline/60 pt-2"
>
<span className="min-w-0 text-label text-secondary">
{roles[roleIndex].label}
</span>
<Mark
mark={mark}
activityIndex={activityIndex}
roleLabel={roles[roleIndex].label}
/>
</div>
))}
</div>
</article>
);
}
export default function RaciMatrix({
title,
subtitle,
roles,
activities,
instanceId,
}: RaciMatrixProps) {
const autoId = useId();
const uid =
(instanceId ?? autoId).replace(/[^a-zA-Z0-9_-]/g, "") || "raci";
const issues = auditActivities(activities);
const firstIssueActivity = issues[0]?.activityIndex;
const notes = activities.filter((activity) => activity.note);
const issuesByActivity = activities.map((_, activityIndex) =>
issues.filter((issue) => issue.activityIndex === activityIndex),
);
const interactionCss = activities
.map(
(_, activityIndex) => `
[data-raci-root="${uid}"]:has([data-raci-hotspot="${activityIndex}"]:is(:hover,:focus-visible)) [data-raci-surface="${activityIndex}"] {
background-color: color-mix(in srgb, var(--color-primary) 9%, var(--color-surface));
box-shadow: inset 3px 0 0 var(--color-primary);
color: var(--color-on-surface);
opacity: 1;
}
[data-raci-root="${uid}"]:has([data-raci-hotspot="${activityIndex}"]:is(:hover,:focus-visible)) [data-raci-default-priority="true"]:not([data-raci-surface="${activityIndex}"]) {
filter: saturate(.28) brightness(1.08);
}`,
)
.join("\n");
return (
<section
data-raci-root={uid}
className="group/raci mx-auto max-w-slide bg-surface p-12 font-sans text-on-surface"
>
<style>{interactionCss}</style>
<input
id={`${uid}-matrix`}
name={`${uid}-view`}
type="radio"
defaultChecked
className="raci-matrix-view sr-only"
/>
<input
id={`${uid}-issues`}
name={`${uid}-view`}
type="radio"
className="raci-issues-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}-matrix`}
className="cursor-pointer rounded-sm px-4 py-1.5 text-label text-secondary group-has-[.raci-matrix-view:checked]/raci:bg-primary group-has-[.raci-matrix-view:checked]/raci:text-on-primary"
>
責任分担
</label>
<label
htmlFor={`${uid}-issues`}
className="cursor-pointer rounded-sm px-4 py-1.5 text-label text-secondary group-has-[.raci-issues-view:checked]/raci:bg-primary group-has-[.raci-issues-view:checked]/raci:text-on-primary"
>
統制課題
</label>
</div>
</header>
<div
data-testid="raci-matrix"
className="block group-has-[.raci-issues-view:checked]/raci:hidden"
>
<div data-raci-view="matrix-desktop" className="hidden sm:block">
<table className="w-full table-fixed border-collapse">
<colgroup>
<col className="w-[29%]" />
{roles.map((role) => (
<col key={role.id} />
))}
</colgroup>
<thead>
<tr className="border-y border-outline">
<th className="px-3 py-3 text-left font-mono text-label text-secondary">
ACTIVITY
</th>
{roles.map((role) => (
<th
key={role.id}
className="px-1 py-3 text-center text-label text-secondary"
>
{role.label}
</th>
))}
</tr>
</thead>
<tbody>
{activities.map((activity, activityIndex) => {
const activityIssues = issuesByActivity[activityIndex];
const isDefaultPriority =
activityIndex === firstIssueActivity;
return (
<tr
key={activityIndex}
data-raci-activity-record={activityIndex}
data-raci-surface={activityIndex}
data-raci-default-priority={
isDefaultPriority ? "true" : undefined
}
data-has-issue={
activityIssues.length > 0 ? "true" : "false"
}
className={`animate-rise border-b transition-[background-color,box-shadow,opacity] ${
isDefaultPriority
? "border-tertiary bg-gradient-to-r from-tertiary/10 to-surface"
: "border-outline"
}`}
style={{ animationDelay: stagger(activityIndex) }}
>
<th scope="row" className="px-3 py-3 text-left">
<span
tabIndex={0}
data-raci-hotspot={activityIndex}
className="block text-body-sm outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
{activity.label}
<ActivityIssueLabels issues={activityIssues} />
</span>
</th>
{activity.assignments.map((mark, roleIndex) => (
<td
key={roles[roleIndex].id}
data-raci-role-index={roleIndex}
data-raci-mark={mark}
className="px-1 py-3 text-center"
>
<Mark
mark={mark}
activityIndex={activityIndex}
roleLabel={roles[roleIndex].label}
/>
</td>
))}
</tr>
);
})}
</tbody>
</table>
</div>
<div data-raci-view="matrix-mobile" className="sm:hidden">
{activities.map((activity, activityIndex) => (
<MobileActivityRecord
key={activityIndex}
activity={activity}
activityIndex={activityIndex}
roles={roles}
issues={issuesByActivity[activityIndex]}
isDefaultPriority={activityIndex === firstIssueActivity}
/>
))}
</div>
<p className="mt-3 text-right font-mono text-label text-secondary">
R 実行 · A 最終責任 · C 相談 · I 報告
</p>
</div>
<div
data-testid="raci-issues"
data-raci-view="issues"
className="hidden group-has-[.raci-issues-view:checked]/raci:block"
>
{issues.length === 0 ? (
<div className="border-y border-outline bg-surface-subtle p-8 text-center">
<p className="text-h3">
責任上の欠陥は検出されませんでした
</p>
<p className="mt-2 text-body-sm text-secondary">
全活動にAが1件、Rが1件以上あります
</p>
</div>
) : (
<div>
{issues.map((issue, issueIndex) => {
const activity = activities[issue.activityIndex];
const summary = activityAuditSummary(activity);
const evidenceIndexes = new Set(
issueEvidenceRoleIndexes(activity, issue),
);
const absence =
issue.code === "missing-accountable"
? { mark: "A", count: summary.accountable }
: issue.code === "missing-responsible"
? { mark: "R", count: summary.responsible }
: null;
return (
<article
key={`${issue.activityIndex}-${issue.code}`}
data-priority={issueIndex === 0 ? "true" : "false"}
data-raci-default-priority={
issueIndex === 0 ? "true" : undefined
}
data-raci-issue-record={issueIndex}
data-raci-issue-activity={issue.activityIndex}
data-raci-surface={issue.activityIndex}
className={`animate-rise border-b px-1 py-5 transition-[background-color,box-shadow,filter,opacity] first:border-t ${
issueIndex === 0
? "border-tertiary bg-gradient-to-r from-tertiary/15 via-surface to-surface"
: "border-outline bg-surface"
}`}
style={{ animationDelay: stagger(issueIndex) }}
>
<div className="grid gap-4 lg:grid-cols-[minmax(11rem,1.3fr)_minmax(20rem,3fr)_minmax(13rem,1.5fr)] lg:items-center">
<div
tabIndex={0}
data-raci-hotspot={issue.activityIndex}
className="outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<div className="flex items-center gap-2">
<span className="font-mono text-label text-secondary">
{String(issueIndex + 1).padStart(2, "0")}
</span>
<span className="border-l-2 border-tertiary pl-2 font-mono text-label text-tertiary">
{issueLabel(issue.code)}
</span>
</div>
<h2 className="mt-1 text-body-sm">
{issue.activityLabel}
</h2>
</div>
<div className="flex flex-wrap gap-x-4 gap-y-3">
{activity.assignments.map((mark, roleIndex) => {
const evidence = evidenceIndexes.has(roleIndex);
return (
<div
key={roles[roleIndex].id}
data-raci-role-index={roleIndex}
data-raci-mark={mark}
className={`flex min-w-28 flex-1 items-center justify-between gap-2 border-t pt-2 ${
evidence
? "border-tertiary"
: "border-outline"
}`}
>
<span className="text-label text-secondary">
{roles[roleIndex].label}
</span>
<Mark
mark={mark}
activityIndex={issue.activityIndex}
roleLabel={roles[roleIndex].label}
evidence={evidence}
/>
</div>
);
})}
</div>
<div className="border-l-2 border-outline pl-3">
<p className="text-body-sm text-secondary">
{issueDescription(issue.code)}
</p>
{absence && (
<span
data-raci-absence={absence.mark}
className="mt-2 inline-flex items-baseline gap-2 font-mono text-label text-tertiary"
>
<strong className="text-h3">
{absence.mark} {absence.count}
</strong>
<span>/ 必要数 1</span>
</span>
)}
{issue.code === "multiple-accountable" && (
<span className="mt-2 block font-mono text-label text-tertiary">
A {summary.accountable} / 必要数 1
</span>
)}
</div>
</div>
</article>
);
})}
</div>
)}
</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((activity, index) => (
<article
key={index}
data-note-item="true"
className="rounded-md bg-surface p-4"
>
<h3 className="text-label">{activity.label}</h3>
<p className="mt-2 text-body-sm">{activity.note}</p>
</article>
))}
</div>
</details>
)}
</section>
);
}
schema.ts47 行
import { z } from "zod";
export const raciMarkSchema = z.enum(["R", "A", "C", "I", "-"]);
export const raciRoleSchema = z
.object({
id: z.string().min(1).describe("役割の一意なID"),
label: z.string().min(1).describe("役割・部門名"),
})
.strict();
export const raciActivitySchema = z
.object({
label: z.string().min(1).describe("業務・意思決定・成果物名"),
assignments: z.array(raciMarkSchema).describe("rolesと同じ順・同じ件数のR/A/C/I/-"),
note: z.string().optional().describe("責任境界、判断条件、例外などの補足"),
})
.strict();
export const schema = z
.object({
title: z.string().describe("スライドタイトル。最優先の責任欠陥を結論として書く"),
subtitle: z.string().optional().describe("対象組織、時点、RACI定義など"),
roles: z.array(raciRoleSchema).min(2).max(6).describe("役割・部門(2〜6件)"),
activities: z.array(raciActivitySchema).min(3).max(8).describe("活動・判断(3〜8件)"),
instanceId: z.string().min(1).optional().describe("表示切替radioの識別子"),
})
.strict()
.superRefine((data, ctx) => {
const roleIds = new Set<string>();
data.roles.forEach((role, index) => {
if (roleIds.has(role.id)) {
ctx.addIssue({ code: "custom", message: "role.id は一意にすること", path: ["roles", index, "id"] });
}
roleIds.add(role.id);
});
data.activities.forEach((activity, index) => {
if (activity.assignments.length !== data.roles.length) {
ctx.addIssue({ code: "custom", message: `assignments は roles と同じ${data.roles.length}件にすること`, path: ["activities", index, "assignments"] });
}
});
});
export type RaciMark = z.infer<typeof raciMarkSchema>;
export type RaciRole = z.infer<typeof raciRoleSchema>;
export type RaciActivity = z.infer<typeof raciActivitySchema>;
export type RaciMatrixProps = z.infer<typeof schema>;
sample-data.json18 行
{
"title": "運用移管の責任設計 — 障害一次判断の最終責任者が未配置",
"subtitle": "新基幹システム運用モデル案(R:実行、A:最終責任、C:相談、I:報告)",
"roles": [
{ "id": "business", "label": "業務部門" },
{ "id": "service", "label": "サービス責任者" },
{ "id": "ops", "label": "運用チーム" },
{ "id": "vendor", "label": "保守ベンダー" },
{ "id": "security", "label": "セキュリティ" }
],
"activities": [
{ "label": "リリース承認", "assignments": ["C", "A", "R", "C", "I"], "note": "サービス責任者が業務影響と試験結果を踏まえて最終承認" },
{ "label": "障害一次判断", "assignments": ["I", "-", "R", "C", "C"], "note": "重大度判定の最終責任が未定義。15分以内の判断目標に影響" },
{ "label": "復旧作業", "assignments": ["I", "A", "R", "R", "C"], "note": "運用チームが指揮し、保守ベンダーが技術作業を実施" },
{ "label": "恒久対策承認", "assignments": ["C", "A", "R", "C", "C"], "note": "再発防止策と残存リスクをサービス責任者が承認" },
{ "label": "月次SLA報告", "assignments": ["I", "A", "R", "C", "I"], "note": "SLA、障害傾向、改善アクションを月次レビュー" }
]
}