Slideshow
In this tutorial we turn Vue Flow into a slideshow: every slide is a node, the slides are laid out
on a grid, and you move between them by clicking a slide, pressing an arrow on a slide, or using the
keyboard. Navigation is just fitView panning the viewport
to the target slide.
Setting up the project
A slide is a custom node — an <article> sized to the slide dimensions. Register it with a
#node-slide slot, and because the slides are large, set a low min-zoom so you can zoom out to see
more than one at a time.
<script setup lang="ts">
import type { NodeProps } from '@xyflow/vue';
defineProps<NodeProps>();
const SLIDE_WIDTH = 1920;
const SLIDE_HEIGHT = 1080;
</script>
<template>
<article class="slide" :style="{ width: `${SLIDE_WIDTH}px`, height: `${SLIDE_HEIGHT}px` }">
<div>Hello, Vue Flow!</div>
</article>
</template><script setup lang="ts">
import type { Node } from '@xyflow/vue';
import { Background, VueFlow } from '@xyflow/vue';
import { shallowRef } from 'vue';
import Slide from './Slide.vue';
const nodes = shallowRef<Node[]>([
{ id: '0', type: 'slide', position: { x: 0, y: 0 }, data: {} },
]);
</script>
<template>
<VueFlow v-model:nodes="nodes" :min-zoom="0.1" fit-view>
<template #node-slide="props">
<Slide v-bind="props" />
</template>
<Background variant="lines" color="#f2f2f2" />
</VueFlow>
</template>Rendering markdown
We want slides authored in Markdown , so store the
source on the node’s data and render it with markdown-it:
import type { Node } from '@xyflow/vue';
import MarkdownIt from 'markdown-it';
export const SLIDE_WIDTH = 1920;
export const SLIDE_HEIGHT = 1080;
export type SlideData = { source: string };
export type SlideNode = Node<SlideData, 'slide'>;
const md = new MarkdownIt();
export function renderMarkdown(source: string) {
return md.render(source);
}<script setup lang="ts">
import type { NodeProps } from '@xyflow/vue';
import type { SlideNode } from './slides';
import { computed } from 'vue';
import { renderMarkdown, SLIDE_HEIGHT, SLIDE_WIDTH } from './slides';
const props = defineProps<NodeProps<SlideNode>>();
const html = computed(() => renderMarkdown(props.data.source));
</script>
<template>
<article class="slide" :style="{ width: `${SLIDE_WIDTH}px`, height: `${SLIDE_HEIGHT}px` }">
<!-- eslint-disable-next-line vue/no-v-html -->
<div class="slide__content" v-html="html" />
</article>
</template>With SlideData in place we can hand-place a few slides by offsetting each one by SLIDE_WIDTH.
Laying out the nodes
Placing slides by hand doesn’t scale. Instead, describe each slide’s neighbours (left / right /
up / down) and compute the layout: start at { x: 0, y: 0 }, then walk the connected slides,
offsetting each by a slide-plus-padding in the linked direction and wiring an edge to it.
function slidesToElements(slides: Record<string, LayoutSlide>) {
const offsets = {
left: { x: -(SLIDE_WIDTH + SLIDE_PADDING), y: 0 },
right: { x: SLIDE_WIDTH + SLIDE_PADDING, y: 0 },
up: { x: 0, y: -(SLIDE_HEIGHT + SLIDE_PADDING) },
down: { x: 0, y: SLIDE_HEIGHT + SLIDE_PADDING },
} as const;
const stack = [{ id: Object.keys(slides)[0], position: { x: 0, y: 0 } }];
const visited = new Set<string>();
const nodes = [];
const edges = [];
while (stack.length) {
const current = stack.pop()!;
if (visited.has(current.id)) {
continue;
}
visited.add(current.id);
const data = slides[current.id];
nodes.push({ id: current.id, type: 'slide', position: current.position, data });
for (const dir of ['left', 'right', 'up', 'down'] as const) {
const target = data?.[dir];
if (target && !visited.has(target)) {
const offset = offsets[dir];
stack.push({ id: target, position: { x: current.position.x + offset.x, y: current.position.y + offset.y } });
edges.push({ id: `${current.id}->${target}`, source: current.id, target });
}
}
}
return { nodes, edges };
}This tiny algorithm has two known limitations: two slides can end up in the same spot, and slides
unreachable from the start are skipped. Both are fine for a hand-authored deck. Edit the slides
object in the demo below to see different layouts.
Navigating between slides
Panning by hand isn’t much of a presentation, so we add three ways to move around — all built on
fitView, which pans and zooms the viewport to fit a set
of nodes. fitView only needs an id, so we never have to look a node up ourselves.
Focus on click
<VueFlow> emits @node-click with the clicked node; fit the view to it:
<script setup lang="ts">
import type { NodeMouseEvent } from '@xyflow/vue';
import { Background, setupVueFlow, VueFlow } from '@xyflow/vue';
import { shallowRef } from 'vue';
import Slide from './Slide.vue';
import { slides, slidesToElements } from './slides';
const initialSlide = '0';
const { nodes: initialNodes, edges: initialEdges } = slidesToElements(initialSlide, slides);
const { fitView } = setupVueFlow();
const nodes = shallowRef(initialNodes);
const edges = shallowRef(initialEdges);
function onNodeClick({ node }: NodeMouseEvent) {
fitView({ nodes: [{ id: node.id }], duration: 150 });
}
</script>
<template>
<VueFlow
v-model:nodes="nodes"
v-model:edges="edges"
:min-zoom="0.1"
:fit-view-options="{ nodes: [{ id: initialSlide }] }"
fit-view
@node-click="onNodeClick"
>
<template #node-slide="props">
<Slide v-bind="props" />
</template>
<Background variant="lines" color="#f2f2f2" />
</VueFlow>
</template>setupVueFlow() creates and provides the store, and
returns the instance we pull fitView from.
Slide controls
Next, a button on each slide for every direction it connects to. The buttons live inside the node, so
a click would bubble up and trigger @node-click too — stop it with @click.stop, and reach for
fitView through useVueFlow() right inside the slide:
<script setup lang="ts">
import type { NodeProps } from '@xyflow/vue';
import type { SlideNode } from './slides';
import { useVueFlow } from '@xyflow/vue';
import { computed } from 'vue';
import { renderMarkdown, SLIDE_HEIGHT, SLIDE_WIDTH } from './slides';
const props = defineProps<NodeProps<SlideNode>>();
const { fitView } = useVueFlow();
const html = computed(() => renderMarkdown(props.data.source));
function moveToSlide(id?: string) {
if (id) {
fitView({ nodes: [{ id }], duration: 150 });
}
}
</script>
<template>
<article class="slide" :style="{ width: `${SLIDE_WIDTH}px`, height: `${SLIDE_HEIGHT}px` }">
<!-- eslint-disable-next-line vue/no-v-html -->
<div class="slide__content" v-html="html" />
<!-- .stop keeps the button click from bubbling to the node and triggering @node-click -->
<footer class="slide__controls nopan">
<button v-if="data.left" type="button" @click.stop="moveToSlide(data.left)">←</button>
<button v-if="data.up" type="button" @click.stop="moveToSlide(data.up)">↑</button>
<button v-if="data.down" type="button" @click.stop="moveToSlide(data.down)">↓</button>
<button v-if="data.right" type="button" @click.stop="moveToSlide(data.right)">→</button>
</footer>
</article>
</template>The nopan class stops the canvas from panning while you interact with the buttons.
Keyboard navigation
Finally, the arrow keys. useKeyPress returns a ref
that’s true while its key is held; we watch each direction and move to the connected slide. Tracking
currentSlide is what tells us where an arrow should lead, so we update it here and in the click
handler (guarding it so clicking the current slide doesn’t re-fit). We also set
:nodes-draggable="false" — slides are for navigating, not dragging.
const arrowKeys = {
left: useKeyPress('ArrowLeft'),
up: useKeyPress('ArrowUp'),
down: useKeyPress('ArrowDown'),
right: useKeyPress('ArrowRight'),
};
const currentSlide = shallowRef(initialSlide);
for (const dir of ['left', 'up', 'down', 'right'] as const) {
watch(arrowKeys[dir], (pressed) => {
if (!pressed) {
return;
}
const target = slides[currentSlide.value][dir];
if (target) {
goToSlide(target);
}
});
}
function goToSlide(id: string) {
currentSlide.value = id;
fitView({ nodes: [{ id }], duration: 150 });
}
function onNodeClick({ node }: NodeMouseEvent) {
if (node.id !== currentSlide.value) {
goToSlide(node.id);
}
}Final thoughts
That’s a working slideshow: slides laid out on a grid, navigable by click, button, and keyboard. From here you could animate transitions differently, generate the deck from a folder of markdown files, or add a minimap for an overview.