Getting started with React Flow UI

Update November 2025: We have updated the tutorial to use the latest version of shadcn/ui, on React 19 and Tailwind 4!

Update July 2025: “React Flow UI” was formerly known as “React Flow Components”. We renamed it because it now includes both components and templates. Additionally, since it’s built on shadcn/ui, the “UI” naming makes it easier for developers to recognize the connection and understand what we offer.

Recently, we launched an exciting new addition to our open-source roster: React Flow UI (Previously known as React Flow Components). These are pre-built nodes, edges, and other ui elements that you can quickly add to your React Flow applications to get up and running. The catch is these components are built on top of shadcn/ui and the shadcn CLI.

We’ve previously written about our experience and what led us to choosing shadcn over on the xyflow blog, but in this tutorial we’re going to focus on how to get started from scratch with shadcn, Tailwind CSS, and React Flow Components.

**Wait, what's shadcn?**

No what, who! Shadcn is the author of a collection of pre-designed components known as shadcn/ui. Notice how we didn’t say library there? Shadcn takes a different approach where components are added to your project’s source code and are “owned” by you: once you add a component you’re free to modify it to suit your needs!

Getting started

To begin with, we will:

Setting up a new vite project
pnpm create vite@latest

Vite is able to scaffold projects for many popular frameworks, but we only care about React! Additionally, make sure to set up a TypeScript project. React Flow’s documentation is a mix of JavaScript and TypeScript, but for shadcn components TypeScript is required!

During the interactive setup, select React and TypeScript:

◇  Project name:
│  my-react-flow-app
│
◇  Select a framework:
│  React
│
◇  Select a variant:
│  TypeScript
│
◇  Use rolldown-vite (Experimental)?:
│  No
│
◇  Install with pnpm and start now?
│  Yes
│
◇  Scaffolding project in /Users/alessandro/src/xyflow/wip/component-style-test-2...
│
◇  Installing dependencies with pnpm...
Setting up Tailwind CSS

All shadcn and React Flow components are styled with Tailwind CSS, so we’ll need to install that and a few other dependencies next.

We can follow the instructions in the shadcn installation guide to install shadcn and Tailwind CSS inside of a freshly scaffolded vite project.

pnpm install tailwindcss @tailwindcss/vite

It is now a lot simpler to set up Tailwind CSS in a vite project, and Tailwind 4 is configured completely in CSS. You can just replace the generated src/index.css file with this one line:

@import 'tailwindcss';
Importing Tailwind CSS as a Vite plugin

Starting with Tailwind CSS v4, you can use the dedicated Vite plugin @tailwindcss/vite rather than the traditional PostCSS plugin. This plugin is configured in our vite.config.ts file, and makes things a lot simpler, both for us developers, and for the compilers.

We simply need to import the plugin and add it to the plugins array in our vite.config.ts file. We also need to add the alias property to the resolve object to tell Vite where to find our source files, as shadcn components use the @ alias to refer to the src directory.

import path from 'path';
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
 
// https://vite.dev/config/
export default defineConfig({
  plugins: [react(), tailwindcss()],
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
    },
  },
});
Importing the Tailwind CSS file

We now need to make sure that the only CSS file in our project is the Tailwind CSS file. In the generated App.tsx, you can safely remove the import of the App.css file, and remove everything else that is in the scaffolded App.tsx file.

To verify that Tailwind CSS is working, we can add a simple div and h1 elements with Tailwind classes.

The updated App.tsx file should look like this:

export default function App() {
  return (
    <div className="w-screen h-screen p-8">
      <h1 className="text-2xl font-bold">Hello World</h1>
    </div>
  );
}

And, the main.tsx file should look like this:

import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App.tsx';
 
createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
  </StrictMode>,
);

If you updated your index.css file and configured Vite to use the Tailwind CSS plugin, you should be able to run the project and see the “Hello World” message in your browser, in a nice, large, bold font.

The classes `w-screen` and `h-screen` are two examples of Tailwind's utility classes. If you're used to styling React apps using a different approach, you might find this a bit strange at first. You can think of Tailwind classes as supercharged inline styles: they're constrained to a set design system and you have access to responsive media queries or pseudo-classes like `hover` and `focus`.
Setting up shadcn/ui

Vite scaffolds some tsconfig files for us when generating a TypeScript project and we’ll need to make some changes to these so the shadcn components can work correctly. The shadcn CLI is pretty clever (we’ll get to that in a second) but it can’t account for every project structure so instead shadcn components that depend on one another make use of TypeScript’s import paths.

The current version of Vite splits TypeScript configuration into three files, two of which need to be edited. Add the baseUrl and paths properties to the compilerOptions section of the tsconfig.json and tsconfig.app.json files:

{
  // ...
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  }
  // ...
}
{
  "compilerOptions": {
    // ...
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
    // ...
  }
}

Nice! Now we’re ready to set up the shadcn/ui CLI and add our first components. Once the CLI is set up, we’ll be able to add new components to our project with a single command - even if they have dependencies or need to modify existing files!

We can now run the following command to set up shadcn/ui in our project:

npx shadcn@latest init

The CLI will perform a few tasks, first it will identify your project’s framework, tailwind version, and then ask you what color you would like to use as the base color for your project. It will then update your index.css file and generate a components.json file in the root of your project, which will be shadcn’s main configuration points.

We can take all the default options for now

✔ Preflight checks.
✔ Verifying framework. Found Vite.
✔ Validating Tailwind CSS config. Found v4.
✔ Validating import alias.
✔ Which color would you like to use as the base color? › Neutral
✔ Writing components.json.
✔ Checking registry.
✔ Updating CSS variables in src/index.css
✔ Installing dependencies.
✔ Created 1 file:
  - src/lib/utils.ts

Success! Project initialization completed.
You may now add components.

Installing React Flow and importing its CSS.

Now we can install React Flow and import its CSS.

pnpm install @xyflow/react

And then import its CSS in our App.tsx file:

import '@xyflow/react/dist/style.css';
 
export default function App() {
  return (
    <div className="w-screen h-screen p-8">
      <h1 className="text-2xl font-bold">Hello World</h1>
    </div>
  );
}

Adding your first components

To demonstrate how powerful shadcn can be, let’s dive right into making a new React Flow app! Now everything is set up, we can add the <BaseNode /> component with a single command:

npx shadcn@latest add https://ui.reactflow.dev/base-node

This command will generate a new file src/components/base-node.tsx, and install the necessary dependencies.

That <BaseNode /> component is not a React Flow node directly. Instead, as the name implies, it’s a base that many of our other nodes build upon. It also comes with additional components that you can use to provide a header and content for your nodes. These components are:

  • <BaseNodeHeader />
  • <BaseNodeHeaderTitle />
  • <BaseNodeContent />
  • <BaseNodeFooter />

You can use it to have a unified style for all of your nodes as well. Let’s see what it looks like by updating our App.tsx file:

import '@xyflow/react/dist/style.css';
 
import {
  BaseNode,
  BaseNodeContent,
  BaseNodeHeader,
  BaseNodeHeaderTitle,
} from '@/components/base-node';
 
export default function App() {
  return (
    <div className="w-screen h-screen p-8">
      <BaseNode>
        <BaseNodeHeader>
          <BaseNodeHeaderTitle>Base Node</BaseNodeHeaderTitle>
        </BaseNodeHeader>
        <BaseNodeContent>
          This is a base node component that can be used to build other nodes.
        </BaseNodeContent>
      </BaseNode>
    </div>
  );
}
 

Ok, not super exciting…

A screenshot of a simple React application. It renders one element, a
rounded container with a blue border and the text 'Hi! 👋' inside.

The <BaseNode /> component is one of the most used components in our UI components registry. Some components may use it internally, to create custom nodes with a consistent style, while some other components can be used in combination with it to create more complex nodes.

For example, let’s add the <NodeTooltip /> component to our project, to display a tooltip when hovering over a node.

npx shadcn@latest add https://ui.reactflow.dev/node-tooltip

And we’ll update our App.tsx file to render a proper flow. We’ll use the same basic setup as most of our examples so we won’t break down the individual pieces here. If you’re still new to React Flow and want to learn a bit more about how to set up a basic flow from scratch, check out our quickstart guide.

{/* TODO this could be linked to example app with RemoteCodeViewer editor */}

import { Position, ReactFlow, useNodesState, type Node } from '@xyflow/react';
 
import '@xyflow/react/dist/style.css';
 
import { BaseNode, BaseNodeContent } from '@/components/base-node';
import {
  NodeTooltip,
  NodeTooltipContent,
  NodeTooltipTrigger,
} from '@/components/node-tooltip';
 
function Tooltip() {
  return (
    <NodeTooltip>
      <NodeTooltipContent position={Position.Top}>Hidden Content</NodeTooltipContent>
      <BaseNode>
        <BaseNodeContent>
          <NodeTooltipTrigger>Hover</NodeTooltipTrigger>
        </BaseNodeContent>
      </BaseNode>
    </NodeTooltip>
  );
}
 
const nodeTypes = {
  tooltip: Tooltip,
};
 
const initialNodes: Node[] = [
  {
    id: '1',
    position: { x: 0, y: 0 },
    data: {},
    type: 'tooltip',
  },
];
 
function Flow() {
  const [nodes, , onNodesChange] = useNodesState(initialNodes);
 
  return (
    <div className="h-screen w-screen p-8 bg-gray-50 rounded-xl">
      <ReactFlow
        nodes={nodes}
        nodeTypes={nodeTypes}
        onNodesChange={onNodesChange}
        fitView
      />
    </div>
  );
}
 
export default function App() {
  return <Flow />;
}

And would you look at that, the tooltip node we added automatically uses the <BaseNode /> component we customized!

Example: tutorials/components/tooltip

App.tsx
import { Position, ReactFlow, useNodesState, type Node } from '@xyflow/react';
 
import '@xyflow/react/dist/style.css';
 
import { BaseNode, BaseNodeContent } from './components/base-node';
import {
  NodeTooltip,
  NodeTooltipContent,
  NodeTooltipTrigger,
} from './components/node-tooltip';
 
function Tooltip() {
  return (
    <NodeTooltip>
      <NodeTooltipContent position={Position.Top}>Hidden Content</NodeTooltipContent>
      <BaseNode>
        <BaseNodeContent>
          <NodeTooltipTrigger>Hover</NodeTooltipTrigger>
        </BaseNodeContent>
      </BaseNode>
    </NodeTooltip>
  );
}
 
const nodeTypes = {
  tooltip: Tooltip,
};
 
const initialNodes: Node[] = [
  {
    id: '1',
    position: { x: 0, y: 0 },
    data: {},
    type: 'tooltip',
  },
];
 
function Flow() {
  const [nodes, , onNodesChange] = useNodesState(initialNodes);
 
  return (
    <div className="h-screen w-screen rounded-xl bg-gray-50 p-8">
      <ReactFlow
        nodes={nodes}
        nodeTypes={nodeTypes}
        onNodesChange={onNodesChange}
        fitView
      />
    </div>
  );
}
 
export default function App() {
  return <Flow />;
}
index.css
@import 'tailwindcss';
 
html,
body {
  margin: 0;
  font-family: sans-serif;
}
 
#app {
  width: 100vw;
  height: 100vh;
}
 
@custom-variant dark (&:is(.dark *));
 
@theme inline {
  --radius-sm: calc(var(--radius) - 4px);
  --radius-md: calc(var(--radius) - 2px);
  --radius-lg: var(--radius);
  --radius-xl: calc(var(--radius) + 4px);
  --color-background: var(--background);
  --color-foreground: var(--foreground);
  --color-card: var(--card);
  --color-card-foreground: var(--card-foreground);
  --color-popover: var(--popover);
  --color-popover-foreground: var(--popover-foreground);
  --color-primary: var(--primary);
  --color-primary-foreground: var(--primary-foreground);
  --color-secondary: var(--secondary);
  --color-secondary-foreground: var(--secondary-foreground);
  --color-muted: var(--muted);
  --color-muted-foreground: var(--muted-foreground);
  --color-accent: var(--accent);
  --color-accent-foreground: var(--accent-foreground);
  --color-destructive: var(--destructive);
  --color-border: var(--border);
  --color-input: var(--input);
  --color-ring: var(--ring);
  --color-chart-1: var(--chart-1);
  --color-chart-2: var(--chart-2);
  --color-chart-3: var(--chart-3);
  --color-chart-4: var(--chart-4);
  --color-chart-5: var(--chart-5);
  --color-sidebar: var(--sidebar);
  --color-sidebar-foreground: var(--sidebar-foreground);
  --color-sidebar-primary: var(--sidebar-primary);
  --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
  --color-sidebar-accent: var(--sidebar-accent);
  --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
  --color-sidebar-border: var(--sidebar-border);
  --color-sidebar-ring: var(--sidebar-ring);
}
 
:root {
  --radius: 0.625rem;
  --background: oklch(1 0 0);
  --foreground: oklch(0.145 0 0);
  --card: oklch(1 0 0);
  --card-foreground: oklch(0.145 0 0);
  --popover: oklch(1 0 0);
  --popover-foreground: oklch(0.145 0 0);
  --primary: oklch(0.205 0 0);
  --primary-foreground: oklch(0.985 0 0);
  --secondary: oklch(0.97 0 0);
  --secondary-foreground: oklch(0.205 0 0);
  --muted: oklch(0.97 0 0);
  --muted-foreground: oklch(0.556 0 0);
  --accent: oklch(0.97 0 0);
  --accent-foreground: oklch(0.205 0 0);
  --destructive: oklch(0.577 0.245 27.325);
  --border: oklch(0.922 0 0);
  --input: oklch(0.922 0 0);
  --ring: oklch(0.708 0 0);
  --chart-1: oklch(0.646 0.222 41.116);
  --chart-2: oklch(0.6 0.118 184.704);
  --chart-3: oklch(0.398 0.07 227.392);
  --chart-4: oklch(0.828 0.189 84.429);
  --chart-5: oklch(0.769 0.188 70.08);
  --sidebar: oklch(0.985 0 0);
  --sidebar-foreground: oklch(0.145 0 0);
  --sidebar-primary: oklch(0.205 0 0);
  --sidebar-primary-foreground: oklch(0.985 0 0);
  --sidebar-accent: oklch(0.97 0 0);
  --sidebar-accent-foreground: oklch(0.205 0 0);
  --sidebar-border: oklch(0.922 0 0);
  --sidebar-ring: oklch(0.708 0 0);
}
 
.dark {
  --background: oklch(0.145 0 0);
  --foreground: oklch(0.985 0 0);
  --card: oklch(0.205 0 0);
  --card-foreground: oklch(0.985 0 0);
  --popover: oklch(0.205 0 0);
  --popover-foreground: oklch(0.985 0 0);
  --primary: oklch(0.922 0 0);
  --primary-foreground: oklch(0.205 0 0);
  --secondary: oklch(0.269 0 0);
  --secondary-foreground: oklch(0.985 0 0);
  --muted: oklch(0.269 0 0);
  --muted-foreground: oklch(0.708 0 0);
  --accent: oklch(0.269 0 0);
  --accent-foreground: oklch(0.985 0 0);
  --destructive: oklch(0.704 0.191 22.216);
  --border: oklch(1 0 0 / 10%);
  --input: oklch(1 0 0 / 15%);
  --ring: oklch(0.556 0 0);
  --chart-1: oklch(0.488 0.243 264.376);
  --chart-2: oklch(0.696 0.17 162.48);
  --chart-3: oklch(0.769 0.188 70.08);
  --chart-4: oklch(0.627 0.265 303.9);
  --chart-5: oklch(0.645 0.246 16.439);
  --sidebar: oklch(0.205 0 0);
  --sidebar-foreground: oklch(0.985 0 0);
  --sidebar-primary: oklch(0.488 0.243 264.376);
  --sidebar-primary-foreground: oklch(0.985 0 0);
  --sidebar-accent: oklch(0.269 0 0);
  --sidebar-accent-foreground: oklch(0.985 0 0);
  --sidebar-border: oklch(1 0 0 / 10%);
  --sidebar-ring: oklch(0.556 0 0);
}
 
@layer base {
  * {
    @apply border-border outline-ring/50;
  }
  body {
    @apply bg-background text-foreground;
  }
}
index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>React Flow Example</title>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="./index.tsx"></script>
  </body>
</html>
index.tsx
import { createRoot } from 'react-dom/client';
import App from './App';
 
import './index.css';
 
const container = document.querySelector('#app');
const root = createRoot(container);
 
root.render(<App />);
components/base-node.tsx
import type { ComponentProps } from 'react';
 
import { cn } from '../lib/utils';
 
export function BaseNode({ className, ...props }: ComponentProps<'div'>) {
  return (
    <div
      className={cn(
        'bg-card text-card-foreground relative rounded-md border',
        'hover:ring-1',
        // React Flow displays node elements inside of a `NodeWrapper` component,
        // which compiles down to a div with the class `react-flow__node`.
        // When a node is selected, the class `selected` is added to the
        // `react-flow__node` element. This allows us to style the node when it
        // is selected, using Tailwind's `&` selector.
        '[.react-flow\\_\\_node.selected_&]:border-muted-foreground',
        '[.react-flow\\_\\_node.selected_&]:shadow-lg',
        className,
      )}
      tabIndex={0}
      {...props}
    />
  );
}
 
/**
 * A container for a consistent header layout intended to be used inside the
 * `<BaseNode />` component.
 */
export function BaseNodeHeader({ className, ...props }: ComponentProps<'header'>) {
  return (
    <header
      {...props}
      className={cn(
        'mx-0 my-0 -mb-1 flex flex-row items-center justify-between gap-2 px-3 py-2',
        // Remove or modify these classes if you modify the padding in the
        // `<BaseNode />` component.
        className,
      )}
    />
  );
}
 
/**
 * The title text for the node. To maintain a native application feel, the title
 * text is not selectable.
 */
export function BaseNodeHeaderTitle({ className, ...props }: ComponentProps<'h3'>) {
  return (
    <h3
      data-slot="base-node-title"
      className={cn('user-select-none flex-1 font-semibold', className)}
      {...props}
    />
  );
}
 
export function BaseNodeContent({ className, ...props }: ComponentProps<'div'>) {
  return (
    <div
      data-slot="base-node-content"
      className={cn('flex flex-col gap-y-2 p-3', className)}
      {...props}
    />
  );
}
 
export function BaseNodeFooter({ className, ...props }: ComponentProps<'div'>) {
  return (
    <div
      data-slot="base-node-footer"
      className={cn(
        'flex flex-col items-center gap-y-2 border-t px-3 pb-3 pt-2',
        className,
      )}
      {...props}
    />
  );
}
components/node-tooltip.tsx
'use client';
 
import {
  createContext,
  useCallback,
  useContext,
  useState,
  type ComponentProps,
} from 'react';
import { NodeToolbar, type NodeToolbarProps } from '@xyflow/react';
 
import { cn } from '../lib/utils';
 
/* TOOLTIP CONTEXT ---------------------------------------------------------- */
 
type TooltipContextType = {
  isVisible: boolean;
  showTooltip: () => void;
  hideTooltip: () => void;
};
 
const TooltipContext = createContext<TooltipContextType | null>(null);
 
/* TOOLTIP NODE ------------------------------------------------------------- */
 
export function NodeTooltip({ children }: ComponentProps<'div'>) {
  const [isVisible, setIsVisible] = useState(false);
 
  const showTooltip = useCallback(() => setIsVisible(true), []);
  const hideTooltip = useCallback(() => setIsVisible(false), []);
 
  return (
    <TooltipContext.Provider value={{ isVisible, showTooltip, hideTooltip }}>
      <div>{children}</div>
    </TooltipContext.Provider>
  );
}
 
/* TOOLTIP TRIGGER ---------------------------------------------------------- */
 
export function NodeTooltipTrigger(props: ComponentProps<'div'>) {
  const tooltipContext = useContext(TooltipContext);
  if (!tooltipContext) {
    throw new Error('NodeTooltipTrigger must be used within NodeTooltip');
  }
  const { showTooltip, hideTooltip } = tooltipContext;
 
  const onMouseEnter = useCallback(
    (e: React.MouseEvent<HTMLDivElement>) => {
      props.onMouseEnter?.(e);
      showTooltip();
    },
    [props, showTooltip],
  );
 
  const onMouseLeave = useCallback(
    (e: React.MouseEvent<HTMLDivElement>) => {
      props.onMouseLeave?.(e);
      hideTooltip();
    },
    [props, hideTooltip],
  );
 
  return <div onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} {...props} />;
}
 
/* TOOLTIP CONTENT ---------------------------------------------------------- */
 
// /**
//  * A component that displays the tooltip content based on visibility context.
//  */
 
export function NodeTooltipContent({
  children,
  position,
  className,
  ...props
}: NodeToolbarProps) {
  const tooltipContext = useContext(TooltipContext);
  if (!tooltipContext) {
    throw new Error('NodeTooltipContent must be used within NodeTooltip');
  }
  const { isVisible } = tooltipContext;
 
  return (
    <div>
      <NodeToolbar
        isVisible={isVisible}
        className={cn('bg-primary text-primary-foreground rounded-sm p-2', className)}
        tabIndex={1}
        position={position}
        {...props}
      >
        {children}
      </NodeToolbar>
    </div>
  );
}
lib/utils.ts
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
 
export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

Moving fast and making things

Now we’ve got a basic understanding of how shadcn/ui and the CLI works, we can begin to see how easy it is to add new components and build out a flow. To see everything React Flow Components has to offer let’s build out a simple calculator flow.

First let’s remove the <NodeTooltip /> and undo our changes to <BaseNode />. In addition to pre-made nodes, React Flow UI also contains building blocks for creating your own custom nodes. To see them, we’ll add the labeled-handle component:

npx shadcn@latest add https://ui.reactflow.dev/labeled-handle
The Number Node

The first node we’ll create is a simple number node with some buttons to increment and decrement the value and a handle to connect it to other nodes. Create a folder src/components/nodes and then add a new file src/components/nodes/num-node.tsx.

We need to install the following shadcn/ui components:

npx shadcn@latest add dropdown-menu button

Now we can start building the node. We will need to access the updateNodeData function to update the node’s data and the setNodes function to delete the node, from the useReactFlow hook. The hook helps us make self-contained components that can be used in other parts of our application, while still giving us quick access to React Flow’s state and functions.

We will need to make four callbacks, to handle the different actions that can be performed on the node.

  • Reset the node’s value to 0
  • Delete the node
  • Increment the node’s value by 1
  • Decrement the node’s value by 1

We will also need to access the node’s data to get the current value and update it.

import { type Node, type NodeProps, Position, useReactFlow } from '@xyflow/react';
import { useCallback } from 'react';
 
import {
  BaseNode,
  BaseNodeContent,
  BaseNodeFooter,
  BaseNodeHeader,
  BaseNodeHeaderTitle,
} from '../base-node';
import { LabeledHandle } from '../labeled-handle';
 
import { EllipsisVertical } from 'lucide-react';
import { Button } from '../ui/button';
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuTrigger,
} from '../ui/dropdown-menu';
 
export type NumNode = Node<{
  value: number;
}>;
 
export function NumNode({ id, data }: NodeProps<NumNode>) {
  const { updateNodeData, setNodes } = useReactFlow();
 
  const handleReset = useCallback(() => {
    updateNodeData(id, { value: 0 });
  }, [id, updateNodeData]);
 
  const handleDelete = useCallback(() => {
    setNodes((nodes) => nodes.filter((node) => node.id !== id));
  }, [id, setNodes]);
 
  const handleIncr = useCallback(() => {
    updateNodeData(id, { value: data.value + 1 });
  }, [id, data.value, updateNodeData]);
 
  const handleDecr = useCallback(() => {
    updateNodeData(id, { value: data.value - 1 });
  }, [id, data.value, updateNodeData]);
 
  return (
    <BaseNode>
      <BaseNodeHeader className="border-b">
        <BaseNodeHeaderTitle>Num</BaseNodeHeaderTitle>
 
        <DropdownMenu>
          <DropdownMenuTrigger asChild>
            <Button
              variant="ghost"
              className="nodrag p-1"
              aria-label="Node Actions"
              title="Node Actions"
            >
              <EllipsisVertical className="size-4" />
            </Button>
          </DropdownMenuTrigger>
          <DropdownMenuContent>
            <DropdownMenuLabel className="font-bold">Node Actions</DropdownMenuLabel>
            <DropdownMenuItem onSelect={handleReset}>Reset</DropdownMenuItem>
            <DropdownMenuItem onSelect={handleDelete}>Delete</DropdownMenuItem>
          </DropdownMenuContent>
        </DropdownMenu>
      </BaseNodeHeader>
 
      <BaseNodeContent>
        <div className="flex gap-2 items-center">
          <Button onClick={handleDecr}>-</Button>
          <pre>{String(data.value).padStart(3, ' ')}</pre>
          <Button onClick={handleIncr}>+</Button>
        </div>
      </BaseNodeContent>
 
      <BaseNodeFooter className="bg-card items-end px-0 py-1 w-full  rounded-b-md">
        <LabeledHandle title="out" type="source" position={Position.Right} />
      </BaseNodeFooter>
    </BaseNode>
  );
}
The Sum Node

The second node we can create is a simple sum node that adds the values of the two input nodes. Create a new file src/components/nodes/sum-node.tsx and paste the following into it:

Particularly, we will need to access the getNodeConnections function to get the values of the two connected input nodes and the updateNodeData function to update the node’s data with the sum of the two input nodes inside of a useEffect hook, whenever one of the values of the input nodes changes.

import {
  type Node,
  type NodeProps,
  Position,
  useReactFlow,
  useStore,
} from '@xyflow/react';
import { useCallback, useEffect } from 'react';
 
import {
  BaseNode,
  BaseNodeContent,
  BaseNodeFooter,
  BaseNodeHeader,
  BaseNodeHeaderTitle,
} from '../base-node';
import { LabeledHandle } from '../labeled-handle';
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuTrigger,
} from '../ui/dropdown-menu';
import { EllipsisVertical } from 'lucide-react';
import { Button } from '../ui/button';
 
export type SumNode = Node<{
  value: number;
}>;
 
export function SumNode({ id }: NodeProps<SumNode>) {
  const { updateNodeData, getNodeConnections, setNodes, setEdges } = useReactFlow();
  const { x, y } = useStore((state) => ({
    x: getHandleValue(
      getNodeConnections({ nodeId: id, handleId: 'x', type: 'target' }),
      state.nodeLookup,
    ),
    y: getHandleValue(
      getNodeConnections({ nodeId: id, handleId: 'y', type: 'target' }),
      state.nodeLookup,
    ),
  }));
 
  const handleDelete = useCallback(() => {
    setNodes((nodes) => nodes.filter((node) => node.id !== id));
    setEdges((edges) => edges.filter((edge) => edge.source !== id));
  }, [id, setNodes, setEdges]);
 
  useEffect(() => {
    updateNodeData(id, { value: x + y });
  }, [x, y]);
 
  return (
    <BaseNode className="w-32">
      <BaseNodeHeader className="border-b">
        <BaseNodeHeaderTitle>Sum</BaseNodeHeaderTitle>
 
        <DropdownMenu>
          <DropdownMenuTrigger asChild>
            <Button
              variant="ghost"
              className="nodrag p-1"
              aria-label="Node Actions"
              title="Node Actions"
            >
              <EllipsisVertical className="size-4" />
            </Button>
          </DropdownMenuTrigger>
          <DropdownMenuContent>
            <DropdownMenuLabel className="font-bold">Node Actions</DropdownMenuLabel>
            <DropdownMenuItem onSelect={handleDelete}>Delete</DropdownMenuItem>
          </DropdownMenuContent>
        </DropdownMenu>
      </BaseNodeHeader>
 
      <BaseNodeContent className="px-0">
        <LabeledHandle title="x" id="x" type="target" position={Position.Left} />
        <LabeledHandle title="y" id="y" type="target" position={Position.Left} />
      </BaseNodeContent>
      <BaseNodeFooter className="bg-card items-end px-0 py-1 w-full rounded-b-md">
        <LabeledHandle title="out" type="source" position={Position.Right} />
      </BaseNodeFooter>
    </BaseNode>
  );
}
 
function getHandleValue(
  connections: Array<{ source: string }>,
  lookup: Map<string, Node<any>>,
) {
  return connections.reduce((acc, { source }) => {
    const node = lookup.get(source)!;
    const value = node.data.value;
 
    return typeof value === 'number' ? acc + value : acc;
  }, 0);
}
The Data Edge

React Flow UI doesn’t just provide components for building nodes. We also provide pre-built edges and other UI elements you can drop into your flows for quick building.

To better visualize data in our calculator flow, let’s pull in the data-edge component. This edge renders a field from the source node’s data object as a label on the edge itself. Add the data-edge component to your project:

npx shadcn@latest add https://ui.reactflow.dev/data-edge

The <DataEdge /> component works by looking up a field from its source node’s data object. We’ve been storing the value of each node in our calculator field in a "value" property so we’ll update our edgeType object to include the new data-edge and we’ll update the onConnect handler to create a new edge of this type, making sure to set the edge’s data object correctly:

The Flow

Now we can put everything together and create our flow.

We will start by defining the custom node and edge types, and the initial nodes and edges that will be displayed in our app.

import { useCallback } from 'react';
import {
  ReactFlow,
  type Node,
  type Edge,
  type OnConnect,
  addEdge,
  useNodesState,
  useEdgesState,
} from '@xyflow/react';
 
import { NumNode } from './components/nodes/num-node';
import { SumNode } from './components/nodes/sum-node';
 
import { DataEdge } from './components/data-edge';
 
import '@xyflow/react/dist/style.css';
 
const nodeTypes = {
  num: NumNode,
  sum: SumNode,
};
 
const initialNodes: Node[] = [
  { id: 'a', type: 'num', data: { value: 0 }, position: { x: 0, y: 0 } },
  { id: 'b', type: 'num', data: { value: 0 }, position: { x: 0, y: 200 } },
  { id: 'c', type: 'sum', data: { value: 0 }, position: { x: 300, y: 100 } },
  { id: 'd', type: 'num', data: { value: 0 }, position: { x: 0, y: 400 } },
  { id: 'e', type: 'sum', data: { value: 0 }, position: { x: 600, y: 400 } },
];
 
const edgeTypes = {
  data: DataEdge,
};
 
const initialEdges: Edge[] = [
  {
    id: 'a->c',
    type: 'data',
    data: { key: 'value' },
    source: 'a',
    target: 'c',
    targetHandle: 'x',
  },
  {
    id: 'b->c',
    type: 'data',
    data: { key: 'value' },
    source: 'b',
    target: 'c',
    targetHandle: 'y',
  },
  {
    id: 'c->e',
    type: 'data',
    data: { key: 'value' },
    source: 'c',
    target: 'e',
    targetHandle: 'x',
  },
  {
    id: 'd->e',
    type: 'data',
    data: { key: 'value' },
    source: 'd',
    target: 'e',
    targetHandle: 'y',
  },
];
 
function Flow() {
  const [nodes, , onNodesChange] = useNodesState(initialNodes);
  const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
 
  const onConnect: OnConnect = useCallback(
    (params) => {
      setEdges((edges) =>
        addEdge({ type: 'data', data: { key: 'value' }, ...params }, edges),
      );
    },
    [setEdges],
  );
 
  return (
    <div className="h-screen w-screen p-8 bg-gray-50 rounded-xl">
      <ReactFlow
        nodes={nodes}
        edges={edges}
        onNodesChange={onNodesChange}
        onEdgesChange={onEdgesChange}
        onConnect={onConnect}
        nodeTypes={nodeTypes}
        edgeTypes={edgeTypes}
        fitView
      />
    </div>
  );
}
 
export default function App() {
  return <Flow />;
}

Putting everything together we end up with quite a capable little calculator!

Example: tutorials/components/complete

App.tsx
import { useCallback } from 'react';
import {
  ReactFlow,
  type Node,
  type Edge,
  type OnConnect,
  addEdge,
  useNodesState,
  useEdgesState,
} from '@xyflow/react';
 
import { NumNode } from './components/nodes/num-node';
import { SumNode } from './components/nodes/sum-node';
 
import { DataEdge } from './components/data-edge';
 
import '@xyflow/react/dist/style.css';
 
const nodeTypes = {
  num: NumNode,
  sum: SumNode,
};
 
const initialNodes: Node[] = [
  { id: 'a', type: 'num', data: { value: 0 }, position: { x: 0, y: 0 } },
  { id: 'b', type: 'num', data: { value: 0 }, position: { x: 0, y: 200 } },
  { id: 'c', type: 'sum', data: { value: 0 }, position: { x: 300, y: 100 } },
  { id: 'd', type: 'num', data: { value: 0 }, position: { x: 0, y: 400 } },
  { id: 'e', type: 'sum', data: { value: 0 }, position: { x: 600, y: 400 } },
];
 
const edgeTypes = {
  data: DataEdge,
};
 
const initialEdges: Edge[] = [
  {
    id: 'a->c',
    type: 'data',
    data: { key: 'value' },
    source: 'a',
    target: 'c',
    targetHandle: 'x',
  },
  {
    id: 'b->c',
    type: 'data',
    data: { key: 'value' },
    source: 'b',
    target: 'c',
    targetHandle: 'y',
  },
  {
    id: 'c->e',
    type: 'data',
    data: { key: 'value' },
    source: 'c',
    target: 'e',
    targetHandle: 'x',
  },
  {
    id: 'd->e',
    type: 'data',
    data: { key: 'value' },
    source: 'd',
    target: 'e',
    targetHandle: 'y',
  },
];
 
function Flow() {
  const [nodes, , onNodesChange] = useNodesState(initialNodes);
  const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
 
  const onConnect: OnConnect = useCallback(
    (params) => {
      setEdges((edges) =>
        addEdge({ type: 'data', data: { key: 'value' }, ...params }, edges),
      );
    },
    [setEdges],
  );
 
  return (
    <div className="h-screen w-screen rounded-xl bg-gray-50 p-8">
      <ReactFlow
        nodes={nodes}
        edges={edges}
        onNodesChange={onNodesChange}
        onEdgesChange={onEdgesChange}
        onConnect={onConnect}
        nodeTypes={nodeTypes}
        edgeTypes={edgeTypes}
        fitView
      />
    </div>
  );
}
 
export function App() {
  return <Flow />;
}
index.css
@import 'tailwindcss';
 
html,
body {
  margin: 0;
  font-family: sans-serif;
}
 
#app {
  width: 100vw;
  height: 100vh;
}
 
@custom-variant dark (&:is(.dark *));
 
@theme inline {
  --radius-sm: calc(var(--radius) - 4px);
  --radius-md: calc(var(--radius) - 2px);
  --radius-lg: var(--radius);
  --radius-xl: calc(var(--radius) + 4px);
  --color-background: var(--background);
  --color-foreground: var(--foreground);
  --color-card: var(--card);
  --color-card-foreground: var(--card-foreground);
  --color-popover: var(--popover);
  --color-popover-foreground: var(--popover-foreground);
  --color-primary: var(--primary);
  --color-primary-foreground: var(--primary-foreground);
  --color-secondary: var(--secondary);
  --color-secondary-foreground: var(--secondary-foreground);
  --color-muted: var(--muted);
  --color-muted-foreground: var(--muted-foreground);
  --color-accent: var(--accent);
  --color-accent-foreground: var(--accent-foreground);
  --color-destructive: var(--destructive);
  --color-border: var(--border);
  --color-input: var(--input);
  --color-ring: var(--ring);
  --color-chart-1: var(--chart-1);
  --color-chart-2: var(--chart-2);
  --color-chart-3: var(--chart-3);
  --color-chart-4: var(--chart-4);
  --color-chart-5: var(--chart-5);
  --color-sidebar: var(--sidebar);
  --color-sidebar-foreground: var(--sidebar-foreground);
  --color-sidebar-primary: var(--sidebar-primary);
  --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
  --color-sidebar-accent: var(--sidebar-accent);
  --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
  --color-sidebar-border: var(--sidebar-border);
  --color-sidebar-ring: var(--sidebar-ring);
}
 
:root {
  --radius: 0.625rem;
  --background: oklch(1 0 0);
  --foreground: oklch(0.145 0 0);
  --card: oklch(1 0 0);
  --card-foreground: oklch(0.145 0 0);
  --popover: oklch(1 0 0);
  --popover-foreground: oklch(0.145 0 0);
  --primary: oklch(0.205 0 0);
  --primary-foreground: oklch(0.985 0 0);
  --secondary: oklch(0.97 0 0);
  --secondary-foreground: oklch(0.205 0 0);
  --muted: oklch(0.97 0 0);
  --muted-foreground: oklch(0.556 0 0);
  --accent: oklch(0.97 0 0);
  --accent-foreground: oklch(0.205 0 0);
  --destructive: oklch(0.577 0.245 27.325);
  --border: oklch(0.922 0 0);
  --input: oklch(0.922 0 0);
  --ring: oklch(0.708 0 0);
  --chart-1: oklch(0.646 0.222 41.116);
  --chart-2: oklch(0.6 0.118 184.704);
  --chart-3: oklch(0.398 0.07 227.392);
  --chart-4: oklch(0.828 0.189 84.429);
  --chart-5: oklch(0.769 0.188 70.08);
  --sidebar: oklch(0.985 0 0);
  --sidebar-foreground: oklch(0.145 0 0);
  --sidebar-primary: oklch(0.205 0 0);
  --sidebar-primary-foreground: oklch(0.985 0 0);
  --sidebar-accent: oklch(0.97 0 0);
  --sidebar-accent-foreground: oklch(0.205 0 0);
  --sidebar-border: oklch(0.922 0 0);
  --sidebar-ring: oklch(0.708 0 0);
}
 
.dark {
  --background: oklch(0.145 0 0);
  --foreground: oklch(0.985 0 0);
  --card: oklch(0.205 0 0);
  --card-foreground: oklch(0.985 0 0);
  --popover: oklch(0.205 0 0);
  --popover-foreground: oklch(0.985 0 0);
  --primary: oklch(0.922 0 0);
  --primary-foreground: oklch(0.205 0 0);
  --secondary: oklch(0.269 0 0);
  --secondary-foreground: oklch(0.985 0 0);
  --muted: oklch(0.269 0 0);
  --muted-foreground: oklch(0.708 0 0);
  --accent: oklch(0.269 0 0);
  --accent-foreground: oklch(0.985 0 0);
  --destructive: oklch(0.704 0.191 22.216);
  --border: oklch(1 0 0 / 10%);
  --input: oklch(1 0 0 / 15%);
  --ring: oklch(0.556 0 0);
  --chart-1: oklch(0.488 0.243 264.376);
  --chart-2: oklch(0.696 0.17 162.48);
  --chart-3: oklch(0.769 0.188 70.08);
  --chart-4: oklch(0.627 0.265 303.9);
  --chart-5: oklch(0.645 0.246 16.439);
  --sidebar: oklch(0.205 0 0);
  --sidebar-foreground: oklch(0.985 0 0);
  --sidebar-primary: oklch(0.488 0.243 264.376);
  --sidebar-primary-foreground: oklch(0.985 0 0);
  --sidebar-accent: oklch(0.269 0 0);
  --sidebar-accent-foreground: oklch(0.985 0 0);
  --sidebar-border: oklch(1 0 0 / 10%);
  --sidebar-ring: oklch(0.556 0 0);
}
 
@layer base {
  * {
    @apply border-border outline-ring/50;
  }
  body {
    @apply bg-background text-foreground;
  }
}
index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>React Flow Example</title>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="./index.tsx"></script>
  </body>
</html>
index.tsx
import { createRoot } from 'react-dom/client';
import { App } from './App';
 
import './index.css';
 
const container = document.querySelector('#app');
const root = createRoot(container);
 
root.render(<App />);
components/base-handle.tsx
import type { ComponentProps } from 'react';
import { Handle, type HandleProps } from '@xyflow/react';
 
import { cn } from '../lib/utils';
 
export type BaseHandleProps = HandleProps;
 
export function BaseHandle({
  className,
  children,
  ...props
}: ComponentProps<typeof Handle>) {
  return (
    <Handle
      {...props}
      className={cn(
        'dark:border-secondary dark:bg-secondary h-[11px] w-[11px] rounded-full border border-slate-300 bg-slate-100 transition',
        className,
      )}
    >
      {children}
    </Handle>
  );
}
components/base-node.tsx
import type { ComponentProps } from 'react';
 
import { cn } from '../lib/utils';
 
export function BaseNode({ className, ...props }: ComponentProps<'div'>) {
  return (
    <div
      className={cn(
        'bg-card text-card-foreground relative rounded-md border',
        'hover:ring-1',
        // React Flow displays node elements inside of a `NodeWrapper` component,
        // which compiles down to a div with the class `react-flow__node`.
        // When a node is selected, the class `selected` is added to the
        // `react-flow__node` element. This allows us to style the node when it
        // is selected, using Tailwind's `&` selector.
        '[.react-flow\\_\\_node.selected_&]:border-muted-foreground',
        '[.react-flow\\_\\_node.selected_&]:shadow-lg',
        className,
      )}
      tabIndex={0}
      {...props}
    />
  );
}
 
/**
 * A container for a consistent header layout intended to be used inside the
 * `<BaseNode />` component.
 */
export function BaseNodeHeader({ className, ...props }: ComponentProps<'header'>) {
  return (
    <header
      {...props}
      className={cn(
        'mx-0 my-0 -mb-1 flex flex-row items-center justify-between gap-2 px-3 py-2',
        // Remove or modify these classes if you modify the padding in the
        // `<BaseNode />` component.
        className,
      )}
    />
  );
}
 
/**
 * The title text for the node. To maintain a native application feel, the title
 * text is not selectable.
 */
export function BaseNodeHeaderTitle({ className, ...props }: ComponentProps<'h3'>) {
  return (
    <h3
      data-slot="base-node-title"
      className={cn('user-select-none flex-1 font-semibold', className)}
      {...props}
    />
  );
}
 
export function BaseNodeContent({ className, ...props }: ComponentProps<'div'>) {
  return (
    <div
      data-slot="base-node-content"
      className={cn('flex flex-col gap-y-2 p-3', className)}
      {...props}
    />
  );
}
 
export function BaseNodeFooter({ className, ...props }: ComponentProps<'div'>) {
  return (
    <div
      data-slot="base-node-footer"
      className={cn(
        'flex flex-col items-center gap-y-2 border-t px-3 pb-3 pt-2',
        className,
      )}
      {...props}
    />
  );
}
components/data-edge.tsx
'use client';
 
import {
  type Edge,
  type EdgeProps,
  type Node,
  BaseEdge,
  EdgeLabelRenderer,
  getBezierPath,
  getSmoothStepPath,
  getStraightPath,
  Position,
  useStore,
} from '@xyflow/react';
import { useMemo } from 'react';
 
export type DataEdge<T extends Node = Node> = Edge<{
  /**
   * The key to lookup in the source node's `data` object. For additional safety,
   * you can parameterize the `DataEdge` over the type of one of your nodes to
   * constrain the possible values of this key.
   *
   * If no key is provided this edge behaves identically to React Flow's default
   * edge component.
   */
  key?: keyof T['data'];
  /**
   * Which of React Flow's path algorithms to use. Each value corresponds to one
   * of React Flow's built-in edge types.
   *
   * If not provided, this defaults to `"bezier"`.
   */
  path?: 'bezier' | 'smoothstep' | 'step' | 'straight';
}>;
 
export function DataEdge({
  data = { path: 'bezier' },
  id,
  markerEnd,
  source,
  sourcePosition,
  sourceX,
  sourceY,
  style,
  targetPosition,
  targetX,
  targetY,
}: EdgeProps<DataEdge>) {
  const nodeData = useStore((state) => state.nodeLookup.get(source)?.data);
  const [edgePath, labelX, labelY] = getPath({
    type: data.path ?? 'bezier',
    sourceX,
    sourceY,
    sourcePosition,
    targetX,
    targetY,
    targetPosition,
  });
 
  const label = useMemo(() => {
    if (data.key && nodeData) {
      const value = nodeData[data.key];
 
      switch (typeof value) {
        case 'string':
        case 'number':
          return value;
 
        case 'object':
          return JSON.stringify(value);
 
        default:
          return '';
      }
    }
  }, [data, nodeData]);
 
  const transform = `translate(${labelX}px,${labelY}px) translate(-50%, -50%)`;
 
  return (
    <>
      <BaseEdge id={id} path={edgePath} markerEnd={markerEnd} style={style} />
      {data.key && (
        <EdgeLabelRenderer>
          <div
            className="bg-background text-foreground absolute rounded border px-1"
            style={{ transform }}
          >
            <pre className="text-xs">{label}</pre>
          </div>
        </EdgeLabelRenderer>
      )}
    </>
  );
}
 
/**
 * Chooses which of React Flow's edge path algorithms to use based on the provided
 * `type`.
 */
function getPath({
  type,
  sourceX,
  sourceY,
  targetX,
  targetY,
  sourcePosition,
  targetPosition,
}: {
  type: 'bezier' | 'smoothstep' | 'step' | 'straight';
  sourceX: number;
  sourceY: number;
  targetX: number;
  targetY: number;
  sourcePosition: Position;
  targetPosition: Position;
}) {
  switch (type) {
    case 'bezier':
      return getBezierPath({
        sourceX,
        sourceY,
        targetX,
        targetY,
        sourcePosition,
        targetPosition,
      });
 
    case 'smoothstep':
      return getSmoothStepPath({
        sourceX,
        sourceY,
        targetX,
        targetY,
        sourcePosition,
        targetPosition,
      });
 
    case 'step':
      return getSmoothStepPath({
        sourceX,
        sourceY,
        targetX,
        targetY,
        sourcePosition,
        targetPosition,
        borderRadius: 0,
      });
 
    case 'straight':
      return getStraightPath({
        sourceX,
        sourceY,
        targetX,
        targetY,
      });
  }
}
components/labeled-handle.tsx
import { type ComponentProps } from 'react';
import { type HandleProps } from '@xyflow/react';
 
import { cn } from '../lib/utils';
import { BaseHandle } from './base-handle';
 
const flexDirections = {
  top: 'flex-col',
  right: 'flex-row-reverse justify-end',
  bottom: 'flex-col-reverse justify-end',
  left: 'flex-row',
};
 
export function LabeledHandle({
  className,
  labelClassName,
  handleClassName,
  title,
  position,
  ...props
}: HandleProps &
  ComponentProps<'div'> & {
    title: string;
    handleClassName?: string;
    labelClassName?: string;
  }) {
  const { ref, ...handleProps } = props;
 
  return (
    <div
      title={title}
      className={cn('relative flex items-center', flexDirections[position], className)}
      ref={ref}
    >
      <BaseHandle position={position} className={handleClassName} {...handleProps} />
      <label className={cn('text-foreground px-3', labelClassName)}>{title}</label>
    </div>
  );
}
lib/utils.ts
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
 
export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}
components/nodes/num-node.tsx
import { type Node, type NodeProps, Position, useReactFlow } from '@xyflow/react';
import { useCallback } from 'react';
 
import {
  BaseNode,
  BaseNodeContent,
  BaseNodeFooter,
  BaseNodeHeader,
  BaseNodeHeaderTitle,
} from '../base-node';
import { LabeledHandle } from '../labeled-handle';
 
import { EllipsisVertical } from 'lucide-react';
import { Button } from '../ui/button';
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuTrigger,
} from '../ui/dropdown-menu';
 
export type NumNode = Node<{
  value: number;
}>;
 
export function NumNode({ id, data }: NodeProps<NumNode>) {
  const { updateNodeData, setNodes, setEdges } = useReactFlow();
 
  const handleReset = useCallback(() => {
    updateNodeData(id, { value: 0 });
  }, [id, updateNodeData]);
 
  const handleDelete = useCallback(() => {
    setNodes((nodes) => nodes.filter((node) => node.id !== id));
    setEdges((edges) => edges.filter((edge) => edge.source !== id));
  }, [id, setNodes, setEdges]);
 
  const handleIncr = useCallback(() => {
    updateNodeData(id, { value: data.value + 1 });
  }, [id, data.value, updateNodeData]);
 
  const handleDecr = useCallback(() => {
    updateNodeData(id, { value: data.value - 1 });
  }, [id, data.value, updateNodeData]);
 
  return (
    <BaseNode>
      <BaseNodeHeader className="border-b">
        <BaseNodeHeaderTitle>Num</BaseNodeHeaderTitle>
 
        <DropdownMenu>
          <DropdownMenuTrigger asChild>
            <Button
              variant="ghost"
              className="nodrag p-1"
              aria-label="Node Actions"
              title="Node Actions"
            >
              <EllipsisVertical className="size-4" />
            </Button>
          </DropdownMenuTrigger>
          <DropdownMenuContent>
            <DropdownMenuLabel className="font-bold">Node Actions</DropdownMenuLabel>
            <DropdownMenuItem onSelect={handleReset}>Reset</DropdownMenuItem>
            <DropdownMenuItem onSelect={handleDelete}>Delete</DropdownMenuItem>
          </DropdownMenuContent>
        </DropdownMenu>
      </BaseNodeHeader>
 
      <BaseNodeContent>
        <div className="flex items-center gap-2">
          <Button onClick={handleDecr}>-</Button>
          <pre>{String(data.value).padStart(3, ' ')}</pre>
          <Button onClick={handleIncr}>+</Button>
        </div>
      </BaseNodeContent>
 
      <BaseNodeFooter className="bg-card w-full items-end rounded-b-md px-0 py-1">
        <LabeledHandle title="out" type="source" position={Position.Right} />
      </BaseNodeFooter>
    </BaseNode>
  );
}
components/nodes/sum-node.tsx
import {
  type Node,
  type NodeProps,
  Position,
  useReactFlow,
  useStore,
} from '@xyflow/react';
import { useCallback, useEffect } from 'react';
 
import {
  BaseNode,
  BaseNodeContent,
  BaseNodeFooter,
  BaseNodeHeader,
  BaseNodeHeaderTitle,
} from '../base-node';
import { LabeledHandle } from '../labeled-handle';
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuTrigger,
} from '../ui/dropdown-menu';
import { EllipsisVertical } from 'lucide-react';
import { Button } from '../ui/button';
 
export type SumNode = Node<{
  value: number;
}>;
 
export function SumNode({ id }: NodeProps<SumNode>) {
  const { updateNodeData, getNodeConnections, setNodes, setEdges } = useReactFlow();
  const { x, y } = useStore((state) => ({
    x: getHandleValue(
      getNodeConnections({ nodeId: id, handleId: 'x', type: 'target' }),
      state.nodeLookup,
    ),
    y: getHandleValue(
      getNodeConnections({ nodeId: id, handleId: 'y', type: 'target' }),
      state.nodeLookup,
    ),
  }));
 
  const handleDelete = useCallback(() => {
    setNodes((nodes) => nodes.filter((node) => node.id !== id));
    setEdges((edges) => edges.filter((edge) => edge.source !== id));
  }, [id, setNodes, setEdges]);
 
  useEffect(() => {
    updateNodeData(id, { value: x + y });
  }, [x, y]);
 
  return (
    <BaseNode className="w-32">
      <BaseNodeHeader className="border-b">
        <BaseNodeHeaderTitle>Sum</BaseNodeHeaderTitle>
 
        <DropdownMenu>
          <DropdownMenuTrigger asChild>
            <Button
              variant="ghost"
              className="nodrag p-1"
              aria-label="Node Actions"
              title="Node Actions"
            >
              <EllipsisVertical className="size-4" />
            </Button>
          </DropdownMenuTrigger>
          <DropdownMenuContent>
            <DropdownMenuLabel className="font-bold">Node Actions</DropdownMenuLabel>
            <DropdownMenuItem onSelect={handleDelete}>Delete</DropdownMenuItem>
          </DropdownMenuContent>
        </DropdownMenu>
      </BaseNodeHeader>
 
      <BaseNodeContent className="px-0">
        <LabeledHandle title="x" id="x" type="target" position={Position.Left} />
        <LabeledHandle title="y" id="y" type="target" position={Position.Left} />
      </BaseNodeContent>
      <BaseNodeFooter className="bg-card w-full items-end rounded-b-md px-0 py-1">
        <LabeledHandle title="out" type="source" position={Position.Right} />
      </BaseNodeFooter>
    </BaseNode>
  );
}
 
function getHandleValue(
  connections: Array<{ source: string }>,
  lookup: Map<string, Node<any>>,
) {
  return connections.reduce((acc, { source }) => {
    const node = lookup.get(source)!;
    const value = node.data.value;
 
    return typeof value === 'number' ? acc + value : acc;
  }, 0);
}
components/ui/button.tsx
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
 
import { cn } from '../../lib/utils';
 
const buttonVariants = cva(
  "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
  {
    variants: {
      variant: {
        default: 'bg-primary text-primary-foreground hover:bg-primary/90',
        destructive:
          'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
        outline:
          'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
        secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
        ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
        link: 'text-primary underline-offset-4 hover:underline',
      },
      size: {
        default: 'h-9 px-4 py-2 has-[>svg]:px-3',
        sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
        lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
        icon: 'size-9',
        'icon-sm': 'size-8',
        'icon-lg': 'size-10',
      },
    },
    defaultVariants: {
      variant: 'default',
      size: 'default',
    },
  },
);
 
function Button({
  className,
  variant,
  size,
  asChild = false,
  ...props
}: React.ComponentProps<'button'> &
  VariantProps<typeof buttonVariants> & {
    asChild?: boolean;
  }) {
  const Comp = asChild ? Slot : 'button';
 
  return (
    <Comp
      data-slot="button"
      className={cn(buttonVariants({ variant, size, className }))}
      {...props}
    />
  );
}
 
export { Button, buttonVariants };
components/ui/dropdown-menu.tsx
'use client';
 
import * as React from 'react';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react';
 
import { cn } from '../../lib/utils';
 
function DropdownMenu({
  ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
  return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
 
function DropdownMenuPortal({
  ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
  return <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
}
 
function DropdownMenuTrigger({
  ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
  return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
}
 
function DropdownMenuContent({
  className,
  sideOffset = 4,
  ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
  return (
    <DropdownMenuPrimitive.Portal>
      <DropdownMenuPrimitive.Content
        data-slot="dropdown-menu-content"
        sideOffset={sideOffset}
        className={cn(
          'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 max-h-(--radix-dropdown-menu-content-available-height) origin-(--radix-dropdown-menu-content-transform-origin) z-50 min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border p-1 shadow-md',
          className,
        )}
        {...props}
      />
    </DropdownMenuPrimitive.Portal>
  );
}
 
function DropdownMenuGroup({
  ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
  return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
}
 
function DropdownMenuItem({
  className,
  inset,
  variant = 'default',
  ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
  inset?: boolean;
  variant?: 'default' | 'destructive';
}) {
  return (
    <DropdownMenuPrimitive.Item
      data-slot="dropdown-menu-item"
      data-inset={inset}
      data-variant={variant}
      className={cn(
        "focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm data-[disabled]:pointer-events-none data-[inset]:pl-8 data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
        className,
      )}
      {...props}
    />
  );
}
 
function DropdownMenuCheckboxItem({
  className,
  children,
  checked,
  ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
  return (
    <DropdownMenuPrimitive.CheckboxItem
      data-slot="dropdown-menu-checkbox-item"
      className={cn(
        "focus:bg-accent focus:text-accent-foreground outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-8 pr-2 text-sm data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
        className,
      )}
      checked={checked}
      {...props}
    >
      <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
        <DropdownMenuPrimitive.ItemIndicator>
          <CheckIcon className="size-4" />
        </DropdownMenuPrimitive.ItemIndicator>
      </span>
      {children}
    </DropdownMenuPrimitive.CheckboxItem>
  );
}
 
function DropdownMenuRadioGroup({
  ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
  return (
    <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />
  );
}
 
function DropdownMenuRadioItem({
  className,
  children,
  ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
  return (
    <DropdownMenuPrimitive.RadioItem
      data-slot="dropdown-menu-radio-item"
      className={cn(
        "focus:bg-accent focus:text-accent-foreground outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-8 pr-2 text-sm data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
        className,
      )}
      {...props}
    >
      <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
        <DropdownMenuPrimitive.ItemIndicator>
          <CircleIcon className="size-2 fill-current" />
        </DropdownMenuPrimitive.ItemIndicator>
      </span>
      {children}
    </DropdownMenuPrimitive.RadioItem>
  );
}
 
function DropdownMenuLabel({
  className,
  inset,
  ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
  inset?: boolean;
}) {
  return (
    <DropdownMenuPrimitive.Label
      data-slot="dropdown-menu-label"
      data-inset={inset}
      className={cn('px-2 py-1.5 text-sm font-medium data-[inset]:pl-8', className)}
      {...props}
    />
  );
}
 
function DropdownMenuSeparator({
  className,
  ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
  return (
    <DropdownMenuPrimitive.Separator
      data-slot="dropdown-menu-separator"
      className={cn('bg-border -mx-1 my-1 h-px', className)}
      {...props}
    />
  );
}
 
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) {
  return (
    <span
      data-slot="dropdown-menu-shortcut"
      className={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
      {...props}
    />
  );
}
 
function DropdownMenuSub({
  ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
  return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}
 
function DropdownMenuSubTrigger({
  className,
  inset,
  children,
  ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
  inset?: boolean;
}) {
  return (
    <DropdownMenuPrimitive.SubTrigger
      data-slot="dropdown-menu-sub-trigger"
      data-inset={inset}
      className={cn(
        'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground outline-hidden flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm data-[inset]:pl-8',
        className,
      )}
      {...props}
    >
      {children}
      <ChevronRightIcon className="ml-auto size-4" />
    </DropdownMenuPrimitive.SubTrigger>
  );
}
 
function DropdownMenuSubContent({
  className,
  ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
  return (
    <DropdownMenuPrimitive.SubContent
      data-slot="dropdown-menu-sub-content"
      className={cn(
        'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-dropdown-menu-content-transform-origin) z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg',
        className,
      )}
      {...props}
    />
  );
}
 
export {
  DropdownMenu,
  DropdownMenuPortal,
  DropdownMenuTrigger,
  DropdownMenuContent,
  DropdownMenuGroup,
  DropdownMenuLabel,
  DropdownMenuItem,
  DropdownMenuCheckboxItem,
  DropdownMenuRadioGroup,
  DropdownMenuRadioItem,
  DropdownMenuSeparator,
  DropdownMenuShortcut,
  DropdownMenuSub,
  DropdownMenuSubTrigger,
  DropdownMenuSubContent,
};

You could continue to improve this flow by adding nodes to perform other operations or to take user input using additional components from the shadcn/ui registry. In fact, keep your eyes peeled soon for a follow-up to this guide where we’ll show a complete application built using React Flow Components .

Wrapping up

In just a short amount of time we’ve managed to build out a fairly complete flow using the components and building blocks provided by shadcn React Flow Components. We’ve learned:

  • How to use building blocks like the <BaseNodeHeader /> and <LabeledHandle /> components to build our own custom nodes without starting from scratch.

  • That React Flow UI also provides custom edges like the <DataEdge /> to drop into our applications.

And thanks to the power of Tailwind, tweaking the visual style of these components is as simple as editing the variables in your CSS file.

That’s all for now! You can see all the components we currently have available over on the UI docs page. If you have any suggestions or requests for new components we’d love to hear about them. Or perhaps you’re already starting to build something with shadcn and React Flow UI. Either way make sure you let us know on our Discord server or on Twitter!