# How to Add Presentation Mode to Your App — Copy-Paste Prompts Included

- Tool: App Features
- Last updated: July 2026

## TL;DR

Presentation mode needs four pieces: a fullscreen trigger using document.requestFullscreen(), a slide renderer with animated transitions, keyboard and swipe navigation, and slide data stored as a JSONB array in Supabase. With Lovable or V0 you can ship a working deck presenter in 3–5 hours for $0–$25/month. All logic runs client-side; the only recurring cost is Supabase for deck storage.

## What Presentation Mode Actually Is

Presentation mode turns a web app into a full-screen stage: all chrome disappears, slides fill the viewport, and the user navigates with keyboard arrows or swipes. It is used in pitch-deck tools, interactive tutorials, onboarding flows, and product demos. The Fullscreen API and BroadcastChannel are browser-native — no third-party service needed. The real product decisions are how slide content is stored (JSONB array gives the most flexibility), how speaker notes are surfaced on a secondary monitor, and whether slides need auto-advance timing. AI tools handle the core slide renderer and Supabase wiring well in one or two prompts; the speaker notes window and keyboard listener scoping are where builds slip.

## Anatomy of the Feature

Seven components build a complete presentation mode. The Fullscreen API and BroadcastChannel are browser-native and zero cost; Framer Motion handles slide transitions; Supabase stores deck data with RLS.

- **Fullscreen Trigger Button** (ui): A button that calls document.requestFullscreen() on the presentation container element on user click. Toggle label switches between 'Present' and 'Exit'. Paired with a global keydown listener that fires Escape to call document.exitFullscreen() and F5 to enter. The fullscreenchange event updates component state so the UI stays in sync.
- **Slide Renderer** (ui): A React component that renders the current slide from a slides[] array held in local state or fetched from Supabase. Uses Framer Motion AnimatePresence with a slide-left or fade variant for slide-to-slide transitions. Reads title, body, and notes fields from each JSONB slide object. Scales content with CSS viewport units so text stays readable at any screen size.
- **Navigation Controls** (ui): Previous and Next buttons with a global keydown listener for ArrowLeft, ArrowRight, and Space. Touch and swipe support added via the react-swipeable library (onSwipedLeft / onSwipedRight handlers). Buttons are hidden when at the first or last slide rather than disabled, to keep the presenter UI uncluttered.
- **Slide Counter** (ui): A fixed-position overlay in the lower-right corner displaying currentIndex + 1 / slides.length. Updates on every navigation event. Fades in after 1 second of no interaction and fades out during active navigation to avoid visual noise during fast advancement.
- **Speaker Notes Overlay** (ui): A secondary window opened via window.open('/notes', 'speaker-notes', 'width=800,height=600') for display on a secondary monitor. Receives the current slide index from the main presentation window via BroadcastChannel API (channel name: 'presentation-sync'). Renders the notes field of the current slide alongside a miniature slide preview.
- **Slide Data Store** (data): A Supabase table storing slide decks with an ordered JSONB slides array. Each element is a JSON object with title (string), body (string), notes (string), duration_seconds (integer), and an optional image_url (Supabase Storage signed URL). Row-level security restricts all operations to the owner via auth.uid().
- **Auto-Advance Timer** (backend): A client-side setInterval that reads the current slide's duration_seconds field and advances the slide index when it elapses. The timer is configurable per slide via a small settings panel. Any user interaction (click, keydown, swipe) resets the interval countdown and shows a 'paused' badge so the presenter knows the timer is suspended.

## Data model

Two tables cover deck storage and session tracking. Run this in the Supabase SQL Editor — it is copy-paste ready.

```sql
create table public.presentations (
  id uuid primary key default gen_random_uuid(),
  user_id uuid references auth.users(id) on delete cascade not null,
  title text not null,
  slides jsonb not null default '[]'::jsonb,
  created_at timestamptz not null default now()
);

alter table public.presentations enable row level security;

create policy "Users can view own presentations"
  on public.presentations for select
  using (auth.uid() = user_id);

create policy "Users can insert own presentations"
  on public.presentations for insert
  with check (auth.uid() = user_id);

create policy "Users can update own presentations"
  on public.presentations for update
  using (auth.uid() = user_id);

create table public.slide_sessions (
  id uuid primary key default gen_random_uuid(),
  presentation_id uuid references public.presentations(id) on delete cascade not null,
  started_at timestamptz not null default now(),
  ended_at timestamptz
);

alter table public.slide_sessions enable row level security;

create policy "Users can view own sessions"
  on public.slide_sessions for select
  using (
    exists (
      select 1 from public.presentations p
      where p.id = presentation_id and p.user_id = auth.uid()
    )
  );

create policy "Users can insert own sessions"
  on public.slide_sessions for insert
  with check (
    exists (
      select 1 from public.presentations p
      where p.id = presentation_id and p.user_id = auth.uid()
    )
  );

create policy "Users can update own sessions"
  on public.slide_sessions for update
  using (
    exists (
      select 1 from public.presentations p
      where p.id = presentation_id and p.user_id = auth.uid()
    )
  );

create index presentations_user_idx
  on public.presentations (user_id, created_at desc);
```

The slides JSONB array stores each slide as {title, body, notes, duration_seconds, image_url}. The slide_sessions table tracks presentation start and end times for analytics. You can extend it with a slides_viewed integer column to measure how far presenters typically get.

## Build paths

### Lovable — fit 4/10, 3–5 hours

Lovable handles the React slide renderer, Supabase deck storage, and Fullscreen API in one prompt. Best path for teams who want auth and deck management included from the start.

1. Create a new Lovable project with Lovable Cloud enabled so the presentations table and auth are provisioned automatically
2. Paste the prompt below; Agent Mode will build the slide renderer, navigation controls, slide counter, and Supabase wiring
3. Open the published URL on a second monitor and test the speaker notes window via window.open — do not test fullscreen in the Lovable preview pane, it is sandboxed
4. Refine slide transitions in Design Mode (no credits consumed for visual-only changes)
5. Click Publish and share the presentation link with a view-only URL parameter

Starter prompt:

```
Build a presentation mode feature. Store slide decks in Supabase table 'presentations' (user_id, title, slides jsonb). Each slide in the array has fields: title, body, notes, duration_seconds, image_url. Create a /present/[id] route that: (1) loads the deck from Supabase on mount, (2) enters fullscreen via document.requestFullscreen() called only from a 'Present' button click — never on page load, (3) adds a global keydown listener for ArrowLeft (previous), ArrowRight and Space (next), and Escape (exit fullscreen) — guard all key handlers behind a presentationActive boolean so they do not fire when presentation mode is off, (4) animates slide transitions with Framer Motion AnimatePresence using a slide-left variant keyed by slide index, (5) shows a fixed overlay with currentIndex + 1 / total slides in the bottom-right corner, (6) adds swipe navigation via react-swipeable (onSwipedLeft/onSwipedRight), (7) opens a speaker notes window at /notes via window.open and sends the current slide index via BroadcastChannel named 'presentation-sync' on every navigation, (8) includes an auto-advance timer per slide reading duration_seconds — reset timer on any user interaction, show a 'Timer paused' badge when suspended, (9) hides Previous button on slide 1 and Next button on last slide rather than disabling them, (10) shows a fallback screen if the deck has exactly 0 slides, (11) if deck has exactly 1 slide hide both navigation buttons. Add a deck list page at /presentations with create, rename, and delete actions.
```

Limitations:

- Fullscreen is blocked inside the Lovable preview iframe due to sandbox restrictions — always test presentation mode on the published URL
- BroadcastChannel speaker notes window requires both windows to share the same origin; verify on the published URL
- Lovable does not persist speaker notes window state across page refreshes; instruct users to reopen the notes window if they refresh the main presentation

### V0 — fit 5/10, 2–4 hours

V0 excels at polished slide UI — clean typography scaling, dark presenter overlays, and smooth transitions. Next.js dynamic route /present/[id] handles deck loading via Server Components. Best path when quality and extensibility matter most.

1. Prompt V0 with the spec below; it will generate the SlideRenderer, NavigationControls, and SpeakerNotesWindow components
2. Add Supabase credentials in the Vars panel (NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY) and run the data model SQL in your Supabase project's SQL Editor
3. Use Design Mode to refine slide typography, background, and transition feel without spending credits
4. Deploy to production and open the published URL on a second monitor to test the window.open() speaker notes — V0 sandbox cannot open secondary windows
5. Set a Vercel environment variable NEXT_PUBLIC_SITE_URL to your production domain so the notes window resolves correctly

Starter prompt:

```
Build a presentation mode feature in Next.js App Router. Create route /present/[id] as a client component. Load the presentation from Supabase using the id param — table: presentations (id, user_id, title, slides jsonb). Each slide object: {title, body, notes, duration_seconds, image_url}. Implement: (1) a 'Present' button that calls document.requestFullscreen() on the presentation container on click, label toggles to 'Exit' on fullscreenchange event, (2) Framer Motion AnimatePresence slide-left transition keyed by slide index, (3) keydown listener for ArrowLeft/ArrowRight/Space (navigation) and Escape (exit fullscreen) — gate all handlers behind a presentationActive ref, remove listener in useEffect cleanup, (4) react-swipeable onSwipedLeft/onSwipedRight for tablet swipe support, (5) fixed overlay showing slide N of total in bottom-right corner, (6) auto-advance timer per slide reading duration_seconds — clears and resets on any user interaction, shows 'paused' badge, (7) speaker notes: a button that calls window.open('/notes', 'speaker-notes') and sends slide index via BroadcastChannel('presentation-sync') on every navigation, (8) edge case: if slides array has exactly 1 item hide both Previous and Next buttons, (9) lazy-load image_url for slide N+1 while slide N is displayed. Create /notes as a separate page that joins BroadcastChannel('presentation-sync') and renders the current slide's notes field. Include Supabase RLS so users only access their own decks.
```

Limitations:

- BroadcastChannel speaker notes requires both windows on the same origin — verify the channel name string is identical in both components
- V0 does not auto-provision Supabase; set NEXT_PUBLIC_SUPABASE_URL and credentials in Vercel Vars before deploying
- V0 sandbox cannot enter fullscreen or open secondary windows — all testing must happen on the deployed URL

### Custom — fit 3/10, 3–7 days

Custom development is necessary when presentations require live multi-user sync, audience interaction, or deeply branded themes per client. The base slide renderer is not where custom time goes — it goes into Realtime synchronization and interactive overlays.

1. Build the core slide renderer with Fullscreen API, Framer Motion, and react-swipeable — identical to the AI path, takes about half a day
2. Add Supabase Realtime channel subscription so all viewers see the same slide simultaneously as the presenter navigates
3. Implement audience features: live polls (votes stored to a poll_responses table), moderated Q&A queue, emoji reactions broadcast via Realtime Broadcast
4. Add per-client branded themes: CSS custom properties for fonts, colors, and logo watermarks loaded from a themes table keyed to user plan
5. Build reconnect logic for Realtime channel drops and a 'catching up...' UI indicator for viewers who rejoin mid-presentation

Limitations:

- Realtime sync adds channel management and reconnect complexity — budget 2–3 extra days above the base estimate
- Audience Q&A moderation and emoji flood control require rate-limiting logic beyond the basic data model

## Gotchas

- **Clicking 'Present' inside Lovable preview does nothing** — The Lovable editor wraps your app in a sandboxed iframe without the allowfullscreen attribute. Calling document.requestFullscreen() inside a restricted iframe is silently blocked by the browser — no error is thrown, the button just appears unresponsive. Fix: Test all fullscreen behavior exclusively on the published Lovable URL. Open the live link in a real browser tab on your actual domain, grant any permissions, and confirm fullscreen works on the production domain before assuming the feature is broken.
- **Speaker notes window opens but never updates as slides advance** — BroadcastChannel requires both windows to share the exact same origin. AI tools sometimes generate postMessage with a hardcoded origin string that differs between local and production environments, or use a different channel name in the notes page than in the main presenter — causing messages to never arrive. Fix: Verify the BroadcastChannel channel name ('presentation-sync') is an identical string literal in both the main presentation component and the /notes page. If using postMessage alongside BroadcastChannel, always use window.location.origin as the targetOrigin — never a hardcoded domain string.
- **Keyboard navigation triggers other app shortcuts simultaneously** — A global document.addEventListener('keydown', handler) added without a cleanup function and without an active-state guard fires for ArrowLeft, ArrowRight, Space, and Escape even when the user is not in presentation mode. This conflicts with search shortcuts, navigation shortcuts, or modal dismiss behavior elsewhere in the app. Fix: Add a presentationActive boolean ref and wrap all keyboard handler logic with an early return if it is false. Remove the event listener in the useEffect cleanup (return () => document.removeEventListener(...)) so it stops firing when the component unmounts or the user exits presentation mode.
- **Slide deck loads slowly on first open** — AI tools often fetch the full presentations row — including all JSONB slides and embedded image URLs — in a single query at mount time. If users store high-resolution images inline or as long signed URLs, the initial load stalls for several seconds on mobile connections. Fix: Store slide images as Supabase Storage signed URLs rather than inline base64 data. Lazy-load the image_url for slide N+1 while slide N is displayed using a useEffect that fires on each navigation event. The deck structure loads instantly; images trickle in just ahead of when the presenter needs them.
- **Auto-advance timer fires while the presenter is mid-sentence** — A setInterval set to duration_seconds keeps running regardless of user activity. If the presenter pauses on a slide to answer a question or read notes, the timer advances to the next slide without warning. There is no visible indicator that a countdown is running. Fix: Reset the interval timer on any click, keydown, or swipe event. Add a visible countdown badge (e.g. 'Next slide in 12s') in the presenter overlay so the countdown is visible. Display a 'Timer paused' indicator whenever the user has interacted within the last few seconds.

## Best practices

- Call document.requestFullscreen() only inside a direct user gesture handler — never on page load or in async chains; browsers silently reject it otherwise
- Store slides as a JSONB array with explicit named fields (title, body, notes, duration_seconds) rather than freeform markup — this keeps the renderer predictable and simplifies the slide editing UI
- Guard every keyboard listener behind a presentationActive boolean and clean up event listeners on unmount to prevent shortcut conflicts elsewhere in the app
- Lazy-load slide images one position ahead of the current slide — preload N+1 while N is showing to eliminate the flash of missing content during fast navigation
- Test the speaker notes BroadcastChannel on the deployed URL with two real browser windows before launch — it cannot be verified in the preview pane
- Give the auto-advance timer a visible countdown badge and reset it on any user interaction so presenters are never surprised by an unattended slide jump
- Add Supabase Storage signed URLs with short expiry (1 hour) for slide images so sharing a presentation URL does not expose permanent public asset links

## Frequently asked questions

### Can I use presentation mode offline without internet?

Yes, once the deck is loaded. All slide rendering, navigation, fullscreen entry/exit, and auto-advance run client-side. If the user loads the deck while connected, they can disconnect and continue presenting. New deck saves or speaker notes sync require connectivity, but the presentation itself does not.

### How do I show speaker notes on a second monitor?

Call window.open('/notes', 'speaker-notes', 'width=800,height=600') from the presenter controls. Drag the opened window to the secondary display. The main presentation window sends the current slide index to the notes window via BroadcastChannel API — both windows must be on the same domain. Works in Chrome, Edge, and Firefox on desktop.

### Does fullscreen work on all browsers?

The Fullscreen API works in all modern desktop browsers (Chrome, Firefox, Safari 16.4+, Edge). On iOS Safari, requestFullscreen() is not supported for arbitrary elements — the user must use the browser's native full-screen gesture. Test on Safari iOS and add a CSS fallback (position:fixed, inset:0, z-index:9999) as a near-fullscreen substitute for iOS users.

### Can I embed videos in slides?

Yes — add a video_url field to the JSONB slide object and render a video element in the slide component. The challenge is browser autoplay policy: video autoplays reliably only if it is muted. For video synchronized to slide timing, use the muted autoplay attribute and provide an unmute button as an overlay so users can opt in to audio.

### How many slides can a deck have?

No hard limit is imposed by the JSONB schema. For practical performance, decks beyond 100 slides with large image URLs should lazy-load images rather than fetching all signed URLs at once. Text-only decks of 300+ slides load in under a second on typical connections.

### Can multiple users present the same deck simultaneously?

With the standard AI-built version, each presenter controls their own slide position independently. For synchronized multi-user presenting — where all viewers see the same slide in real time — you need Supabase Realtime channel subscription to broadcast navigation events. This is a custom development requirement not covered by a single AI prompt.

### How do I share a presentation link with view-only access?

Add an is_public boolean column to the presentations table and a separate Supabase RLS policy: CREATE POLICY 'Public read' ON presentations FOR SELECT USING (is_public = true). Generate a shareable URL like /present/[id]?view=true that hides editing controls but renders all slides normally. The viewer cannot edit or delete the deck.

### Does auto-advance work on mobile?

Yes — the setInterval timer advances slides on any device. Mobile swipe navigation via react-swipeable works alongside it. The main mobile limitation is fullscreen: iOS Safari does not support requestFullscreen() for app-embedded elements. Use the CSS full-viewport positioning fallback to simulate fullscreen on iOS rather than calling requestFullscreen() directly.

---

Source: https://www.rapidevelopers.com/app-features/presentation-mode
© RapidDev — https://www.rapidevelopers.com/app-features/presentation-mode
