Canvas
Workflow canvas on xyflow (React Flow, Vue Flow, Svelte Flow) with the dotted Edmi background.
import { Canvas } from "@/components/ai/canvas";import { Controls } from "@/components/ai/controls";import { Edge } from "@/components/ai/edge";import { Node, NodeContent, NodeDescription, NodeFooter, NodeHeader, NodeTitle,} from "@/components/ai/node";import { Panel } from "@/components/ai/panel";import { Badge } from "@/components/ui/badge";import { Button } from "@/components/ui/button";
type StepData = { title: string; description: string; body?: string; footer?: string; handles: { target: boolean; source: boolean };};
const nodes = [ { id: "start", type: "step", position: { x: 0, y: 90 }, data: { title: "Start", description: "Trigger · every hour", body: "cron 0 * * * *", handles: { target: false, source: true }, }, }, { id: "drift", type: "step", position: { x: 330, y: 0 }, data: { title: "Check drift", description: "Tool · get_prices", body: "MAG4 drift: 2.4%", footer: "412 ms", handles: { target: true, source: true }, }, }, { id: "post", type: "step", position: { x: 330, y: 190 }, data: { title: "Draft feed post", description: "Agent · writer", body: "Writing...", footer: "running", handles: { target: true, source: true }, }, }, { id: "decision", type: "step", position: { x: 660, y: 0 }, data: { title: "Decision", description: "drift > 2%?", body: "yes / no", handles: { target: true, source: true }, }, },];
const edges = [ { id: "e1", source: "start", target: "drift", type: "animated" }, { id: "e2", source: "start", target: "post" }, { id: "e3", source: "drift", target: "decision", type: "animated" }, { id: "e4", source: "post", target: "decision", type: "temporary" },];
const nodeTypes = { step: ({ data, selected }: { data: StepData; selected?: boolean }) => ( <Node handles={data.handles} selected={selected}> <NodeHeader> <NodeTitle>{data.title}</NodeTitle> <NodeDescription>{data.description}</NodeDescription> </NodeHeader> <NodeContent> {data.body === "cron 0 * * * *" ? ( <Badge variant="secondary">{data.body}</Badge> ) : ( <span className="font-mono text-xs">{data.body}</span> )} </NodeContent> {data.footer && <NodeFooter>{data.footer}</NodeFooter>} </Node> ),};
const edgeTypes = { animated: Edge.Animated, temporary: Edge.Temporary,};
export default function Demo() { return ( <div className="h-[420px] w-full overflow-hidden rounded-[calc(var(--radius)*1.4)] border border-border"> <Canvas edges={edges} edgeTypes={edgeTypes} nodes={nodes} nodeTypes={nodeTypes} > <Controls /> <Panel position="top-right"> <Button size="sm">Run</Button> </Panel> </Canvas> </div> );}<script setup lang="ts">import { markRaw } from "vue";import { Canvas } from "@/components/ai/canvas";import { Controls } from "@/components/ai/controls";import { Edge } from "@/components/ai/edge";import { Node, NodeContent, NodeDescription, NodeFooter, NodeHeader, NodeTitle } from "@/components/ai/node";import { Panel } from "@/components/ai/panel";import { Badge } from "@/components/ui/badge";import { Button } from "@/components/ui/button";
const nodes = [ { id: "start", type: "step", position: { x: 0, y: 90 }, data: { title: "Start", description: "Trigger · every hour", body: "cron 0 * * * *", handles: { target: false, source: true } } }, { id: "drift", type: "step", position: { x: 330, y: 0 }, data: { title: "Check drift", description: "Tool · get_prices", body: "MAG4 drift: 2.4%", footer: "412 ms", handles: { target: true, source: true } } }, { id: "post", type: "step", position: { x: 330, y: 190 }, data: { title: "Draft feed post", description: "Agent · writer", body: "Writing...", footer: "running", handles: { target: true, source: true } } }, { id: "decision", type: "step", position: { x: 660, y: 0 }, data: { title: "Decision", description: "drift > 2%?", body: "yes / no", handles: { target: true, source: true } } },];
const edges = [ { id: "e1", source: "start", target: "drift", type: "animated" }, { id: "e2", source: "start", target: "post" }, { id: "e3", source: "drift", target: "decision", type: "animated" }, { id: "e4", source: "post", target: "decision", type: "temporary" },];
const edgeTypes = { animated: markRaw(Edge.Animated), temporary: markRaw(Edge.Temporary) };</script>
<template> <div class="h-[420px] w-full overflow-hidden rounded-[calc(var(--radius)*1.4)] border border-border"> <Canvas :nodes="nodes" :edges="edges" :edge-types="edgeTypes"> <template #node-step="{ data, selected }"> <Node :handles="data.handles" :selected="selected"> <NodeHeader> <NodeTitle>{{ data.title }}</NodeTitle> <NodeDescription>{{ data.description }}</NodeDescription> </NodeHeader> <NodeContent> <Badge v-if="data.body === 'cron 0 * * * *'" variant="secondary">{{ data.body }}</Badge> <span v-else class="font-mono text-xs">{{ data.body }}</span> </NodeContent> <NodeFooter v-if="data.footer">{{ data.footer }}</NodeFooter> </Node> </template> <Controls /> <Panel position="top-right"> <Button size="sm">Run</Button> </Panel> </Canvas> </div></template><script lang="ts"> import { Canvas } from "$lib/components/ai/canvas"; import { Controls } from "$lib/components/ai/controls"; import { Edge } from "$lib/components/ai/edge"; import { Panel } from "$lib/components/ai/panel"; import { Button } from "$lib/components/ui/button"; import Step from "./_shared/ai-workflow-step.svelte";
const nodeTypes = { step: Step }; const edgeTypes = { animated: Edge.Animated, temporary: Edge.Temporary };
let nodes = $state.raw([ { id: "start", type: "step", position: { x: 0, y: 90 }, data: { title: "Start", description: "Trigger · every hour", body: "cron 0 * * * *", handles: { target: false, source: true } } }, { id: "drift", type: "step", position: { x: 330, y: 0 }, data: { title: "Check drift", description: "Tool · get_prices", body: "MAG4 drift: 2.4%", footer: "412 ms", handles: { target: true, source: true } } }, { id: "post", type: "step", position: { x: 330, y: 190 }, data: { title: "Draft feed post", description: "Agent · writer", body: "Writing...", footer: "running", handles: { target: true, source: true } } }, { id: "decision", type: "step", position: { x: 660, y: 0 }, data: { title: "Decision", description: "drift > 2%?", body: "yes / no", handles: { target: true, source: true } } }, ]); let edges = $state.raw([ { id: "e1", source: "start", target: "drift", type: "animated" }, { id: "e2", source: "start", target: "post" }, { id: "e3", source: "drift", target: "decision", type: "animated" }, { id: "e4", source: "post", target: "decision", type: "temporary" }, ]);</script>
<div style="height: 420px" class="w-full overflow-hidden rounded-[calc(var(--radius)*1.4)] border border-border"> <Canvas bind:nodes bind:edges {nodeTypes} {edgeTypes}> <Controls /> <Panel position="top-right"> <Button size="sm">Run</Button> </Panel> </Canvas></div>Hosts Node, Edge, Connection, Controls, Panel and Toolbar. Dots are --input on --background; the surface has pan on scroll, selection on drag, fit view on load and Backspace/Delete to remove.
Installation
Section titled “Installation”npx shadcn@latest add @edmi-ui/ai-canvaspnpm dlx shadcn@latest add @edmi-ui/ai-canvasyarn dlx shadcn@latest add @edmi-ui/ai-canvasbunx shadcn@latest add @edmi-ui/ai-canvasPick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Install the dependencies:
Terminal window npm install @xyflow/reactTerminal window pnpm add @xyflow/reactTerminal window yarn add @xyflow/reactTerminal window bun add @xyflow/reactCopy the code from Component source into your project and update the import paths to match your aliases.
npx shadcn-vue@latest add @edmi-ui/ai-canvaspnpm dlx shadcn-vue@latest add @edmi-ui/ai-canvasyarn dlx shadcn-vue@latest add @edmi-ui/ai-canvasbunx shadcn-vue@latest add @edmi-ui/ai-canvasPick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Install the dependencies:
Terminal window npm install @vue-flow/core @vue-flow/background reka-uiTerminal window pnpm add @vue-flow/core @vue-flow/background reka-uiTerminal window yarn add @vue-flow/core @vue-flow/background reka-uiTerminal window bun add @vue-flow/core @vue-flow/background reka-uiCopy the code from Component source into your project and update the import paths to match your aliases.
npx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-canvas.jsonpnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-canvas.jsonyarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-canvas.jsonbunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-canvas.jsonPick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Copy the code from Component source into your project and update the import paths to match your aliases.
import { Canvas } from "@/components/ai/canvas";import { Controls } from "@/components/ai/controls";import { Panel } from "@/components/ai/panel";import { Button } from "@/components/ui/button";
<div className="h-[420px] w-full overflow-hidden rounded-[calc(var(--radius)*1.4)] border border-border"> <Canvas > <Controls /> <Panel position="top-right"> <Button size="sm">Run</Button> </Panel> </Canvas></div><script setup lang="ts">import { Canvas } from "@/components/ai/canvas";import { Controls } from "@/components/ai/controls";import { Node, NodeContent, NodeDescription, NodeFooter, NodeHeader, NodeTitle,} from "@/components/ai/node";import { Panel } from "@/components/ai/panel";import { Badge } from "@/components/ui/badge";import { Button } from "@/components/ui/button";</script>
<template> <div class="h-[420px] w-full overflow-hidden rounded-[calc(var(--radius)*1.4)] border border-border"> <Canvas> <template #node-step="{ data, selected }"> <Node :handles="data.handles" :selected="selected"> <NodeHeader> <NodeTitle>{{ data.title }}</NodeTitle> <NodeDescription>{{ data.description }}</NodeDescription> </NodeHeader> <NodeContent> <Badge v-if="data.body === 'cron 0 * * * *'" variant="secondary">{{ data.body }}</Badge> <span v-else class="font-mono text-xs">{{ data.body }}</span> </NodeContent> <NodeFooter v-if="data.footer">{{ data.footer }}</NodeFooter> </Node> </template> <Controls /> <Panel position="top-right"> <Button size="sm">Run</Button> </Panel> </Canvas> </div></template><script lang="ts"> import { Canvas } from "$lib/components/ai/canvas"; import { Controls } from "$lib/components/ai/controls"; import { Panel } from "$lib/components/ai/panel"; import { Button } from "$lib/components/ui/button";</script>
<div style="height: 420px" class="w-full overflow-hidden rounded-[calc(var(--radius)*1.4)] border border-border"> <Canvas bind:nodes bind:edges > <Controls /> <Panel position="top-right"> <Button size="sm">Run</Button> </Panel> </Canvas></div>Component source
Section titled “Component source”import type { ReactFlowProps } from "@xyflow/react";import { Background, BackgroundVariant, ReactFlow } from "@xyflow/react";import { cn } from "cn";import type { CSSProperties, ReactNode } from "react";
import "@xyflow/react/dist/style.css";
export type CanvasProps = ReactFlowProps & { children?: ReactNode;};
const deleteKeyCode = ["Backspace", "Delete"];
/** xyflow reads these variables; mapping them to Edmi tokens keeps every mode and theme in sync. */const tokenVars = { "--xy-edge-stroke-default": "var(--muted-foreground)", "--xy-edge-stroke-selected-default": "var(--foreground)", "--xy-edge-stroke-width-default": "1.6", "--xy-connectionline-stroke-default": "var(--ring)", "--xy-connectionline-stroke-width-default": "1.6", "--xy-background-color-default": "var(--background)", "--xy-selection-background-color-default": "var(--ring-soft)", "--xy-selection-border-default": "1px solid var(--ring)", "--xy-attribution-background-color-default": "var(--card)",} as CSSProperties;
/** Workflow surface: dotted `--input` dots on `--background`, pan and zoom, fit view on load. */export const Canvas = ({ children, className, style, ...props}: CanvasProps) => ( <ReactFlow data-slot="ai-canvas" className={cn("bg-background", className)} deleteKeyCode={deleteKeyCode} fitView panOnDrag={false} panOnScroll selectionOnDrag={true} style={{ ...tokenVars, ...style }} zoomOnDoubleClick={false} {...props} > <Background color="var(--input)" gap={18} size={1} variant={BackgroundVariant.Dots} /> {children} </ReactFlow>);<script setup lang="ts">import type { FlowEmits, FlowProps } from "@vue-flow/core"import { Background } from "@vue-flow/background"import { VueFlow } from "@vue-flow/core"import { useForwardPropsEmits, useId } from "reka-ui"import { computed, useSlots } from "vue"import "@vue-flow/core/dist/style.css"
defineOptions({ inheritAttrs: false })
const props = withDefaults(defineProps<FlowProps>(), { deleteKeyCode: () => ["Backspace", "Delete"], fitViewOnInit: true, panOnDrag: false, panOnScroll: true, selectNodesOnDrag: true, zoomOnDoubleClick: false,})
const emits = defineEmits<FlowEmits>()const slots = useSlots()// Every slot except the default one is handed to Vue Flow as is (#node-<type>, #edge-<type>, ...).const forwardedSlotNames = computed(() => Object.keys(slots).filter((n) => n !== "default"))// The FlowProps type is deep enough to hit TS2589 in the generic helper: widen it.const forwarded = useForwardPropsEmits(props as Record<string, unknown>, emits as any)// Vue Flow numbers its stores with a module counter, which differs between server and client (hydration// mismatch on the generated ids): a stable per-instance id keeps both sides equal.const flowId = useId()</script>
<template> <!-- Workflow surface: dotted --input dots on --background. Edge, selection and connection colors are mapped to Edmi tokens here because Vue Flow ships its defaults as plain colors. --> <VueFlow data-slot="ai-canvas" v-bind="{ ...$attrs, ...forwarded, id: props.id ?? flowId }" class="bg-background" > <Background :gap="18" :size="1" pattern-color="var(--input)" bg-color="var(--background)" />
<!-- Forward the other slots as they are: #node-<type>, #edge-<type>, #connection-line, #zoom-pane. --> <template v-for="name in forwardedSlotNames" :key="name" #[name]="slotData"> <slot :name="name" v-bind="slotData || {}" /> </template>
<slot /> </VueFlow></template>
<style>/* Vue Flow ships its defaults as plain colors; map them to Edmi tokens (specificity 0,2,0 beats the library). */[data-slot="ai-canvas"] .vue-flow__edge-path { stroke: var(--muted-foreground); stroke-width: 1.6;}[data-slot="ai-canvas"] .vue-flow__edge.selected .vue-flow__edge-path { stroke: var(--foreground);}[data-slot="ai-canvas"] .vue-flow__connection-path { stroke: var(--ring); stroke-width: 1.6;}[data-slot="ai-canvas"] .vue-flow__selection,[data-slot="ai-canvas"] .vue-flow__nodesselection-rect { border: 1px solid var(--ring); background: var(--ring-soft);}</style>export { default as Canvas } from "./Canvas.vue";<script lang="ts"> import { Background, BackgroundVariant, SvelteFlow, type SvelteFlowProps } from "@xyflow/svelte"; import type { Snippet } from "svelte"; import { cn } from "$lib/utils.js"; import "@xyflow/svelte/dist/style.css";
let { nodes = $bindable([]), edges = $bindable([]), class: className, style, children, ...restProps }: SvelteFlowProps & { children?: Snippet } = $props();
// xyflow reads these variables; mapping them to Edmi tokens keeps every mode and theme in sync. const tokenVars = [ "--xy-edge-stroke-default: var(--muted-foreground)", "--xy-edge-stroke-selected-default: var(--foreground)", "--xy-edge-stroke-width-default: 1.6", "--xy-connectionline-stroke-default: var(--ring)", "--xy-connectionline-stroke-width-default: 1.6", "--xy-background-color-default: var(--background)", "--xy-attribution-background-color-default: var(--card)", "--xy-selection-background-color-default: var(--ring-soft)", "--xy-selection-border-default: 1px solid var(--ring)", ].join(";");</script>
<!-- Workflow surface: dotted --input dots on --background, pan on scroll, selection on drag, fit view on load. --><SvelteFlow data-slot="ai-canvas" bind:nodes bind:edges class={cn("bg-background", className)} style={`${tokenVars};${style ?? ""}`} deleteKey={["Backspace", "Delete"]} fitView panOnDrag={false} panOnScroll selectionOnDrag zoomOnDoubleClick={false} {...restProps}> <Background gap={18} size={1} variant={BackgroundVariant.Dots} patternColor="var(--input)" /> {@render children?.()}</SvelteFlow>import Canvas from "./canvas.svelte";
export { Canvas };| Part | Prop | Type | Default |
|---|---|---|---|
| Canvas | nodes, edges, nodeTypes, edgeTypes |
xyflow props | |
| Canvas | connectionLineComponent |
Connection |