Doc Diagram
A Mermaid diagram with source and live preview, drawn in the veni-os whiteboard palette (light and dark). A picker inserts a starter for each of 31 types - flowchart, swimlane, sequence, class, state, ER, Gantt, git graph, pie, mindmap, kanban, timeline, journey, quadrant, XY, requirement, Sankey, architecture, C4, block, packet, radar, treemap and more - and invalid source shows an inline error. In the editor, /diagram, /mermaid, ```mermaid plus a space, or pasting Mermaid source makes one.
npx shadcn@latest add @velaui/doc-diagrampnpm dlx velaui add @velaui/doc-diagramyarn dlx shadcn@latest add @velaui/doc-diagrambunx --bun shadcn@latest add @velaui/doc-diagramveni add doc-diagramInstalls the copy of vela-ui that veni-builder serves to Venizia apps; veni-builder serves the Radix UI copy only, so this tab exists for that base alone.
npx shadcn@latest add @velaui/doc-diagrampnpm dlx velaui add @velaui/doc-diagramyarn dlx shadcn@latest add @velaui/doc-diagrambunx --bun shadcn@latest add @velaui/doc-diagramExamples
- Default
- Sequence
- Charts
- Invalid
Usage
Props
| Prop | Type | Default |
|---|---|---|
className | string | — |
onChange | (value: string) => void | — |
readOnly | boolean | — |
valuerequired | string | — |
Takes these and every prop of DocDiagramProps.
Dependencies
- npm
mermaid@12.2.0- Installs with it
- Select
Source
components/ui/doc-diagram.tsx
/* eslint-disable react-refresh/only-export-components */
import * as React from "react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { cn } from "@/lib/utils";
/** A starter for each Mermaid diagram type this block draws. The editor's `/diagram` starts from `Flowchart`. */
export const DOC_DIAGRAM_TEMPLATES: Record<string, string> = {
Flowchart: "flowchart TD\n A[Start] --> B{Ready?}\n B -- Yes --> C[Ship]\n B -- No --> D[Fix]\n D --> B",
Swimlane:
"swimlane-beta\n subgraph Customer\n A[Place order]\n end\n subgraph Shop\n B[Pack]\n C[Ship]\n end\n A --> B --> C",
Sequence: "sequenceDiagram\n participant U as User\n participant A as API\n U->>A: Request\n A-->>U: Response",
Class: "classDiagram\n class Doc {\n +String title\n +save()\n }\n Doc <|-- Page",
State: "stateDiagram-v2\n [*] --> Draft\n Draft --> Review\n Review --> Published\n Published --> [*]",
"Entity relationship":
"erDiagram\n CUSTOMER ||--o{ ORDER : places\n ORDER ||--|{ LINE_ITEM : contains\n CUSTOMER {\n string name\n string email\n }",
Gantt:
"gantt\n title Launch\n dateFormat YYYY-MM-DD\n section Build\n Schema freeze :a1, 2026-10-01, 5d\n Dry migration :after a1, 4d\n section Ship\n Cut over :2026-10-12, 2d",
"Git graph": "gitGraph\n commit\n branch feature\n checkout feature\n commit\n checkout main\n merge feature",
Pie: 'pie showData title Traffic\n "Web" : 55\n "Mobile" : 35\n "API" : 10',
Mindmap: "mindmap\n root((Launch))\n Product\n Onboarding\n Billing\n Marketing\n Pricing page",
Kanban: "kanban\n Todo\n [Write docs]\n Doing\n [Review PR]\n Done\n [Ship]",
Timeline: "timeline\n title Roadmap\n Q3 : Beta\n Q4 : Launch : Scale",
"User journey": "journey\n title Checkout\n section Cart\n Add item: 5: Customer\n Pay: 3: Customer",
"Quadrant chart":
"quadrantChart\n title Reach and engagement\n x-axis Low reach --> High reach\n y-axis Low engagement --> High engagement\n quadrant-1 Expand\n quadrant-2 Promote\n quadrant-3 Re-evaluate\n quadrant-4 Improve\n Campaign A: [0.3, 0.6]\n Campaign B: [0.7, 0.8]",
"XY chart":
'xychart-beta\n title "Sales"\n x-axis [jan, feb, mar]\n y-axis "Revenue" 0 --> 100\n bar [30, 55, 80]\n line [30, 55, 80]',
Requirement:
"requirementDiagram\n requirement login {\n id: 1\n text: Users can log in\n risk: low\n verifymethod: test\n }\n element auth {\n type: service\n }\n auth - satisfies -> login",
Sankey: "sankey-beta\n\nVisits,Signup,50\nVisits,Bounce,50\nSignup,Paid,20",
Architecture:
"architecture-beta\n group api(cloud)[API]\n service db(database)[Database] in api\n service server(server)[Server] in api\n db:L -- R:server",
C4: 'C4Context\n title System context\n Person(user, "User")\n System(app, "App")\n Rel(user, app, "Uses")',
Block: "block-beta\n columns 3\n A B C\n D:2 E",
Agentflow: "agentflow-beta\n Start --> Agent",
Railroad: 'railroad-ebnf-beta\n digit = "0" | "1" ;\n number = digit , { digit } ;',
"Tree view": "treeView-beta\n src\n index.ts\n lib",
Cynefin: 'cynefin-beta\n complex\n "Launch"',
"Wardley map": "wardley-beta\n title Tea shop\n anchor Customer [0.95, 0.63]\n component Cup [0.8, 0.5]",
"Event modeling": "eventmodeling\n tf 01 cmd PlaceOrder",
Venn: 'venn-beta\n set A["Design"]\n set B["Code"]\n union A,B["Product"]',
Ishikawa: "ishikawa-beta\n Late launch\n People\n Hiring\n Process\n Review",
Packet: 'packet-beta\n 0-15: "Source port"\n 16-31: "Destination port"',
Radar: 'radar-beta\n axis a["Speed"], b["Quality"], c["Cost"]\n curve x["Team"]{80, 70, 60}',
Treemap: 'treemap-beta\n"Root"\n "A": 30\n "B": 20',
Info: "info",
};
const KEYWORDS =
"graph|flowchart|swimlane-beta|sequenceDiagram|classDiagram|stateDiagram(?:-v2)?|erDiagram|gantt|gitGraph|pie|mindmap|kanban|timeline|journey|quadrantChart|xychart(?:-beta)?|requirementDiagram|sankey(?:-beta)?|architecture-beta|C4(?:Context|Container|Component|Dynamic|Deployment)|block(?:-beta)?|agentflow-beta|railroad(?:-ebnf|-abnf|-peg)?-beta|treeView-beta|cynefin-beta|wardley-beta|eventmodeling|usecase-beta|venn-beta|ishikawa(?:-beta)?|packet(?:-beta)?|radar-beta|treemap(?:-beta)?|info";
const FIRST_LINE = new RegExp(`^(?:${KEYWORDS})(?:\\s+(?:TB|TD|BT|RL|LR|showData|title\\b.*))*$`);
/**
* Whether text is Mermaid source: several lines whose first (after comments and front matter) is a diagram keyword,
* as `flowchart TD`. One line is not enough, because a lone `info` or `timeline` is as likely to be a word.
*/
export function isMermaidSource(text: string) {
const lines = text
.replace(/^\s*---[\s\S]*?\n---\s*\n/, "")
.trim()
.split("\n")
.filter(l => !l.trim().startsWith("%%"));
return lines.length > 1 && FIRST_LINE.test(lines[0].trim());
}
/**
* Mermaid's `base` theme in the veni-os whiteboard palette (its excalidraw colour scheme): light fills with a dark
* navy stroke, slate lines, blue headings, and the same warm / green / violet roles for start, end and AI. Nodes stay
* light in dark mode so their text keeps its contrast; the text and lines around them follow the theme.
*
* Charts follow the whiteboard's chart templates: colour is the palette, and one accent (the `start` orange) marks the
* one thing the chart is about; everything else is neutral blue, with a navy axis, slate gridlines and ink labels.
*/
export function docDiagramTheme(dark: boolean) {
const ink = "#374151";
const navy = "#1e3a5f";
const slate = "#64748b";
const focal = { fill: "#fed7aa", stroke: "#c2410c" };
const neutral = { fill: "#dbeafe", stroke: "#1e40af" };
const total = "#93c5fd";
const grid = dark ? "#475569" : "#cbd5e1";
const heading = dark ? "#93c5fd" : "#1e40af";
const text = dark ? "#e2e8f0" : ink;
const fills = ["#93c5fd", "#fed7aa", "#a7f3d0", "#ddd6fe", "#fef3c7", "#fee2e2", "#60a5fa", "#dbeafe"];
// Series colours for the charts and sections of the maps: the accent first, mid-tones that still carry ink text.
const series = ["#fdba74", "#93c5fd", "#6ee7b7", "#c4b5fd", "#fcd34d", "#fca5a5", "#60a5fa", "#bfdbfe"];
const strokes = ["#1e40af", "#c2410c", "#047857", "#6d28d9", "#b45309", "#dc2626", "#1e3a5f", "#3b82f6"];
const scale = Object.fromEntries(
series.flatMap((fill, i) => [
[`cScale${i}`, fill],
[`cScaleLabel${i}`, ink],
]),
);
const pie = Object.fromEntries(
[focal.fill, total, neutral.fill, "#60a5fa", grid, "#3b82f6", "#a7f3d0", "#ddd6fe"].map((fill, i) => [
`pie${i + 1}`,
fill,
]),
);
const git = Object.fromEntries(
strokes.flatMap((stroke, i) => [
[`git${i}`, stroke],
[`gitBranchLabel${i}`, "#ffffff"],
]),
);
const fill = Object.fromEntries(fills.map((color, i) => [`fillType${i}`, color]));
return {
fontFamily: "Inter, ui-sans-serif, system-ui, sans-serif",
primaryColor: neutral.fill,
primaryBorderColor: navy,
primaryTextColor: ink,
secondaryColor: "#fef3c7",
secondaryBorderColor: "#b45309",
secondaryTextColor: ink,
tertiaryColor: "#a7f3d0",
tertiaryBorderColor: "#047857",
tertiaryTextColor: ink,
lineColor: dark ? "#94a3b8" : slate,
textColor: text,
titleColor: heading,
edgeLabelBackground: dark ? "#1e293b" : "#ffffff",
mainBkg: neutral.fill,
nodeBorder: navy,
clusterBkg: dark ? "#1e293b" : "#eff6ff",
clusterBorder: total,
noteBkgColor: "#fef3c7",
noteBorderColor: "#b45309",
noteTextColor: ink,
actorBkg: total,
actorBorder: navy,
actorTextColor: navy,
actorLineColor: dark ? "#94a3b8" : slate,
signalColor: dark ? "#cbd5e1" : ink,
signalTextColor: text,
labelBoxBkgColor: neutral.fill,
labelBoxBorderColor: navy,
labelTextColor: navy,
loopTextColor: text,
...scale,
...git,
...fill,
// Pie: the accent slice first, then blues; a navy outline like a whiteboard shape.
...pie,
pieStrokeColor: navy,
pieStrokeWidth: "1.5px",
pieOuterStrokeColor: navy,
pieOuterStrokeWidth: "1.5px",
pieOpacity: "1",
pieTitleTextColor: heading,
pieSectionTextColor: ink,
pieLegendTextColor: text,
// XY: neutral bars, the accent line, then navy and slate; a navy axis with slate ticks.
xyChart: {
backgroundColor: "transparent",
titleColor: heading,
plotColorPalette: [total, focal.stroke, navy, slate, "#3b82f6"].join(","),
xAxisLineColor: dark ? "#94a3b8" : navy,
yAxisLineColor: dark ? "#94a3b8" : navy,
xAxisTickColor: slate,
yAxisTickColor: slate,
xAxisLabelColor: text,
yAxisLabelColor: text,
xAxisTitleColor: text,
yAxisTitleColor: text,
},
// Quadrant: no filled quadrants but faint tints, the accent on the points.
quadrant1Fill: dark ? "#2a2118" : "#fff7ed",
quadrant2Fill: dark ? "#1b2433" : "#eff6ff",
quadrant3Fill: dark ? "#1e293b" : "#f8fafc",
quadrant4Fill: dark ? "#1b2433" : "#eff6ff",
quadrant1TextFill: text,
quadrant2TextFill: text,
quadrant3TextFill: text,
quadrant4TextFill: text,
quadrantPointFill: focal.stroke,
quadrantPointTextFill: text,
quadrantXAxisTextFill: text,
quadrantYAxisTextFill: text,
quadrantTitleFill: heading,
quadrantInternalBorderStrokeFill: grid,
quadrantExternalBorderStrokeFill: dark ? "#94a3b8" : navy,
// Gantt: neutral bars, the active task in the accent, a dashed navy today line.
taskBkgColor: neutral.fill,
taskBorderColor: neutral.stroke,
taskTextColor: ink,
taskTextLightColor: ink,
taskTextDarkColor: ink,
taskTextOutsideColor: text,
activeTaskBkgColor: focal.fill,
activeTaskBorderColor: focal.stroke,
doneTaskBkgColor: total,
doneTaskBorderColor: navy,
critBkgColor: "#fee2e2",
critBorderColor: "#dc2626",
gridColor: grid,
todayLineColor: dark ? "#93c5fd" : navy,
sectionBkgColor: dark ? "#1e293b" : "#f8fafc",
altSectionBkgColor: "transparent",
sectionBkgColor2: dark ? "#1b2433" : "#eff6ff",
excludeBkgColor: dark ? "#1e293b" : "#f1f5f9",
// Packet: neutral fields with a navy outline.
packet: {
startByteColor: slate,
endByteColor: slate,
labelColor: ink,
titleColor: heading,
blockStrokeColor: navy,
blockFillColor: neutral.fill,
},
// Radar: a navy axis, slate rings at the whiteboard's gridline strength.
radar: {
axisColor: slate,
axisStrokeWidth: 1,
graticuleColor: slate,
graticuleOpacity: 0.1,
graticuleStrokeWidth: 1,
curveOpacity: 0.45,
curveStrokeWidth: 2.5,
},
};
}
/** What Mermaid is initialised with: the base theme, its variables, and the Sankey ribbons in slate. */
export function docDiagramConfig(dark: boolean) {
return { theme: "base" as const, themeVariables: docDiagramTheme(dark), sankey: { linkColor: "#64748b" } };
}
/** Mermaid fixes a few colours in the SVG itself; set them here: Sankey nodes are navy ink and its ribbons whiteboard-light. */
export function restyleDiagram(svg: string) {
return svg
.replace(/(<g class="node"[^>]*>\s*<rect[^>]*?) fill="[^"]*"/g, '$1 fill="#1e3a5f"')
.replace('class="links" fill="none" stroke-opacity="0.5"', 'class="links" fill="none" stroke-opacity="0.35"');
}
/** Whether the page is in dark mode (a `dark` class on the root), following the theme toggle. */
function useDark() {
const [dark, setDark] = React.useState(
() => typeof document !== "undefined" && document.documentElement.classList.contains("dark"),
);
React.useEffect(() => {
const root = document.documentElement;
const observer = new MutationObserver(() => setDark(root.classList.contains("dark")));
observer.observe(root, { attributes: true, attributeFilter: ["class"] });
return () => observer.disconnect();
}, []);
return dark;
}
export interface DocDiagramProps {
/** Mermaid source. */
value: string;
readOnly?: boolean;
onChange?: (value: string) => void;
className?: string;
}
const tab =
"rounded px-2 py-0.5 text-xs hover:bg-bg-surface-2 aria-pressed:bg-bg-surface-2 aria-pressed:font-medium pointer-coarse:min-h-11 pointer-coarse:min-w-11";
let renders = 0;
/** A Mermaid diagram of any type it knows: source and live preview in the whiteboard palette, with a starter per type. Mermaid loads on first preview. */
function DocDiagram({ value, readOnly, onChange, className }: DocDiagramProps) {
const [view, setView] = React.useState<"preview" | "source">("preview");
const [svg, setSvg] = React.useState("");
const [error, setError] = React.useState<string | null>(null);
const id = "mmd" + React.useId().replace(/\W/g, "");
const dark = useDark();
React.useEffect(() => {
if (view !== "preview" || !value.trim()) return;
let live = true;
// A fresh id per render: overlapping renders must not share a node, and a failed one leaves `#d<id>` behind.
const rid = `${id}-${++renders}`;
(async () => {
try {
const { default: mermaid } = await import("mermaid");
mermaid.initialize({ startOnLoad: false, securityLevel: "strict", ...docDiagramConfig(dark) });
const out = await mermaid.render(rid, value);
if (live) (setSvg(restyleDiagram(out.svg)), setError(null));
} catch (e) {
document.getElementById(`d${rid}`)?.remove();
if (live) (setSvg(""), setError(e instanceof Error ? e.message : String(e)));
}
})();
return () => {
live = false;
};
}, [value, view, id, dark]);
return (
<div className={cn("my-2 overflow-hidden rounded-md border border-border-default", className)}>
{!readOnly && (
<div className="flex items-center gap-1 px-2 py-1 text-text-muted">
<button type="button" aria-pressed={view === "preview"} className={tab} onClick={() => setView("preview")}>
Preview
</button>
<button type="button" aria-pressed={view === "source"} className={tab} onClick={() => setView("source")}>
Source
</button>
{view === "source" && (
// Remounts after a pick, so the trigger shows its prompt again.
<Select
key={value}
onValueChange={name => typeof name === "string" && onChange?.(DOC_DIAGRAM_TEMPLATES[name])}
>
<SelectTrigger
variant="fill"
className="ml-auto h-6 w-auto gap-1 px-1.5 text-xs"
aria-label="Insert an example diagram"
>
<SelectValue placeholder="Insert example" />
</SelectTrigger>
<SelectContent>
{Object.keys(DOC_DIAGRAM_TEMPLATES).map(name => (
<SelectItem key={name} value={name}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
)}
{view === "source" ? (
<textarea
spellCheck={false}
aria-label="Mermaid source"
value={value}
readOnly={readOnly}
onChange={e => onChange?.(e.target.value)}
className="block min-h-32 w-full resize-none bg-transparent p-2 font-mono text-sm leading-[1.6] outline-none field-sizing-content"
/>
) : error ? (
<p role="alert" className="p-3 text-sm text-negative-middle">
Can't draw this diagram. Check the source: {error.split("\n")[0]}
</p>
) : svg ? (
<div className="flex justify-center overflow-x-auto p-3" dangerouslySetInnerHTML={{ __html: svg }} />
) : (
<p className="p-3 text-sm text-text-muted">{value.trim() ? "Rendering…" : "Empty diagram"}</p>
)}
</div>
);
}
export { DocDiagram };