Skip to content
edmi-ui

Canvas

Workflow canvas on xyflow (React Flow, Vue Flow, Svelte Flow) with the dotted Edmi background.

StackBlitz ↗StackBlitz ↗StackBlitz ↗
Press enter or space to select a node. You can then use the arrow keys to move the node around. Press delete to remove it and escape to cancel.
Press enter or space to select an edge. You can then press delete to remove it or escape to cancel.

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.

Terminal window
npx shadcn@latest add @edmi-ui/ai-canvas
Terminal window
pnpm dlx shadcn@latest add @edmi-ui/ai-canvas
Terminal window
yarn dlx shadcn@latest add @edmi-ui/ai-canvas
Terminal window
bunx shadcn@latest add @edmi-ui/ai-canvas

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-vue@latest add @edmi-ui/ai-canvas
Terminal window
pnpm dlx shadcn-vue@latest add @edmi-ui/ai-canvas
Terminal window
yarn dlx shadcn-vue@latest add @edmi-ui/ai-canvas
Terminal window
bunx shadcn-vue@latest add @edmi-ui/ai-canvas

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-canvas.json
Terminal window
pnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-canvas.json
Terminal window
yarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-canvas.json
Terminal window
bunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-canvas.json

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

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>
registry/components/ai/canvas.tsx
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>
);
registry/components/ai/canvas/Canvas.vue
<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>
registry/components/ai/canvas/index.ts
export { default as Canvas } from "./Canvas.vue";
src/lib/registry/ai/canvas/canvas.svelte
<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>
src/lib/registry/ai/canvas/index.ts
import Canvas from "./canvas.svelte";
export { Canvas };
Part Prop Type Default
Canvas nodes, edges, nodeTypes, edgeTypes xyflow props
Canvas connectionLineComponent Connection