pptx

Editing

Move, resize, and edit text in presentations.

Loading sample deck…

Editing requires two things: loading the presentation with readOnly={false}, and placing Presentation.Selection inside Presentation.Slide.

<Presentation.Root file={file} readOnly={false}>
  <Presentation.Viewport autoFit>
    <Presentation.Slide>
      <Presentation.Selection />
    </Presentation.Slide>
  </Presentation.Viewport>
</Presentation.Root>

Features

  • Move shapes by dragging them
  • Resize shapes by dragging their handles (hold Shift on a corner handle to lock the aspect ratio)
  • Edit text inline: single-click a text box, or double-click or start typing on any shape
  • Multi-select with Shift/Ctrl/Cmd+click, Ctrl/Cmd+A, or by dragging a marquee on the empty canvas
  • Delete selected shapes with Delete or Backspace
  • Nudge with the arrow keys (1 px, or 10 px while holding Shift)
  • Undo and redo with Ctrl/Cmd+Z and Ctrl/Cmd+Shift+Z (or Ctrl/Cmd+Y)

Undo and redo shortcuts are disabled by default to avoid conflicts with the surrounding app. Enable them with undoRedoShortcuts on Presentation.Selection:

<Presentation.Selection undoRedoShortcuts />

When enabled, they are bound on the document rather than the overlay, so they keep working when undo switches slides or when focus moves to a thumbnail after a keyboard reorder. Only keystrokes aimed at the presentation act on the deck: the boundary is Presentation.Root when there is one, otherwise the overlay itself. Text fields are always left alone.

For full control, leave undoRedoShortcuts disabled and call store.undo() and store.redo().

Edit callbacks

Each edit callback receives (status, error?) for undo/redo (where status is "success" | "empty") and (nodeId, error?) for node events. Check error to show a toast or log failures:

<Presentation.Selection
  onUndo={(status) => {
    if (status === "empty") toast.error("Nothing to undo");
  }}
  onRedo={(status, error) => {
    if (error) toast.error(error instanceof Error ? error.message : "Redo failed");
    else if (status === "empty") toast.error("Nothing to redo");
  }}
  onNodeTransform={(nodeId, error) => {
    if (error) toast.error("Could not move shape");
  }}
  onNodeDelete={(nodeId, error) => {
    if (error) toast.error("Could not delete shape");
  }}
  onTextChange={(nodeId, error) => {
    if (error) toast.error("Text edit failed");
  }}
/>

Programmatic edits

Use store.edit() to apply changes from outside the UI (for example, from a toolbar button). Edit operations have full undo/redo support.

import { useCreatePresentationStore } from "@diceui/pptx";

const store = useCreatePresentationStore();

// Change a shape's fill color
await store.edit({
  type: "setSolidFill",
  slideId,
  nodeId,
  color: "#ef4444",
});

// Move a shape
await store.edit({
  type: "setNodeTransform",
  slideId,
  nodeId,
  x: 100,
  y: 200,
  width: 400,
  height: 300,
});

// Edit text
await store.edit({
  type: "setTextBody",
  slideId,
  nodeId,
  paragraphs: [
    {
      runs: [{ text: "Hello world" }],
    },
  ],
});

Batch operations

Group multiple edits into a single undoable step with "batch":

await store.edit({
  type: "batch",
  ops: [
    { type: "deleteNode", slideId, nodeId: "2" },
    { type: "deleteNode", slideId, nodeId: "3" },
  ],
});

Saving

Call store.save() to get the edited presentation as a Uint8Array, then trigger a download:

async function onSave() {
  const bytes = await store.save();
  const blob = new Blob([bytes], {
    type: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
  });
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = "edited.pptx";
  a.click();
  URL.revokeObjectURL(url);
}

The saved file round-trips all untouched parts byte-for-byte. Only slides that were edited are re-serialized.

Undo / redo stack

The store maintains a linear undo/redo history:

store.canUndo(); // true if there is something to undo
store.canRedo(); // true if there is something to redo
store.undo(); // reverts the last edit; navigates to the affected slide
store.redo(); // re-applies the last undone edit

Cross-slide edits navigate to the affected slide automatically on undo/redo.

Reordering slides

Moving a slide is an edit like any other, so it is undoable and included in isDirty. The thumbnail strip deliberately ships no drag-and-drop: reordering is left to whatever sortable library you already use, and Presentation.ThumbnailItem is built to be wrapped by one. It forwards ref, merges arbitrary props and event handlers, and lets your style win over its internals so transforms animate normally.

Commit the drop as a moveSlide edit:

async function onDragEnd({ active, over }: DragEndEvent) {
  if (!over || active.id === over.id) return;
  const toIndex = slideIds.indexOf(String(over.id));
  await store.edit({ type: "moveSlide", slideId: String(active.id), toIndex });
}

A few things are worth knowing when wiring this up:

  • dnd-kit's attributes set role="button" and tabIndex={0}. Both would override what the list needs, replacing the option role and putting every thumbnail in the tab order instead of the single roving tab stop. Drop those two keys and spread the rest.
  • Give the pointer sensor a small activation distance ({ distance: 4 }), or a plain click starts a drag instead of selecting the slide.
  • store.edit() is async, so hold the dropped order in local state until it settles; otherwise the strip snaps back to the old order for a frame.
  • Clamp the drag with restrictToFirstScrollableAncestor. The strip scrolls vertically, and a transformed child still counts toward its scroll container's overflow, so an unclamped drag past the last thumbnail grows scrollHeight, which lets auto-scroll run, which grows the transform again — the list scrolls for as long as you hold the pointer at the edge.
  • Give a drag overlay's thumbnail the decorative prop. The overlay is a second copy of a slide that is still in the list, and without it the copy takes the original's place in the roving focus map, unregisters it on drop, and the deck is announced with a duplicate option.
<DragOverlay>
  {draggedId ? (
    <Presentation.ThumbnailItem decorative slideId={draggedId}>
      <Presentation.ThumbnailItemNumber />
      <Presentation.ThumbnailItemPreview />
    </Presentation.ThumbnailItem>
  ) : null}
</DragOverlay>

Render the overlay inside Presentation.ThumbnailList so the copy can reach the list context it needs to paint a real miniature. It is fixed-positioned, so the strip's overflow will not clip it.

The list also owns ↑/↓/Home/End for roving focus, so a keyboard drag sensor bound to those keys will fight it. Modifier combinations (Alt/Option+↑, for example) pass through untouched.

The demo at the top of this page wires all of that up with dnd-kit, though nothing in the approach is specific to it. Here it is in full:

presentation-editing-demo.tsx
"use client";

import * as React from "react";

import type { PresentationStore } from "@diceui/pptx";
import { useCreatePresentationStore, useHistory, usePresentation } from "@diceui/pptx";
import type { DragEndEvent, DragStartEvent, DropAnimation } from "@dnd-kit/core";
import {
  DndContext,
  DragOverlay,
  PointerSensor,
  closestCenter,
  defaultDropAnimation,
  defaultDropAnimationSideEffects,
  useSensor,
  useSensors,
} from "@dnd-kit/core";
import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers";
import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Button } from "@pptx/ui/components/button";
import { Input } from "@pptx/ui/components/input";
import { Label } from "@pptx/ui/components/label";
import {
  Presentation,
  PresentationContent,
  PresentationError,
  PresentationLoading,
  PresentationProvider,
  PresentationSelection,
  PresentationSlide,
  PresentationThumbnailItem,
  PresentationThumbnailItemNumber,
  PresentationThumbnailItemPreview,
  PresentationThumbnailList,
  PresentationViewport,
} from "@pptx/ui/components/presentation";
import { Tooltip, TooltipContent, TooltipTrigger } from "@pptx/ui/components/tooltip";
import { Redo2Icon, Undo2Icon } from "lucide-react";
import { toast } from "sonner";

import { PresentationZoomSelect } from "@/components/presentation-zoom-select";
import { DEMO_DECK_PATH } from "@/lib/constants";

/**
 * Keep the source item visible during the drop animation.
 *
 * The default side effect sets its opacity to `0`, delaying the thumbnail
 * item's focus ring from reappearing until roughly 250 ms after pointer release.
 */
const DROP_ANIMATION: DropAnimation = {
  ...defaultDropAnimation,
  sideEffects: defaultDropAnimationSideEffects({ styles: { active: {} } }),
};

export function PresentationEditingDemo() {
  const store = useCreatePresentationStore();

  React.useEffect(() => {
    fetch(DEMO_DECK_PATH)
      .then((res) => {
        // fetch resolves on 404, so an unchecked body would reach the parser as
        // an error page rather than a deck.
        if (!res.ok) throw new Error(`${DEMO_DECK_PATH}: ${res.status}.`);
        return res.arrayBuffer();
      })
      // Editing and reordering both need the source package retained.
      .then((buffer) => store.load(buffer, { readOnly: false }))
      .catch(() => {
        // Fail silently to avoid blocking the main thread.
      });
    // oxlint-disable-next-line react-hooks/exhaustive-deps -- store is a stable ref, intentionally omitted from deps
  }, []);

  return (
    <div className="not-prose flex h-100 flex-col overflow-hidden rounded-lg border">
      <PresentationProvider store={store}>
        <PresentationToolbar store={store} />
        <Presentation className="min-h-0 flex-1">
          <SortableThumbnailList store={store} />
          <PresentationContent>
            <PresentationLoading />
            <PresentationError />
            <PresentationViewport autoFit autoFitPadding={10}>
              <PresentationSlide>
                <PresentationSelection undoRedoShortcuts />
              </PresentationSlide>
            </PresentationViewport>
          </PresentationContent>
        </Presentation>
      </PresentationProvider>
    </div>
  );
}

function PresentationToolbar({ store }: { store: PresentationStore }) {
  const id = React.useId();
  const { status } = usePresentation();
  const { canUndo, canRedo, undo, redo } = useHistory();

  async function onFileChange(event: React.ChangeEvent<HTMLInputElement>) {
    const file = event.target.files?.[0];
    if (!file) return;
    try {
      await store.load(file, { readOnly: false });
    } catch {
      // Fail silently because `load()` already wrote the failure to `store.error`.
    }
  }

  return (
    <div className="flex items-center gap-2 border-b p-1.5">
      <Label htmlFor={`${id}-file`} className="sr-only">
        Open .pptx
      </Label>
      <Input
        id={`${id}-file`}
        type="file"
        accept=".pptx"
        className="h-8 max-w-56 text-xs"
        onChange={onFileChange}
      />
      <span className="min-w-0 truncate text-sm text-muted-foreground">
        {status === "ready"
          ? "Drag a shape to move it, or drag a thumbnail to reorder the deck"
          : "Loading sample deck…"}
      </span>
      <Tooltip>
        <TooltipTrigger
          render={
            <Button
              aria-label="Undo"
              variant="ghost"
              size="icon-sm"
              className="ml-auto active:not-aria-[haspopup]:translate-y-0 not-data-disabled:active:not-aria-[haspopup]:translate-y-px data-disabled:opacity-50"
              disabled={!canUndo}
              focusableWhenDisabled
              onClick={() => undo()}
            >
              <Undo2Icon />
            </Button>
          }
        />
        <TooltipContent>Undo</TooltipContent>
      </Tooltip>
      <Tooltip>
        <TooltipTrigger
          render={
            <Button
              aria-label="Redo"
              variant="ghost"
              size="icon-sm"
              className="active:not-aria-[haspopup]:translate-y-0 not-data-disabled:active:not-aria-[haspopup]:translate-y-px data-disabled:opacity-50"
              disabled={!canRedo}
              focusableWhenDisabled
              onClick={() => void redo().catch(() => toast.error("Redo failed"))}
            >
              <Redo2Icon />
            </Button>
          }
        />
        <TooltipContent>Redo</TooltipContent>
      </Tooltip>
      <PresentationZoomSelect />
    </div>
  );
}

interface SortableThumbnailListProps {
  store: PresentationStore;
}

function SortableThumbnailList({ store }: SortableThumbnailListProps) {
  const { presentation } = usePresentation();
  const slideIds = presentation?.slides.map((slide) => slide.id) ?? [];

  /**
   * Order to paint while a drop is being committed. `store.edit()` is async, so
   * without this the strip would snap back to the old order for a frame between
   * the pointer release and the edit landing.
   */
  const [pendingIds, setPendingIds] = React.useState<string[] | null>(null);
  const orderedIds = pendingIds ?? slideIds;

  const [draggedId, setDraggedId] = React.useState<string | null>(null);

  // Use PointerSensor only to prevent keyboard drag from conflicting with ArrowUp/Down roving focus.
  const sensors = useSensors(
    useSensor(PointerSensor, {
      activationConstraint: { distance: 4 },
    }),
  );

  async function onDragEnd(event: DragEndEvent) {
    const { active, over } = event;
    setDraggedId(null);
    if (!over || active.id === over.id) return;

    const slideId = String(active.id);
    const toIndex = orderedIds.indexOf(String(over.id));
    if (toIndex === -1) return;

    const fromIndex = orderedIds.indexOf(slideId);
    if (fromIndex === -1) return;

    const next = [...orderedIds];
    next.splice(fromIndex, 1);
    next.splice(toIndex, 0, slideId);
    setPendingIds(next);

    try {
      await store.edit({ type: "moveSlide", slideId, toIndex });
    } finally {
      setPendingIds(null);
    }
  }

  return (
    <DndContext
      sensors={sensors}
      collisionDetection={closestCenter}
      modifiers={[restrictToVerticalAxis, restrictToFirstScrollableAncestor]}
      onDragStart={({ active }: DragStartEvent) => setDraggedId(String(active.id))}
      onDragCancel={() => setDraggedId(null)}
      onDragEnd={onDragEnd}
    >
      <SortableContext items={orderedIds} strategy={verticalListSortingStrategy}>
        <PresentationThumbnailList className="p-1.5">
          {() => (
            <>
              {orderedIds.map((slideId) => (
                <SortableThumbnailItem key={slideId} slideId={slideId} />
              ))}
              <DragOverlay dropAnimation={DROP_ANIMATION}>
                {draggedId ? (
                  <PresentationThumbnailItem
                    decorative
                    slideId={draggedId}
                    className="h-full cursor-grabbing bg-background shadow-lg"
                  >
                    <PresentationThumbnailItemNumber />
                    <PresentationThumbnailItemPreview />
                  </PresentationThumbnailItem>
                ) : null}
              </DragOverlay>
            </>
          )}
        </PresentationThumbnailList>
      </SortableContext>
    </DndContext>
  );
}

interface SortableThumbnailItemProps {
  slideId: string;
}

function SortableThumbnailItem({ slideId }: SortableThumbnailItemProps) {
  const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
    id: slideId,
  });

  // dnd-kit sets role="button" and tabIndex={0}; both would override what the
  // list needs, replacing the `option` role and putting every thumbnail in the
  // tab order instead of the one roving tab stop.
  const { role: _role, tabIndex: _tabIndex, ...dragAttributes } = attributes;

  return (
    <PresentationThumbnailItem
      slideId={slideId}
      ref={setNodeRef}
      style={{ transform: CSS.Transform.toString(transform), transition }}
      // The overlay carries the thumbnail during the drag, so what stays in the
      // list is just the slot it will land in.
      className={isDragging ? "opacity-30" : undefined}
      onSelect={(event) => {
        // Pressing a thumbnail focuses it, and focus navigates. Suppress that
        // while dragging so the deck does not jump mid-gesture.
        if (isDragging) event.preventDefault();
      }}
      {...dragAttributes}
      {...listeners}
    >
      <PresentationThumbnailItemNumber />
      <PresentationThumbnailItemPreview />
    </PresentationThumbnailItem>
  );
}

Unsaved changes

store.isDirty() reports whether the presentation has changed since it was last saved. If the user undoes those changes and returns to the saved state, it becomes false again avoiding unnecessary unsaved changes warnings.

window.addEventListener("beforeunload", (event) => {
  if (store.isDirty()) event.preventDefault();
});

In the component tree, use useHistory:

function Toolbar() {
  const store = usePresentationStore();
  const { canUndo, canRedo, isDirty, undo, redo } = useHistory();

  return (
    <>
      <button disabled={!canUndo} onClick={undo}>
        Undo
      </button>
      <button disabled={!canRedo} onClick={() => void redo()}>
        Redo
      </button>
      <button onClick={() => void store.save()}>{isDirty ? "Save" : "Saved"}</button>
    </>
  );
}

Outside the tree, the same values come through onHistoryChange:

<Presentation.Root
  file={file}
  readOnly={false}
  onHistoryChange={({ canUndo, canRedo, isDirty }) => {
    setHasUnsavedChanges(isDirty);
  }}
/>

On this page