Sign in

Slideshow

Create a collaborative slideshow, presentation editor, or pitch deck with Liveblocks. Synchronize slides and their content, show live cursors and active selections, follow a presenter, upload media, and let AI edit the deck alongside your users.

Example of a collaborative AI slideshow editor

Multiplayer editing in the AI Slideshow example

Features

  • Realtime collaboration: Synchronize slide order, content, and layout between users.
  • Presence: Show live cursors, avatar stacks, active slides, and selected elements.
  • Presentation mode: Let everyone follow a presenter through the deck in realtime.
  • Server-side editing: Let trusted backend processes create, rewrite, and rearrange slides.
  • Agentic editing: Let AI agents generate and apply slide content.
  • Version history: Save, preview, and restore complete versions of the deck.
  • Multiplayer undo/redo: Give each user an independent history of their changes.
  • File uploads: Add images, videos, and other uploaded assets to slides.
  • Comments: Attach feedback to a slide or a specific point on its surface.
  • Permissions: Control who can view, present, and edit the slideshow.

Get started

Choose a starting point for your slideshow.

Implementation

This is an overview of how each feature can be implemented. Store permanent slide content, order, and layout in Sync. Keep temporary cursors, selections, and presentation state in Presence. Use Comments for review discussions and Feeds for durable AI workflow state.

Realtime collaboration

Store slide order in a LiveList, and store slides by stable ID in a LiveMap. Each slide can be a LiveObject containing layout data and LiveText for collaborative text. This lets one user rearrange the deck while another edits a slide, without either change overwriting the other.

Read the deck with useStorage and update it with useMutation.

import { LiveObject, LiveText } from "@liveblocks/client";import { useMutation, useStorage } from "@liveblocks/react/suspense";
function SlideDeck() { const slideOrder = useStorage((root) => root.slideOrder);
const addSlide = useMutation(({ storage }) => { const slideId = crypto.randomUUID();
storage.get("slides").set( slideId, new LiveObject({ title: new LiveText("Untitled"), body: new LiveText(""), }) ); storage.get("slideOrder").push(slideId); }, []);
return <button onClick={addSlide}>Add slide ({slideOrder.length})</button>;}

Liveblocks applies changes optimistically and resolves simultaneous edits for you. Read Storage to choose the right structure for each part of the deck.

Presence

Use Presence for information that only matters while someone is connected, such as their cursor, active slide, selected element, and editing mode. Publish local state with useUpdateMyPresence and render collaborators with useOthers.

import { useOthers, useUpdateMyPresence } from "@liveblocks/react/suspense";import { Cursor } from "@liveblocks/react-ui";
function SlidePresence({ slideId }: { slideId: string }) { const updateMyPresence = useUpdateMyPresence(); const collaborators = useOthers();
return ( <div onPointerEnter={() => updateMyPresence({ activeSlideId: slideId })} onPointerMove={(event) => updateMyPresence({ cursor: { x: event.clientX, y: event.clientY } }) } > {collaborators.map(({ connectionId, info, presence }) => presence.activeSlideId === slideId && presence.cursor ? ( <Cursor key={connectionId} label={info.name} color={info.color} style={{ position: "absolute", transform: `translate(${presence.cursor.x}px, ${presence.cursor.y}px)`, }} /> ) : null )} </div> );}

Add AvatarStack to the editor toolbar to show everyone currently in the room.

Presentation mode

Presentation mode is temporary, so store the presenter’s status and active slide in presence instead of the saved deck. Viewers can find the presenter with useOthers and immediately follow the current slide, including when they join midway through a presentation.

import { useOthers, useUpdateMyPresence } from "@liveblocks/react/suspense";
function PresentationControls() { const updateMyPresence = useUpdateMyPresence(); const presenter = useOthers((others) => others.find((other) => other.presence.isPresenting) );
function presentSlide(activeSlideId: string) { updateMyPresence({ isPresenting: true, activeSlideId }); }
return ( <button onClick={() => presentSlide("slide-2")}> Present next: {presenter?.presence.activeSlideId} </button> );}

Only let users with presentation access set isPresenting, and decide what happens if the current presenter disconnects. For example, return viewers to independent navigation or transfer control to another editor.

Server-side editing

Trusted server processes can edit a slideshow from the back end with Liveblocks.mutateStorage. The server reads and writes the same Sync data as connected users, so new slides, rewrites, and reordered sections appear in realtime.

import { LiveObject, LiveText } from "@liveblocks/client";import { Liveblocks } from "@liveblocks/node";
const liveblocks = new Liveblocks({ secret: process.env.LIVEBLOCKS_SECRET_KEY!,});
await liveblocks.mutateStorage("slideshow-room", ({ root }) => { const slideId = crypto.randomUUID();
root.get("slides").set( slideId, new LiveObject({ title: new LiveText("Quarterly results"), body: new LiveText("Revenue increased by 18%."), }) ); root.get("slideOrder").push(slideId);});

Learn more under Server-side editing.

Agentic editing

To allow AI agents to modify your slideshow, generate your changes with AI then use mutateStorage to apply them. To show that AI is working in your app use setPresence to show it working—your agent will appear in Presence alongside humans. Finally, remove the agent’s presence to indicate that the agent is no longer working.

import { LiveObject, LiveText } from "@liveblocks/client";import { Liveblocks } from "@liveblocks/node";import { generateText, Output } from "ai";import { z } from "zod";
const liveblocks = new Liveblocks({ secret: process.env.LIVEBLOCKS_SECRET_KEY!,});
const roomId = "slideshow-room";const agent: Liveblocks["UserMeta"] = { id: "ai-agent", info: { name: "AI agent", color: "#7c3aed" },};
await liveblocks.setPresence(roomId, { userId: agent.id, userInfo: agent.info, data: { status: "thinking", activeSlideId: null }, ttl: 60,});
await liveblocks.mutateStorage(roomId, async ({ root }) => { const slides = root.get("slides");
const { output: slide } = await generateText({ model: "openai/gpt-5.6-sol", output: Output.object({ schema: z.object({ title: z.string(), body: z.string(), }), }), prompt: `Create a slide about realtime apps. Here are the current slides: ${slides.toJSON()}`, });
const slideId = crypto.randomUUID();
slides.set( slideId, new LiveObject({ title: new LiveText(slide.title), body: new LiveText(slide.body), }) ); root.get("slideOrder").push(slideId);});
await liveblocks.setPresence(roomId, { userId: agent.id, userInfo: agent.info, data: { status: "idle", activeSlideId: null }, ttl: 2,});

Additionally, you can use Feeds to store AI workflow state, and to pass agent status updates to the UI. Learn more under Agentic editing.

Version history

Create versions before publishing a deck, importing slides, or allowing an agent to rewrite a large section. Use useHistoryVersions to list versions, useHistoryVersionStorageData to build a read-only preview, and useRestoreToStorageVersion to restore the complete deck as one synchronized change.

import { useRestoreToStorageVersion } from "@liveblocks/react/suspense";
function RestoreSlideshow({ versionId }: { versionId: string }) { const restore = useRestoreToStorageVersion(versionId);
return <button onClick={() => restore()}>Restore this version</button>;}

Automatic versions can be enabled in the dashboard, and meaningful versions can be created from your backend with Liveblocks.createVersionHistorySnapshot. Learn more under Version history.

Multiplayer undo/redo

Use useHistory to connect undo and redo to the slideshow toolbar. Each user’s history is independent, so undoing a slide edit or reorder does not reverse another collaborator’s work. Pause and resume history while dragging or resizing an element so the complete gesture becomes a single undo step.

import {  useCanRedo,  useCanUndo,  useRedo,  useUndo,} from "@liveblocks/react/suspense";
function SlideshowToolbar() { const undo = useUndo(); const redo = useRedo(); const canUndo = useCanUndo(); const canRedo = useCanRedo();
return ( <> <button onClick={undo} disabled={!canUndo}> Undo </button> <button onClick={redo} disabled={!canRedo}> Redo </button> </> );}

Learn more under Multiplayer undo/redo.

File uploads

Upload images, videos, and other binary assets with useUploadFile, then save the returned LiveFile in the slide’s Sync data. Resolve the reference for display with useFileUrl.

import type { LiveFile } from "@liveblocks/client";import {  useFileUrl,  useMutation,  useUploadFile,} from "@liveblocks/react/suspense";
function SlideImage({ image }: { image: LiveFile }) { const uploadFile = useUploadFile(); const { url: imageUrl } = useFileUrl(image); const setImage = useMutation(({ storage }, liveFile: LiveFile) => { storage.get("slides").get("slide-1")?.set("image", liveFile); }, []);
async function uploadImage(file: File) { setImage(await uploadFile(file)); }
return ( <> <input type="file" accept="image/*,video/*" onChange={(event) => { const file = event.currentTarget.files?.[0]; if (file) uploadImage(file); }} /> {imageUrl ? <img src={imageUrl} alt="" /> : null} </> );}

Store reusable uploaded assets once and reference them from each slide that uses them instead of uploading duplicate files.

Comments

Use Comments for review discussions. Store a stable slideId and percentage-based x and y coordinates in thread metadata so a pin remains attached to the same point when the 16:9 slide surface is resized. Create threads with FloatingComposer and render existing threads with useThreads.

import { CommentPin, FloatingComposer } from "@liveblocks/react-ui";
function CommentOnSlide({ slideId }: { slideId: string }) { return ( <FloatingComposer metadata={{ slideId, x: 0.5, y: 0.3 }}> <CommentPin /> </FloatingComposer> );}

The canvas Comments quickstart shows the same coordinate-based placement pattern on a draggable surface.

Permissions

Each presentation is contained inside a room in your Liveblocks app, and permission groups can set access to it. For example, your slideshow may have an editor group and a viewer group. This can be set when modifying or creating a room, for example with Liveblocks.createRoom.

await liveblocks.createRoom(`my-room-id`, {  defaultAccesses: [    // No access by default  ],  groupsAccesses: {    // "viewers" group has read access    viewers: ["*:read"],  },  usersAccesses: {    // "olivier" has write access    olivier: ["*:write"],  },});

More complex controls can be set too, learn more under Permissions.

Examples

Explore a complete slideshow with multiplayer editing, AI-generated slides, live cursors, Comments, and feeds.