2024/01/07

Create a slide show presentation with React Flow

Hayleigh Thompson
Software Engineer

We recently published the findings from our React Flow 2023 end-of-year survey with an interactive presentation of the key findings, using React Flow itself. There were lots of useful bits built into this slideshow app, so we wanted to share how we built it!

Screenshot of slides layed out on an infinite canvas, each with information pulled from a survey of React Flow users
Our 2023 end of year survey app was made up of many static nodes and buttons to navigate between them.

By the end of this tutorial, you will have built a presentation app with

  • Support for markdown slides
  • Keyboard navigation around the viewport
  • Automatic layouting
  • Click-drag panning navigation (à la Prezi)

Along the way, you’ll learn a bit about the basics of layouting algorithms, creating static flows, and custom nodes.

Once you’re done, the app will look like this!

To follow along with this tutorial we’ll assume you have a basic understanding of React and React Flow, but if you get stuck on the way feel free to reach out to us on Discord!

Here’s the repo with the final code if you’d like to skip ahead or refer to it as we go.

Let’s get started!

Setting up the project

We like to recommend using Vite when starting new React Flow projects, and this time we’ll use TypeScript too. You can scaffold a new project with the following command:

npm create vite@latest -- --template react-ts

If you’d prefer to follow along with JavaScript feel free to use the react template instead. You can also follow along in your browser by using our codesandbox templates:

Besides React Flow we only need to pull in one dependency, react-remark, to help us render markdown in our slides.

npm install reactflow react-remark

We’ll modify the generated main.tsx to include React Flow’s styles, as well as wrap the app in a <ReactFlowProvider /> to make sure we can access the React Flow instance inside our components;

main.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { ReactFlowProvider } from '@xyflow/react';
 
import App from './App';
 
import 'reactflow/dist/style.css';
import './index.css';
 
ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <ReactFlowProvider>
      {/* The parent element of the React Flow component needs a width and a height
          to work properly. If you're styling your app as you follow along, you
          can remove this div and apply styles to the #root element in your CSS.
       */}
      <div style={{ width: '100vw', height: '100vh' }}>
        <App />
      </div>
    </ReactFlowProvider>
  </React.StrictMode>,
);

This tutorial is going to gloss over the styling of the app, so feel free to use any CSS framework or styling solution you’re familiar with. If you’re going to style your app differently from just writing CSS, for example with Styled Components or Tailwind CSS, you can skip the import to index.css.

💡

How you style your app is up to you, but you must always include React Flow’s styles! If you don’t need the default styles, at a minimum you should include the base styles from reactflow/dist/base.css.

Each slide of our presentation will be a node on the canvas, so let’s create a new file Slide.tsx that will be our custom node used to render each slide.

Slide.tsx
import { type Node, type NodeProps } from '@xyflow/react';
 
export const SLIDE_WIDTH = 1920;
export const SLIDE_HEIGHT = 1080;
 
export type SlideNode = Node<SlideData, 'slide'>;
 
export type SlideData = {};
 
const style = {
  width: `${SLIDE_WIDTH}px`,
  height: `${SLIDE_HEIGHT}px`,
} satisfies React.CSSProperties;
 
export function Slide({ data }: NodeProps<SlideNode>) {
  return (
    <article className="slide nodrag" style={style}>
      <div>Hello, React Flow!</div>
    </article>
  );
}

We’re setting the slide width and height as constants here (rather than styling the node in CSS) because we’ll want access to those dimensions later on. We’ve also stubbed out the SlideData type so we can properly type the component’s props.

The last thing to do is to register our new custom node and show something on the screen.

App.tsx
import { ReactFlow } from '@xyflow/react';
import { Slide } from './Slide.tsx';
 
const nodeTypes = {
  slide: Slide,
};
 
export default export default function App() {
  const nodes = [
    { id: '0', type: 'slide', position: { x: 0, y: 0 }, data: {} },
  ];
 
  return <ReactFlow nodes={nodes} nodeTypes={nodeTypes} fitView />;
}
💡

It’s important to remember to define your nodeTypes object outside of the component (or to use React’s useMemo hook)! When the nodeTypes object changes, the entire flow is re-rendered.

With the basics put together, you can start the development server by running npm run dev and see the following:

Not super exciting yet, but let’s add markdown rendering and create a few slides side by side!

Rendering markdown

We want to make it easy to add content to our slides, so we’d like the ability to write Markdown in our slides. If you’re not familiar, Markdown is a simple markup language for creating formatted text documents. If you’ve ever written a README on GitHub, you’ve used Markdown!

Thanks to the react-remark package we installed earlier, this step is a simple one. We can use the <Remark /> component to render a string of markdown content into our slides.

Slide.tsx
import { type Node, type NodeProps } from '@xyflow/react';
import { Remark } from 'react-remark';
 
export const SLIDE_WIDTH = 1920;
export const SLIDE_HEIGHT = 1080;
 
export type SlideNode = Node<SlideData, 'slide'>;
 
export type SlideData = {
  source: string;
};
 
const style = {
  width: `${SLIDE_WIDTH}px`,
  height: `${SLIDE_HEIGHT}px`,
} satisfies React.CSSProperties;
 
export function Slide({ data }: NodeProps<SlideNode>) {
  return (
    <article className="slide nodrag" style={style}>
      <Remark>{data.source}</Remark>
    </article>
  );
}

In React Flow, nodes can have data stored on them that can be used during rendering. In this case we’re storing the markdown content to display by adding a source property to the SlideData type and passing that to the <Remark /> component. We can update our hardcoded nodes with some markdown content to see it in action:

App.tsx
import { ReactFlow } from '@xyflow/react';
import { Slide, SLIDE_WIDTH } from './Slide';
 
const nodeTypes = {
  slide: Slide,
};
 
export default export default function App() {
  const nodes = [
    {
      id: '0',
      type: 'slide',
      position: { x: 0, y: 0 },
      data: { source: '# Hello, React Flow!' },
    },
    {
      id: '1',
      type: 'slide',
      position: { x: SLIDE_WIDTH, y: 0 },
      data: { source: '...' },
    },
    {
      id: '2',
      type: 'slide',
      position: { x: SLIDE_WIDTH * 2, y: 0 },
      data: { source: '...' },
    },
  ];
 
  return <ReactFlow
    nodes={nodes}
    nodeTypes={nodeTypes}
    fitView
    minZoom={0.1}
  />;
}

Note that we’ve added the minZoom prop to the <ReactFlow /> component. Our slides are quite large, and the default minimum zoom level is not enough to zoom out and see multiple slides at once.

In the nodes array above, we’ve made sure to space the slides out by doing some manual math with the SLIDE_WIDTH constant. In the next section we’ll come up with an algorithm to automatically lay out the slides in a grid.

Laying out the nodes

We often get asked how to automatically lay out nodes in a flow, and we have some documentation on how to use common layouting libraries like dagre and d3-hierarchy in our layouting guide. Here you’ll be writing your own super-simple layouting algorithm, which gets a bit nerdy, but stick with us!

For our presentation app we’ll construct a simple grid layout by starting from 0,0 and updating the x or y coordinates any time we have a new slide to the left, right, up, or down.

First, we need to update our SlideData type to include optional ids for the slides to the left, right, up, and down of the current slide.

Slide.tsx
export type SlideData = {
  source: string;
  left?: string;
  up?: string;
  down?: string;
  right?: string;
};

Storing this information on the node data directly gives us some useful benefits:

  • We can write fully declarative slides without worrying about the concept of nodes and edges

  • We can compute the layout of the presentation by visiting connecting slides

  • We can add navigation buttons to each slide to navigate between them automatically. We’ll handle that in a later step.

The magic happens in a function we’re going to define called slidesToElements. This function will take an object containing all our slides addressed by their id, and an id for the slide to start at. Then it will work through each connecting slide to build an array of nodes and edges that we can pass to the <ReactFlow /> component.

The algorithm will go something like this:

  • Push the initial slide’s id and the position { x: 0, y: 0 } onto a stack.

  • While that stack is not empty…

    • Pop the current position and slide id off the stack.

    • Look up the slide data by id.

    • Push a new node onto the nodes array with the current id, position, and slide data.

    • Add the slide’s id to a set of visited slides.

    • For every direction (left, right, up, down)…

      • Make sure the slide has not already been visited.

      • Take the current position and update the x or y coordinate by adding or subtracting SLIDE_WIDTH or SLIDE_HEIGHT depending on the direction.

      • Push the new position and the new slide’s id onto a stack.

      • Push a new edge onto the edges array connecting the current slide to the new slide.

      • Repeat for the remaining directions…

If all goes to plan, we should be able to take a stack of slides shown below and turn them into a neatly laid out grid!

Let’s see the code. In a file called slides.ts add the following:

slides.ts
import { SlideData, SLIDE_WIDTH, SLIDE_HEIGHT } from './Slide';
 
export const slidesToElements = (
  initial: string,
  slides: Record<string, SlideData>,
) => {
  // Push the initial slide's id and the position `{ x: 0, y: 0 }` onto a stack.
  const stack = [{ id: initial, position: { x: 0, y: 0 } }];
  const visited = new Set();
  const nodes = [];
  const edges = [];
 
  // While that stack is not empty...
  while (stack.length) {
    // Pop the current position and slide id off the stack.
    const { id, position } = stack.pop();
    // Look up the slide data by id.
    const data = slides[id];
    const node = { id, type: 'slide', position, data };
 
    // Push a new node onto the nodes array with the current id, position, and slide
    // data.
    nodes.push(node);
    // add the slide's id to a set of visited slides.
    visited.add(id);
 
    // For every direction (left, right, up, down)...
    // Make sure the slide has not already been visited.
    if (data.left && !visited.has(data.left)) {
      // Take the current position and update the x or y coordinate by adding or
      // subtracting `SLIDE_WIDTH` or `SLIDE_HEIGHT` depending on the direction.
      const nextPosition = {
        x: position.x - SLIDE_WIDTH,
        y: position.y,
      };
 
      // Push the new position and the new slide's id onto a stack.
      stack.push({ id: data.left, position: nextPosition });
      // Push a new edge onto the edges array connecting the current slide to the
      // new slide.
      edges.push({ id: `${id}->${data.left}`, source: id, target: data.left });
    }
 
    // Repeat for the remaining directions...
  }
 
  return { nodes, edges };
};

We’ve left out the code for the right, up, and down directions for brevity, but the logic is the same for each direction. We’ve also included the same breakdown of the algorithm as comments, to help you navigate the code.

Below is a demo app of the layouting algorithm, you can edit the slides object to see how adding slides to different directions affects the layout. For example, try extending 4’s data to include down: '5' and see how the layout updates.

import Flow from './Flow';
 
// add more slides and create different layouts by
// linking slides in different ways.
const slides = {
  '1': { right: '2' },
  '2': { left: '1', up: '3', right: '4' },
  '3': { down: '2' },
  '4': { left: '2' },
};
 
export default function App() {
  return <Flow slides={slides} />;
}

If you spend a little time playing with this demo, you’ll likely run across two limitations of this algorithm:

  1. It is possible to construct a layout that overlaps two slides in the same position.

  2. The algorithm will ignore nodes that cannot be reached from the initial slide.

Addressing these shortcomings is totally possible, but a bit beyond the scope of this tutorial. If you give a shot, be sure to share your solution with us on the discord server!

With our layouting algorithm written, we can hop back to App.tsx and remove the hardcoded nodes array in favor of the new slidesToElements function.

App.tsx
import { ReactFlow } from '@xyflow/react';
import { slidesToElements } from './slides';
import { Slide, SlideData, SLIDE_WIDTH } from './Slide';
 
const slides: Record<string, SlideData> = {
  '0': { source: '# Hello, React Flow!', right: '1' },
  '1': { source: '...', left: '0', right: '2' },
  '2': { source: '...', left: '1' },
};
 
const nodeTypes = {
  slide: Slide,
};
 
const initialSlide = '0';
const { nodes, edges } = slidesToElements(initialSlide, slides);
 
export default export default function App() {
  return (
    <ReactFlow
      nodes={nodes}
      nodeTypes={nodeTypes}
      fitView
      fitViewOptions={{ nodes: [{ id: initialSlide }] }}
      minZoom={0.1}
    />
  );
}

The slides in our flow are static, so we can move the slidesToElements call outside the component to make sure we’re not recalculating the layout if the component re-renders. Alternatively, you could use React’s useMemo hook to define things inside the component but only calculate them once.

Because we have the idea of an “initial” slide now, we’re also using the fitViewOptions to ensure the initial slide is the one that is focused when the canvas is first loaded.

So far we have our presentation laid out in a grid but we have to manually pan the canvas to see each slide, which isn’t very practical for a presentation! We’re going to add three different ways to navigate between slides:

  • Click-to-focus on nodes for jumping to different slides by clicking on them.

  • Navigation buttons on each slide for moving sequentially between slides in any valid direction.

  • Keyboard navigation using the arrow keys for moving around the presentation without using the mouse or interacting with a slide directly.

Focus on click

The <ReactFlow /> element can receive an onNodeClick callback that fires when any node is clicked. Along with the mouse event itself, we also receive a reference to the node that was clicked on, and we can use that to pan the canvas thanks to the fitView method.

fitView is a method on the React Flow instance, and we can get access to it by using the useReactFlow hook.

App.tsx
import { useCallback } from 'react';
import { ReactFlow, useReactFlow, type NodeMouseHandler } from '@xyflow/react';
import { Slide, SlideData, SLIDE_WIDTH } from './Slide';
 
const slides: Record<string, SlideData> = {
  ...
}
 
const nodeTypes = {
  slide: Slide,
};
 
const initialSlide = '0';
const { nodes, edges } = slidesToElements(initialSlide, slides);
 
export default function App() {
  const { fitView } = useReactFlow();
  const handleNodeClick = useCallback<NodeMouseHandler>(
    (_, node) => {
      fitView({ nodes: [node], duration: 150 });
    },
    [fitView],
  );
 
  return (
    <ReactFlow
      ...
      fitViewOptions={{ nodes: [{ id: initialSlide }] }}
      onNodeClick={handleNodeClick}
    />
  );
}
💡

It’s important to remember to include fitView as in the dependency array of our handleNodeClick callback. That’s because the fitView function is replaced once React Flow has initialised the viewport. If you forget this step you’ll likely find out that handleNodeClick does nothing at all (and yes, we also forget this ourselves sometimes too ).

Calling fitView with no arguments would attempt to fit every node in the graph into view, but we only want to focus on the node that was clicked! The FitViewOptions object lets us provide an array of just the nodes we want to focus on: in this case, that’s just the node that was clicked.

Slide controls

Clicking to focus a node is handy for zooming out to see the big picture before focusing back in on a specific slide, but it’s not a very practical way for navigating around a prsentation. In this step we’ll add some controls to each slide that allow us to move to a connected slide in any direction.

Let’s add a <footer> to each slide that conditionally renders a button in any direction with a connected slide. We’ll also preemptively create a moveToNextSlide callback that we’ll use in a moment.

Slide.tsx
import { type NodeProps, fitView } from '@xyflow/react';
import { Remark } from 'react-remark';
import { useCallback } from 'react';
 
...
 
export function Slide({ data }: NodeProps<SlideNide>) {
  const moveToNextSlide = useCallback((id: string) => {}, []);
 
  return (
    <article className="slide nodrag" style={style}>
      <Remark>{data.source}</Remark>
      <footer className="slide__controls nopan">
        {data.left && (<button onClick={() => moveToNextSlide(data.left)}>←</button>)}
        {data.up && (<button onClick={() => moveToNextSlide(data.up)}>↑</button>)}
        {data.down && (<button onClick={() => moveToNextSlide(data.down)}>↓</button>)}
        {data.right && (<button onClick={() => moveToNextSlide(data.right)}>→</button>)}
      </footer>
    </article>
  );
}

You can style the footer however you like, but it’s important to add the "nopan" class to prevent prevent the canvas from panning as you interact with any of the buttons.

To implement moveToSlide we’ll make use of fitView agan. Previously we had a reference to the actual node that was clicked on to pass to fitView, but this time we only have a node’s id. You might be tempted to look up the target node by its id, but actually that’s not necessary! If we look at the type of FitViewOptions we can see that the array of nodes we pass in only needs to have an id property:

https://reactflow.dev/api-reference/types/fit-view-options
export type FitViewOptions = {
  padding?: number;
  includeHiddenNodes?: boolean;
  minZoom?: number;
  maxZoom?: number;
  duration?: number;
  nodes?: (Partial<Node> & { id: Node['id'] })[];
};

Partial<Node> means that all of the fields of the Node object type get marked as optional, and then we intersect that with { id: Node['id'] } to ensure that the id field is always required. This means we can just pass in an object with an id property and nothing else, and fitView will know what to do with it!

Slide.tsx
import { type NodeProps, useReactFlow } from '@xyflow/react';
 
export function Slide({ data }: NodeProps<SlideNide>) {
  const { fitView } = useReactFlow();
 
  const moveToNextSlide = useCallback(
    (id: string) => fitView({ nodes: [{ id }] }),
    [fitView],
  );
 
  return (
    <article className="slide" style={style}>
      ...
    </article>
  );
}
import React, { useCallback, useMemo } from 'react';
import {
  ReactFlow,
  useReactFlow,
  ReactFlowProvider,
  Background,
  BackgroundVariant,
  type Node,
  type NodeMouseHandler,
} from '@xyflow/react';
 
// we need to import the React Flow styles to make it work
import '@xyflow/react/dist/style.css';
 
import slides from './slides';
import {
  Slide,
  SLIDE_WIDTH,
  SLIDE_HEIGHT,
  SLIDE_PADDING,
  type SlideData,
} from './Slide';
 
const slidesToElements = () => {
  const start = Object.keys(slides)[0];
  const stack = [{ id: start, position: { x: 0, y: 0 } }];
  const visited = new Set();
  const nodes = [];
  const edges = [];
 
  while (stack.length) {
    const { id, position } = stack.pop();
    const slide = slides[id];
    const node = {
      id,
      type: 'slide',
      position,
      data: slide,
      draggable: false,
    } satisfies Node<SlideData>;
 
    if (slide.left && !visited.has(slide.left)) {
      const nextPosition = {
        x: position.x - (SLIDE_WIDTH + SLIDE_PADDING),
        y: position.y,
      };
 
      stack.push({ id: slide.left, position: nextPosition });
      edges.push({
        id: `${id}->${slide.left}`,
        source: id,
        target: slide.left,
      });
    }
 
    if (slide.up && !visited.has(slide.up)) {
      const nextPosition = {
        x: position.x,
        y: position.y - (SLIDE_HEIGHT + SLIDE_PADDING),
      };
 
      stack.push({ id: slide.up, position: nextPosition });
      edges.push({ id: `${id}->${slide.up}`, source: id, target: slide.up });
    }
 
    if (slide.down && !visited.has(slide.down)) {
      const nextPosition = {
        x: position.x,
        y: position.y + (SLIDE_HEIGHT + SLIDE_PADDING),
      };
 
      stack.push({ id: slide.down, position: nextPosition });
      edges.push({
        id: `${id}->${slide.down}`,
        source: id,
        target: slide.down,
      });
    }
 
    if (slide.right && !visited.has(slide.right)) {
      const nextPosition = {
        x: position.x + (SLIDE_WIDTH + SLIDE_PADDING),
        y: position.y,
      };
 
      stack.push({ id: slide.right, position: nextPosition });
      edges.push({
        id: `${id}->${slide.down}`,
        source: id,
        target: slide.down,
      });
    }
 
    nodes.push(node);
    visited.add(id);
  }
 
  return { start, nodes, edges };
};
 
const nodeTypes = {
  slide: Slide,
};
 
function Flow() {
  const { fitView } = useReactFlow();
  const { start, nodes, edges } = useMemo(() => slidesToElements(), []);
 
  const handleNodeClick = useCallback<NodeMouseHandler>(
    (_, node) => {
      fitView({ nodes: [{ id: node.id }], duration: 150 });
    },
    [fitView],
  );
 
  return (
    <ReactFlow
      nodes={nodes}
      nodeTypes={nodeTypes}
      edges={edges}
      fitView
      fitViewOptions={{ nodes: [{ id: start }] }}
      minZoom={0.1}
      onNodeClick={handleNodeClick}
    >
      <Background color="#f2f2f2" variant={BackgroundVariant.Lines} />
    </ReactFlow>
  );
}
 
export default () => (
  <ReactFlowProvider>
    <Flow />
  </ReactFlowProvider>
);

Keyboard navigation

The final piece of the puzzle is to add keyboard navigation to our presentation. It’s not very convenient to have to always click on a slide to move to the next one, so we’ll add some keyboard shortcuts to make it easier. React Flow lets us listen to keyboard events on the <ReactFlow /> component through handlers like onKeyDown.

Up until now the slide currently focused is implied by the position of the canvas, but if we want to handle key presses on the entire canvas we need to explicitly track the current slide. We need to this because we need to know which slide to navigate to when an arrow key is pressed!

App.tsx
import { useState, useCallback } from 'react';
import { ReactFlow, useReactFlow } from '@xyflow/react';
import { Slide, SlideData, SLIDE_WIDTH } from './Slide';
 
const slides: Record<string, SlideData> = {
  ...
}
 
const nodeTypes = {
  slide: Slide,
};
 
const initialSlide = '0';
const { nodes, edges } = slidesToElements(initialSlide, slides)
 
export default function App() {
  const [currentSlide, setCurrentSlide] = useState(initialSlide);
  const { fitView } = useReactFlow();
 
  const handleNodeClick = useCallback<NodeMouseHandler>(
    (_, node) => {
      fitView({ nodes: [node] });
      setCurrentSlide(node.id);
    },
    [fitView],
  );
 
  return (
    <ReactFlow
      ...
      onNodeClick={handleNodeClick}
    />
  );
}

Here we’ve added a bit of state, currentSlide, to our flow component and we’re making sure to update it whenever a node is clicked. Next, we’ll write a callback to handle keyboard events on the canvas:

App.tsx
export default function App() {
  const [currentSlide, setCurrentSlide] = useState(initialSlide);
  const { fitView } = useReactFlow();
 
  ...
 
  const handleKeyPress = useCallback<KeyboardEventHandler>(
    (event) => {
      const slide = slides[currentSlide];
 
      switch (event.key) {
        case 'ArrowLeft':
        case 'ArrowUp':
        case 'ArrowDown':
        case 'ArrowRight':
          const direction = event.key.slice(5).toLowerCase();
          const target = slide[direction];
 
          if (target) {
            event.preventDefault();
            setCurrentSlide(target);
            fitView({ nodes: [{ id: target }] });
          }
      }
    },
    [currentSlide, fitView],
  );
 
  return (
    <ReactFlow
      ...
      onKeyPress={handleKeyPress}
    />
  );
}

To save some typing we’re extracting the direction from the key pressed - if the user pressed 'ArrowLeft' we’ll get 'left' and so on. Then, if there is actually a slide connected in that direction we’ll update the current slide and call fitView to navigate to it!

We’re also preventing the default behaviour of the arrow keys to prevent the window from scrolling up and down. This is necessary for this tutorial because the canvas is only one part of the page, but for an app where the canvas is the entire viewport you might not need to do this.

And that’s everything! To recap let’s look at the final result and talk about what we’ve learned.

Final thoughts

Even if you’re not planning on making the next Prezi, we’ve still looked at a few useful features of React Flow in this tutorial:

  • The useReactFlow hook to access the fitView method.

  • The onNodeClick event handler to listen to clicks on every node in a flow.

  • The onKeyPress event handler to listen to keyboard events on the entire canvas.

We’ve also looked at how to implement a simple layouting algorithm ourselves. Layouting is a really common question we get asked about, but if your needs aren’t that complex you can get quite far rolling your own solution!

If you’re looking for ideas on how to extend this project, you could try addressing the issues we pointed out with the layouting algorithm, coming up with a more sophisticated Slide component with different layouts, or something else entirely.

You can use the completed source code as a starting point, or you can just keep building on top of what we’ve made today. We’d love to see what you build so please share it with us over on our Discord server or Twitter.

Get Pro examples, prioritized bug reports, 1:1 support from the maintainers, and more with React Flow Pro