Skip to content
edmi-ui

Node

Workflow node card with header, content, footer and source/target handles.

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.

Built on card.

Selected nodes get the --ring border and a 3px ring. Inside xyflow the wrapper’s selected class does this automatically.

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

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-node
Terminal window
pnpm dlx shadcn-vue@latest add @edmi-ui/ai-node
Terminal window
yarn dlx shadcn-vue@latest add @edmi-ui/ai-node
Terminal window
bunx shadcn-vue@latest add @edmi-ui/ai-node

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-node.json
Terminal window
pnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-node.json
Terminal window
yarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-node.json
Terminal window
bunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-node.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";
<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>
registry/components/ai/node.tsx
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}
/>
);
registry/components/ai/node/Node.vue
<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>
registry/components/ai/node/NodeAction.vue
<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>
registry/components/ai/node/NodeContent.vue
<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>
registry/components/ai/node/NodeDescription.vue
<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>
registry/components/ai/node/NodeFooter.vue
<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>
registry/components/ai/node/NodeHeader.vue
<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>
registry/components/ai/node/NodeTitle.vue
<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>
registry/components/ai/node/index.ts
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";
src/lib/registry/ai/node/node.svelte
<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>
src/lib/registry/ai/node/node-action.svelte
<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>
src/lib/registry/ai/node/node-content.svelte
<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>
src/lib/registry/ai/node/node-description.svelte
<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>
src/lib/registry/ai/node/node-footer.svelte
<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>
src/lib/registry/ai/node/node-header.svelte
<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>
src/lib/registry/ai/node/node-title.svelte
<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>
src/lib/registry/ai/node/index.ts
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,
};

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.

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.
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