Node
Workflow node card with header, content, footer and source/target handles.
import { Canvas } from "@/components/ai/canvas";import { Node, NodeContent, NodeDescription, NodeFooter, NodeHeader, NodeTitle,} from "@/components/ai/node";import { Badge } from "@/components/ui/badge";
const nodes = [ { id: "check", type: "step", position: { x: 0, y: 0 }, data: { selected: false }, }, { id: "decision", type: "decision", position: { x: 300, y: 0 }, data: {}, selected: true, },];
const nodeTypes = { step: () => ( <Node handles={{ target: true, source: true }}> <NodeHeader> <NodeTitle>Check drift</NodeTitle> <NodeDescription>Tool · get_prices</NodeDescription> </NodeHeader> <NodeContent> <span className="font-mono text-xs"> MAG4 drift: <b>2.4%</b> </span> </NodeContent> <NodeFooter>412 ms</NodeFooter> </Node> ), decision: () => ( <Node handles={{ target: true, source: true }}> <NodeHeader> <NodeTitle>Decision</NodeTitle> <NodeDescription>drift > 2%?</NodeDescription> </NodeHeader> <NodeContent className="flex gap-1.5"> <Badge variant="success">yes</Badge> <Badge variant="outline">no</Badge> </NodeContent> <NodeFooter>selected</NodeFooter> </Node> ),};
export default function Demo() { return ( <div className="h-56 w-full overflow-hidden rounded-[calc(var(--radius)*1.4)] border border-border"> <Canvas nodes={nodes} nodeTypes={nodeTypes} edges={[]} /> </div> );}<script setup lang="ts">import { Canvas } from "@/components/ai/canvas";import { Node, NodeContent, NodeDescription, NodeFooter, NodeHeader, NodeTitle } from "@/components/ai/node";import { Badge } from "@/components/ui/badge";
const nodes = [ { id: "check", type: "check", position: { x: 0, y: 0 }, data: {} }, { id: "decision", type: "decision", position: { x: 300, y: 0 }, data: {}, selected: true },];</script>
<template> <div class="h-56 w-full overflow-hidden rounded-[calc(var(--radius)*1.4)] border border-border"> <Canvas :nodes="nodes" :edges="[]"> <template #node-check> <Node :handles="{ target: true, source: true }"> <NodeHeader> <NodeTitle>Check drift</NodeTitle> <NodeDescription>Tool · get_prices</NodeDescription> </NodeHeader> <NodeContent> <span class="font-mono text-xs">MAG4 drift: <b>2.4%</b></span> </NodeContent> <NodeFooter>412 ms</NodeFooter> </Node> </template> <template #node-decision="{ selected }"> <Node :handles="{ target: true, source: true }" :selected="selected"> <NodeHeader> <NodeTitle>Decision</NodeTitle> <NodeDescription>drift > 2%?</NodeDescription> </NodeHeader> <NodeContent class="flex gap-1.5"> <Badge variant="success">yes</Badge> <Badge variant="outline">no</Badge> </NodeContent> <NodeFooter>selected</NodeFooter> </Node> </template> </Canvas> </div></template><script lang="ts"> import { Canvas } from "$lib/components/ai/canvas"; import Step from "./_shared/ai-workflow-step.svelte";
const nodeTypes = { step: Step };
let nodes = $state.raw([ { id: "check", type: "step", position: { x: 0, y: 0 }, data: { title: "Check drift", description: "Tool · get_prices", body: "MAG4 drift: 2.4%", footer: "412 ms", handles: { target: true, source: true } } }, { id: "decision", type: "step", position: { x: 300, y: 0 }, selected: true, data: { title: "Decision", description: "drift > 2%?", badges: ["yes", "no"], footer: "selected", handles: { target: true, source: true } } }, ]); let edges = $state.raw([]);</script>
<div style="height: 224px" class="w-full overflow-hidden rounded-[calc(var(--radius)*1.4)] border border-border"> <Canvas bind:nodes bind:edges {nodeTypes} /></div>Built on card.
Selected nodes get the --ring border and a 3px ring. Inside xyflow the wrapper’s selected class does this automatically.
Installation
Section titled “Installation”npx shadcn@latest add @edmi-ui/ai-nodepnpm dlx shadcn@latest add @edmi-ui/ai-nodeyarn dlx shadcn@latest add @edmi-ui/ai-nodebunx shadcn@latest add @edmi-ui/ai-nodePick 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/reactAdd the Edmi components this one builds on:
card,elevation.Copy 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-nodepnpm dlx shadcn-vue@latest add @edmi-ui/ai-nodeyarn dlx shadcn-vue@latest add @edmi-ui/ai-nodebunx shadcn-vue@latest add @edmi-ui/ai-nodePick 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 @vueuse/coreTerminal window pnpm add @vue-flow/core @vueuse/coreTerminal window yarn add @vue-flow/core @vueuse/coreTerminal window bun add @vue-flow/core @vueuse/coreAdd the Edmi components this one builds on:
card,elevation.Copy 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-node.jsonpnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-node.jsonyarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-node.jsonbunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-node.jsonPick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Add the Edmi components this one builds on:
card,elevation.Copy the code from Component source into your project and update the import paths to match your aliases.
import { Canvas } from "@/components/ai/canvas";
<div className="h-56 w-full overflow-hidden rounded-[calc(var(--radius)*1.4)] border border-border"> <Canvas edges={[]} /></div><script setup lang="ts">import { Canvas } from "@/components/ai/canvas";import { Node, NodeContent, NodeDescription, NodeFooter, NodeHeader, NodeTitle,} from "@/components/ai/node";</script>
<template> <div class="h-56 w-full overflow-hidden rounded-[calc(var(--radius)*1.4)] border border-border"> <Canvas :edges="[]"> <template #node-check> <Node :handles="{ target: true, source: true }"> <NodeHeader> <NodeTitle>Check drift</NodeTitle> <NodeDescription>Tool · get_prices</NodeDescription> </NodeHeader> <NodeContent> <span class="font-mono text-xs">MAG4 drift: <b>2.4%</b></span> </NodeContent> <NodeFooter>412 ms</NodeFooter> </Node> </template> <!-- … --> </Canvas> </div></template><script lang="ts"> import { Canvas } from "$lib/components/ai/canvas";</script>
<div style="height: 224px" class="w-full overflow-hidden rounded-[calc(var(--radius)*1.4)] border border-border"> <Canvas bind:nodes bind:edges /></div>Component source
Section titled “Component source”import { Handle, Position } from "@xyflow/react";import { cn } from "cn";import type { ComponentProps } from "react";import { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle,} from "@/registry/edmi/ui/card";
const handleClass = "!size-2.5 !min-h-0 !min-w-0 !rounded-full !border-2 !border-muted-foreground !bg-card";
export type NodeProps = ComponentProps<typeof Card> & { handles: { target: boolean; source: boolean; }; /** Ring state. Inside xyflow the `selected` class on the node wrapper does the same. */ selected?: boolean;};
/** Workflow step built on Card: target handle on the left, source handle on the right. Flat by default, `elevation` ✦ is forwarded to the card. */export const Node = ({ handles, selected, className, children, ...props}: NodeProps) => ( <Card data-slot="ai-node" data-selected={selected ? "" : undefined} className={cn( "relative size-full h-auto w-60 gap-0 overflow-visible rounded-[calc(var(--radius)*1.2)] py-0", "data-[selected]:border-ring data-[selected]:shadow-ring [.selected_&]:border-ring [.selected_&]:shadow-ring", className, )} {...props} > {handles.target && ( <Handle className={handleClass} position={Position.Left} type="target" /> )} {handles.source && ( <Handle className={handleClass} position={Position.Right} type="source" /> )} {children} </Card>);
export type NodeHeaderProps = ComponentProps<typeof CardHeader>;
export const NodeHeader = ({ className, ...props }: NodeHeaderProps) => ( <CardHeader data-slot="ai-node-header" className={cn( "gap-0.5 rounded-t-[inherit] border-b border-border-2 px-3 py-2.5 [.border-b]:pb-2.5", className, )} {...props} />);
export type NodeTitleProps = ComponentProps<typeof CardTitle>;
export const NodeTitle = ({ className, ...props }: NodeTitleProps) => ( <CardTitle data-slot="ai-node-title" className={cn("text-[13.5px] font-semibold", className)} {...props} />);
export type NodeDescriptionProps = ComponentProps<typeof CardDescription>;
export const NodeDescription = ({ className, ...props}: NodeDescriptionProps) => ( <CardDescription data-slot="ai-node-description" className={cn("text-xs", className)} {...props} />);
export type NodeActionProps = ComponentProps<typeof CardAction>;
export const NodeAction = (props: NodeActionProps) => ( <CardAction data-slot="ai-node-action" {...props} />);
export type NodeContentProps = ComponentProps<typeof CardContent>;
export const NodeContent = ({ className, ...props }: NodeContentProps) => ( <CardContent data-slot="ai-node-content" className={cn("px-3 py-2.5 text-[12.5px]", className)} {...props} />);
export type NodeFooterProps = ComponentProps<typeof CardFooter>;
export const NodeFooter = ({ className, ...props }: NodeFooterProps) => ( <CardFooter data-slot="ai-node-footer" className={cn( "rounded-b-[inherit] border-t border-border-2 bg-transparent px-3 py-2 text-[11.5px] text-muted-foreground", className, )} {...props} />);<script setup lang="ts">import type { HTMLAttributes } from "vue"import { Handle, Position } from "@vue-flow/core"import { cn } from "@/registry/edmi/lib/utils"import { Card } from "@/registry/edmi/ui/card"import type { Elevation } from "@/registry/edmi/ui/elevation"
interface NodeHandles { target?: boolean source?: boolean}
const props = withDefaults(defineProps<{ class?: HTMLAttributes["class"] handles?: NodeHandles /** Ring state. Inside Vue Flow the `selected` class on the node wrapper does the same. */ selected?: boolean /** ✦ depth: sunken -1, flat 0, raised +1, floating +2. */ elevation?: Elevation}>(), { selected: false, elevation: undefined,})
const handleClass = "size-2.5 rounded-full border-2 border-muted-foreground bg-card"</script>
<template> <Card data-slot="ai-node" :data-selected="props.selected ? '' : undefined" :elevation="props.elevation" :class="cn( 'relative size-full h-auto w-60 gap-0 overflow-visible rounded-[calc(var(--radius)*1.2)] py-0', 'data-[selected]:border-ring data-[selected]:shadow-ring [.selected_&]:border-ring [.selected_&]:shadow-ring', props.class, )" > <Handle v-if="props.handles?.target" :class="handleClass" :position="Position.Left" type="target" /> <Handle v-if="props.handles?.source" :class="handleClass" :position="Position.Right" type="source" /> <slot /> </Card></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { CardAction } from "@/registry/edmi/ui/card"
const props = defineProps<{ class?: HTMLAttributes["class"]}>()</script>
<template> <CardAction data-slot="ai-node-action" :class="props.class"> <slot /> </CardAction></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { cn } from "@/registry/edmi/lib/utils"import { CardContent } from "@/registry/edmi/ui/card"
const props = defineProps<{ class?: HTMLAttributes["class"]}>()</script>
<template> <CardContent data-slot="ai-node-content" :class="cn('px-3 py-2.5 text-[12.5px]', props.class)"> <slot /> </CardContent></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { cn } from "@/registry/edmi/lib/utils"import { CardDescription } from "@/registry/edmi/ui/card"
const props = defineProps<{ class?: HTMLAttributes["class"]}>()</script>
<template> <CardDescription data-slot="ai-node-description" :class="cn('text-xs', props.class)"> <slot /> </CardDescription></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { cn } from "@/registry/edmi/lib/utils"import { CardFooter } from "@/registry/edmi/ui/card"
const props = defineProps<{ class?: HTMLAttributes["class"]}>()</script>
<template> <CardFooter data-slot="ai-node-footer" :class="cn('rounded-b-[inherit] border-t border-border-2 bg-transparent px-3 py-2 text-[11.5px] text-muted-foreground', props.class)" > <slot /> </CardFooter></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { cn } from "@/registry/edmi/lib/utils"import { CardHeader } from "@/registry/edmi/ui/card"
const props = defineProps<{ class?: HTMLAttributes["class"]}>()</script>
<template> <CardHeader data-slot="ai-node-header" :class="cn('gap-0.5 rounded-t-[inherit] border-b border-border-2 px-3 py-2.5 [.border-b]:pb-2.5', props.class)" > <slot /> </CardHeader></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { cn } from "@/registry/edmi/lib/utils"import { CardTitle } from "@/registry/edmi/ui/card"
const props = defineProps<{ class?: HTMLAttributes["class"]}>()</script>
<template> <CardTitle data-slot="ai-node-title" :class="cn('text-[13.5px] font-semibold', props.class)"> <slot /> </CardTitle></template>export { default as Node } from "./Node.vue";export { default as NodeAction } from "./NodeAction.vue";export { default as NodeContent } from "./NodeContent.vue";export { default as NodeDescription } from "./NodeDescription.vue";export { default as NodeFooter } from "./NodeFooter.vue";export { default as NodeHeader } from "./NodeHeader.vue";export { default as NodeTitle } from "./NodeTitle.vue";<script lang="ts"> import { Handle, Position } from "@xyflow/svelte"; import type { ComponentProps } from "svelte"; import { cn } from "$lib/utils.js"; import { Card } from "$lib/registry/ui/card/index.js";
let { class: className, handles = { target: false, source: false }, selected = false, children, ...restProps }: ComponentProps<typeof Card> & { handles?: { target?: boolean; source?: boolean }; /** Ring state. Inside Svelte Flow the `selected` class on the node wrapper does the same. */ selected?: boolean; } = $props();
const handleClass = "size-2.5! min-h-0! min-w-0! rounded-full! border-2! border-muted-foreground! bg-card!";</script>
<Card {...restProps} data-slot="ai-node" data-selected={selected ? "" : undefined} class={cn( "relative size-full h-auto w-60 gap-0 overflow-visible rounded-[calc(var(--radius)*1.2)] py-0", "data-[selected]:border-ring data-[selected]:shadow-ring [.selected_&]:border-ring [.selected_&]:shadow-ring", className )}> {#if handles.target} <Handle class={handleClass} position={Position.Left} type="target" /> {/if} {#if handles.source} <Handle class={handleClass} position={Position.Right} type="source" /> {/if} {@render children?.()}</Card><script lang="ts"> import type { ComponentProps } from "svelte"; import { CardAction } from "$lib/registry/ui/card/index.js";
let { class: className, children, ...restProps }: ComponentProps<typeof CardAction> = $props();</script>
<CardAction {...restProps} data-slot="ai-node-action" class={className}> {@render children?.()}</CardAction><script lang="ts"> import type { ComponentProps } from "svelte"; import { cn } from "$lib/utils.js"; import { CardContent } from "$lib/registry/ui/card/index.js";
let { class: className, children, ...restProps }: ComponentProps<typeof CardContent> = $props();</script>
<CardContent {...restProps} data-slot="ai-node-content" class={cn("px-3 py-2.5 text-[12.5px]", className)}> {@render children?.()}</CardContent><script lang="ts"> import type { ComponentProps } from "svelte"; import { cn } from "$lib/utils.js"; import { CardDescription } from "$lib/registry/ui/card/index.js";
let { class: className, children, ...restProps }: ComponentProps<typeof CardDescription> = $props();</script>
<CardDescription {...restProps} data-slot="ai-node-description" class={cn("text-xs", className)}> {@render children?.()}</CardDescription><script lang="ts"> import type { ComponentProps } from "svelte"; import { cn } from "$lib/utils.js"; import { CardFooter } from "$lib/registry/ui/card/index.js";
let { class: className, children, ...restProps }: ComponentProps<typeof CardFooter> = $props();</script>
<CardFooter {...restProps} data-slot="ai-node-footer" class={cn("rounded-b-[inherit] border-t border-border-2 bg-transparent px-3 py-2 text-[11.5px] text-muted-foreground", className)}> {@render children?.()}</CardFooter><script lang="ts"> import type { ComponentProps } from "svelte"; import { cn } from "$lib/utils.js"; import { CardHeader } from "$lib/registry/ui/card/index.js";
let { class: className, children, ...restProps }: ComponentProps<typeof CardHeader> = $props();</script>
<CardHeader {...restProps} data-slot="ai-node-header" class={cn("gap-0.5 rounded-t-[inherit] border-b border-border-2 px-3 py-2.5 [.border-b]:pb-2.5", className)}> {@render children?.()}</CardHeader><script lang="ts"> import type { ComponentProps } from "svelte"; import { cn } from "$lib/utils.js"; import { CardTitle } from "$lib/registry/ui/card/index.js";
let { class: className, children, ...restProps }: ComponentProps<typeof CardTitle> = $props();</script>
<CardTitle {...restProps} data-slot="ai-node-title" class={cn("text-[13.5px] font-semibold", className)}> {@render children?.()}</CardTitle>import Root from "./node.svelte";import Action from "./node-action.svelte";import Content from "./node-content.svelte";import Description from "./node-description.svelte";import Footer from "./node-footer.svelte";import Header from "./node-header.svelte";import Title from "./node-title.svelte";
export { Action, Action as NodeAction, Content, Content as NodeContent, Description, Description as NodeDescription, Footer, Footer as NodeFooter, Header, Header as NodeHeader, Root, // Root as Node, Title, Title as NodeTitle,};Elevation ✦
Section titled “Elevation ✦”See the Elevation guide for the levels, roles and layered mode.
The node card are flat by default and take the surface level in layered mode (raised). sunken (-1) is a soft inset, raised (+1) a bevel and floating (+2) a bevel with a soft drop.
import { Canvas } from "@/components/ai/canvas";import { Node, NodeContent, NodeDescription, NodeFooter, NodeHeader, NodeTitle,} from "@/components/ai/node";
const levels = [ { value: "sunken", label: "Sunken (-1)" }, { value: "flat", label: "Flat (0)" }, { value: "raised", label: "Raised (+1)" }, { value: "floating", label: "Floating (+2)" },] as const;
const nodes = levels.map(({ value, label }, i) => ({ id: value, type: "step", position: { x: (i % 2) * 290, y: Math.floor(i / 2) * 170 }, data: { elevation: value, label },}));
const nodeTypes = { step: ({ data, }: { data: { elevation: (typeof levels)[number]["value"]; label: string }; }) => ( <Node handles={{ target: true, source: true }} elevation={data.elevation}> <NodeHeader> <NodeTitle>Check drift</NodeTitle> <NodeDescription>{data.label}</NodeDescription> </NodeHeader> <NodeContent> <span className="font-mono text-xs"> MAG4 drift: <b>2.4%</b> </span> </NodeContent> <NodeFooter>412 ms</NodeFooter> </Node> ),};
export default function Demo() { return ( <div className="h-[26rem] w-full overflow-hidden rounded-[calc(var(--radius)*1.4)] border border-border"> <Canvas edges={[]} fitViewOptions={{ maxZoom: 1, padding: 0.2 }} nodes={nodes} nodeTypes={nodeTypes} /> </div> );}<script setup lang="ts">import { Canvas } from "@/components/ai/canvas";import { Node, NodeContent, NodeDescription, NodeFooter, NodeHeader, NodeTitle } from "@/components/ai/node";
const levels = [{ value: "sunken", label: "Sunken (-1)" }, { value: "flat", label: "Flat (0)" }, { value: "raised", label: "Raised (+1)" }, { value: "floating", label: "Floating (+2)" }];
const nodes = levels.map(({ value, label }, i) => ({ id: value, type: "step", position: { x: (i % 2) * 290, y: Math.floor(i / 2) * 170 }, data: { elevation: value, label },}));
// Four nodes at zoom 1 with room around them.const fit = (flow: { fitView: (o: { maxZoom: number; padding: number }) => unknown }) => setTimeout(() => flow.fitView({ maxZoom: 1, padding: 0.2 }), 50);</script>
<template> <div class="h-[26rem] w-full overflow-hidden rounded-[calc(var(--radius)*1.4)] border border-border"> <Canvas :nodes="nodes" :edges="[]" :fit-view-on-init="false" @init="fit"> <template #node-step="{ data }"> <Node :handles="{ target: true, source: true }" :elevation="data.elevation"> <NodeHeader> <NodeTitle>Check drift</NodeTitle> <NodeDescription>{{ data.label }}</NodeDescription> </NodeHeader> <NodeContent> <span class="font-mono text-xs">MAG4 drift: <b>2.4%</b></span> </NodeContent> <NodeFooter>412 ms</NodeFooter> </Node> </template> </Canvas> </div></template><script lang="ts"> import { Canvas } from "$lib/components/ai/canvas"; import Step from "./_shared/ai-workflow-step.svelte";
const nodeTypes = { step: Step };
const levels = ["sunken", "flat", "raised", "floating"] as const; const labels = { sunken: "Sunken (-1)", flat: "Flat (0)", raised: "Raised (+1)", floating: "Floating (+2)" };
let nodes = $state.raw( levels.map((elevation, i) => ({ id: elevation, type: "step", position: { x: (i % 2) * 290, y: Math.floor(i / 2) * 170 }, data: { title: "Check drift", description: labels[elevation], body: "MAG4 drift: 2.4%", footer: "412 ms", elevation, handles: { target: true, source: true } }, })), ); let edges = $state.raw([]);</script>
<div style="height: 416px" class="w-full overflow-hidden rounded-[calc(var(--radius)*1.4)] border border-border"> <Canvas bind:nodes bind:edges {nodeTypes} fitViewOptions={{ maxZoom: 1, padding: 0.2 }} /></div>| Part | Prop | Type | Default |
|---|---|---|---|
| Node | handles |
{ target: boolean; source: boolean } |
|
| Node | selected |
boolean |
false |
| Node | elevation |
"auto" | "sunken" | "flat" | "raised" | "floating" |
"auto" |
| NodeHeader, NodeTitle, NodeDescription, NodeAction, NodeContent, NodeFooter | Card parts |