Vela UI Docsvenizia.ai

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-diagram
npx shadcn@latest add @velaui/doc-diagram

Examples

  • Default
  • Sequence
  • Charts
  • Invalid

View code

Doc Diagram · Default

Installation

pnpm dlx velaui add @velaui/doc-diagram
pnpm dlx velaui add @velaui/doc-diagram

Code

doc-diagram-default.tsx
import * as React from "react";

import { DocDiagram, DOC_DIAGRAM_TEMPLATES } from "@/components/ui/doc-diagram";

export default function Example() {
  const [code, setCode] = React.useState(DOC_DIAGRAM_TEMPLATES.Flowchart);
  return (
    <div className="w-full max-w-xl p-6">
      <DocDiagram value={code} onChange={setCode} />
    </div>
  );
}

View code

Doc Diagram · Sequence

Installation

pnpm dlx velaui add @velaui/doc-diagram
pnpm dlx velaui add @velaui/doc-diagram

Code

doc-diagram-sequence.tsx
import { DocDiagram, DOC_DIAGRAM_TEMPLATES } from "@/components/ui/doc-diagram";

export default function Example() {
  return (
    <div className="w-full max-w-xl p-6">
      <DocDiagram readOnly value={DOC_DIAGRAM_TEMPLATES.Sequence} />
    </div>
  );
}

View code

Doc Diagram · Charts

Installation

pnpm dlx velaui add @velaui/doc-diagram
pnpm dlx velaui add @velaui/doc-diagram

Code

doc-diagram-charts.tsx
import { DocDiagram, DOC_DIAGRAM_TEMPLATES } from "@/components/ui/doc-diagram";

// The same palette draws every type: flowcharts and sequences, but also charts, plans and maps.
const TYPES = ["Pie", "XY chart", "Gantt", "Mindmap"];

export default function Example() {
  return (
    <div className="w-full max-w-xl space-y-2 p-6">
      {TYPES.map(name => (
        <DocDiagram key={name} readOnly value={DOC_DIAGRAM_TEMPLATES[name]} />
      ))}
    </div>
  );
}

View code

Doc Diagram · Invalid

Installation

pnpm dlx velaui add @velaui/doc-diagram
pnpm dlx velaui add @velaui/doc-diagram

Code

doc-diagram-invalid.tsx
import { DocDiagram } from "@/components/ui/doc-diagram";

export default function Example() {
  return (
    <div className="w-full max-w-xl p-6">
      <DocDiagram readOnly value={"flowchart TD\n  A -->"} />
    </div>
  );
}

Usage

import * as React from "react";

import { DocDiagram, DOC_DIAGRAM_TEMPLATES } from "@/components/ui/doc-diagram";

export default function Example() {
  const [code, setCode] = React.useState(DOC_DIAGRAM_TEMPLATES.Flowchart);
  return (
    <div className="w-full max-w-xl p-6">
      <DocDiagram value={code} onChange={setCode} />
    </div>
  );
}

Props

PropTypeDefault
classNamestring—
onChange(value: string) => void—
readOnlyboolean—
valuerequiredstring—

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&apos;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 };