DEV Community

Cover image for Build a PDF Viewer in React with MUI
Bob Singor
Bob Singor

Posted on

Build a PDF Viewer in React with MUI

Not long ago, I was looking for an easy way to embed a PDF in my website. I tried many different PDF viewers, but implementing it was a real headache.

You might be in a similar situation. You are trying to add a PDF viewer to your project and want the viewer styled with the same UI framework as you are currently using (in this case MUI), but you have no idea how to do it!

Don't look further! In this article I will exactly tell you how to do that. By the end of the article you will have a PDF viewer working in React Material UI with pagination, zooming, pinch-zoom, page-rotation and different page layouts.

It will look like this:

Screenshot MUI PDF viewer

This is something that would normally take you weeks to build. But by reading this 10-minute article you will have a fully working PDF viewer and you can move on to your next task.

Let's get started.

Installation and Setup

I assume that you already have a MUI React project set up, so I will not go into details on how to do that.

In this article we will be using MUI icons, make sure that you have installed @mui/icons-material

# For PNPM use:
pnpm add @mui/icons-material

# For Yarn use:
yarn add @mui/icons-material

# For NPM use:
npm install @mui/icons-material
Enter fullscreen mode Exit fullscreen mode

We are using an open-source project called Embed PDF which basically allows you to easily Embed a PDF inside your website. In this case we are using their headless option which gives us full control over the UI.

Install the following plugins

# For PNPM use:
pnpm add @embedpdf/core @embedpdf/engines @embedpdf/plugin-loader @embedpdf/plugin-viewport @embedpdf/plugin-scroll @embedpdf/plugin-zoom @embedpdf/plugin-render @embedpdf/plugin-tiling @embedpdf/plugin-rotate @embedpdf/plugin-spread @embedpdf/plugin-fullscreen @embedpdf/plugin-export @embedpdf/plugin-interaction-manager

# For Yarn use:
yarn add @embedpdf/core @embedpdf/engines @embedpdf/plugin-loader @embedpdf/plugin-viewport @embedpdf/plugin-scroll @embedpdf/plugin-zoom @embedpdf/plugin-render @embedpdf/plugin-tiling @embedpdf/plugin-rotate @embedpdf/plugin-spread @embedpdf/plugin-fullscreen @embedpdf/plugin-export @embedpdf/plugin-interaction-manager

# For NPM use:
npm install @embedpdf/core @embedpdf/engines @embedpdf/plugin-loader @embedpdf/plugin-viewport @embedpdf/plugin-scroll @embedpdf/plugin-zoom @embedpdf/plugin-render @embedpdf/plugin-tiling @embedpdf/plugin-rotate @embedpdf/plugin-spread @embedpdf/plugin-fullscreen @embedpdf/plugin-export @embedpdf/plugin-interaction-manager
Enter fullscreen mode Exit fullscreen mode

Great, if the packages are installed it's time to initialize the PDF engine.

I am using PDFium as our PDF engine, which is open-source and maintained by Google. It is the default PDF engine used in Chrome. I chose PDFium because it's battle tested and actively maintained.

We can initialze the PDF engine hook as follows:

import { usePdfiumEngine } from '@embedpdf/engines/react';

const { engine, isLoading, error } = usePdfiumEngine({
  wasmUrl: 'https://cdn.jsdelivr.net/npm/@embedpdf/pdfium/dist/pdfium.wasm',
});
Enter fullscreen mode Exit fullscreen mode

Let me show you are simple component that loads the engine inside the EmbedPDF core

import { EmbedPDF } from "@embedpdf/core/react";
import { usePdfiumEngine } from "@embedpdf/engines/react";
import { Box, CircularProgress, Alert } from "@mui/material";

const plugins = [];

const Loading = () => {
  return (
    <Box
      sx={{
        height: "100%",
        width: "100%",
        display: "flex",
        justifyContent: "center",
        alignItems: "center",
      }}
    >
      <CircularProgress size={48} />
    </Box>
  );
};

const PdfViewer = () => {
  const { engine, isLoading, error } = usePdfiumEngine({
    wasmUrl: "https://cdn.jsdelivr.net/npm/@embedpdf/pdfium/dist/pdfium.wasm",
  });

  if (error) {
    return (
      <Alert severity="error">
        Failed to initialize PDF viewer: {error.message}
      </Alert>
    );
  }

  if (isLoading || !engine) {
    return <Loading />;
  }

  return (
    <EmbedPDF onInitialized={async () => {}} engine={engine} plugins={plugins}>
      {({ pluginsReady }) => (
        <>
          {pluginsReady ? (
            <Alert severity="success">Engine loaded and core ready</Alert>
          ) : (
            <Loading />
          )}
        </>
      )}
    </EmbedPDF>
  );
};

export default PdfViewer;
Enter fullscreen mode Exit fullscreen mode

If all set up properly we should see a message "Engine loaded and core ready" as you can see in the sandbox below:

Great the setup is done now! In the next part of this article we are going to setup a very basic headless viewer with a simple MUI toolbar.

Creating the PDF Viewer

The Embed PDF library works with plugins. We will choose for the sake of this article the most common plugins.

We will load the plugins in the array that we created already in the snippet before.

const plugins = [];
Enter fullscreen mode Exit fullscreen mode

I will first give you a short summary of the plugins that we are using. Then we will initialize/install the plugins.

@embedpdf/viewport

This plugin does manages display area, scrolling and viewport metrics

@embedpdf/plugin-scroll

This plugin is for virtualized scrolling. This means that if you have a huge PDF of 1000 pages it will not load all pages because that would probably crash your browser, it only loads that pages that are in your viewport with a bit of margin.

@embedpdf/plugin-zoom

Gives the ability to zoom-in and out on a PDF page. They have also the option to add pinch zoom for mobile and marquee zoom (basically selecting an area of the page and zooming into that)

@embedpdf/plugin-rotate

This plugin allows you to rotate backwards or forwards of the pages in the viewport. This is not for individually pages but for all pages in the viewport

@embedpdf/plugin-spread

This plugin gives you 3 common page-layouts for PDFs, single page, odd pages, even pages

@embedpdf/plugin-export

Ability to download the PDF

@embedpdf/plugin-fullscreen

Speaks for itself. Change the viewer to fullscreen.

@embedpdf/plugin-loader

This is the plugin that loads the PDF, can be a buffer on can be an URL. In our case we are showing an example with a PDF url

@embedpdf/plugin-tiling

The tiling plugin renders the PDF page. But it renders it in tiles. Which gives much better performance for higher zoom levels. If you are zoomed in it will not render the full page, it will only render the tiles that are in the viewport.

Ok those are the plugins we are going to use for our PDF viewer. Let's initialize these plugins.

// Add the lines below the rest of the imports stay the same
import { createPluginRegistration } from '@embedpdf/core';
import { LoaderPluginPackage } from "@embedpdf/plugin-loader";
import { ViewportPluginPackage } from "@embedpdf/plugin-viewport";
import { ScrollPluginPackage, ScrollStrategy } from "@embedpdf/plugin-scroll";
import { ZoomMode, ZoomPluginPackage } from "@embedpdf/plugin-zoom";
import { TilingPluginPackage } from "@embedpdf/plugin-tiling";
import { RotatePluginPackage } from "@embedpdf/plugin-rotate";
import { SpreadPluginPackage } from "@embedpdf/plugin-spread";
import { FullscreenPluginPackage } from "@embedpdf/plugin-fullscreen";
import { ExportPluginPackage } from "@embedpdf/plugin-export";
import { RenderPluginPackage } from "@embedpdf/plugin-render";

const plugins = [
  createPluginRegistration(LoaderPluginPackage, {
    loadingOptions: {
      type: "url",
      pdfFile: {
        id: "1",
        url: "https://snippet.embedpdf.com/ebook.pdf",
      },
      options: {
        mode: "full-fetch",
      },
    },
  }),
  createPluginRegistration(ViewportPluginPackage, {
    viewportGap: 10,
  }),
  createPluginRegistration(ScrollPluginPackage, {
    strategy: ScrollStrategy.Vertical,
  }),
  createPluginRegistration(RenderPluginPackage),
  createPluginRegistration(TilingPluginPackage, {
    tileSize: 768,
    overlapPx: 2.5,
    extraRings: 0,
  }),
  createPluginRegistration(ZoomPluginPackage, {
    defaultZoomLevel: ZoomMode.FitPage,
  }),
  createPluginRegistration(RotatePluginPackage),
  createPluginRegistration(SpreadPluginPackage),
  createPluginRegistration(FullscreenPluginPackage),
  createPluginRegistration(ExportPluginPackage),
]
Enter fullscreen mode Exit fullscreen mode

Great! Now let's set up the React components. First we have to import the React components for these plugins.

import { FilePicker } from "@embedpdf/plugin-loader/react";
import { Viewport } from "@embedpdf/plugin-viewport/react";
import { Scroller } from "@embedpdf/plugin-scroll/react";
import { TilingLayer } from "@embedpdf/plugin-tiling/react";
import { Rotate } from "@embedpdf/plugin-rotate/react";
import { FullscreenProvider } from "@embedpdf/plugin-fullscreen/react";
import { Download } from "@embedpdf/plugin-export/react";
import { RenderLayer } from "@embedpdf/plugin-render/react";
Enter fullscreen mode Exit fullscreen mode

Now that is done we can setup the components as follows:

<EmbedPDF onInitialized={async () => {}} engine={engine} plugins={plugins}>
  {({ pluginsReady }) => (
    <FullscreenProvider>
      <Viewport
        style={{
          width: "100%",
          height: "100%",
          flexGrow: 1,
          backgroundColor: "#f1f3f5",
          overflow: "auto",
        }}
      >
        {pluginsReady ? (
          <Scroller
            renderPage={({ pageIndex, scale, width, height, document }) => (
              <Rotate pageSize={{ width, height }}>
                <Box
                  key={document?.id}
                  sx={{
                    width,
                    height,
                    position: "relative",
                    backgroundColor: "white",
                  }}
                >
                  <RenderLayer pageIndex={pageIndex} />
                  <TilingLayer pageIndex={pageIndex} scale={scale} />
                </Box>
              </Rotate>
            )}
          />
        ) : (
          <Loading />
        )}
      </Viewport>
      <Download />
      <FilePicker />
    </FullscreenProvider>
  )}
</EmbedPDF>
Enter fullscreen mode Exit fullscreen mode

Now we have a very basic headless PDF viewer! If everything went well it should look as the example below.

Now it's time to start adding the UI components. So that we are able to control the PDF viewer from a toolbar.

Adding the MUI components

Alright, here’s where the magic happens! We’ve got our headless PDF viewer set up, but it’s not much use without some controls. Let’s add a toolbar with MUI components to handle zooming, page navigation, rotating pages, changing layouts, and more. I’ve already included the code for the Toolbar, ZoomControls, PageControls, so let’s break down how they work and fit them into our viewer.

ToggleIconButton

The ToggleIconButton is a custom MUI IconButton that changes its look when a menu is open—like a visual cue for the user. It’s used in the Toolbar and ZoomControls.

import {
  IconButtonProps as MuiIconButtonProps,
  IconButton as MuiIconButton,
  alpha,
} from "@mui/material";

interface ToggleIconButtonProps extends MuiIconButtonProps {
  isOpen: boolean;
  // Extend with MUI's IconButton props if needed
  children: React.ReactNode;
}

export const ToggleIconButton: React.FC<ToggleIconButtonProps> = ({
  isOpen,
  children,
  ...props
}) => {
  return (
    <MuiIconButton
      edge="start"
      size={"small"}
      aria-label="toggle search"
      aria-pressed={isOpen}
      {...props}
      sx={{
        bgcolor: isOpen
          ? (theme) => alpha(theme.palette.common.white, 0.24)
          : "transparent",
        "&:hover": {
          bgcolor: (theme) => alpha(theme.palette.common.white, 0.16),
        },
        transition: "background-color 120ms",
        color: "white",
        ...props.sx,
      }}
    >
      {children}
    </MuiIconButton>
  );
};

Enter fullscreen mode Exit fullscreen mode

ZoomControls

Next up, the ZoomControls let you zoom in and out of the PDF. This component uses the useZoom hook to get the current zoom level and control it. Here’s what you get:

  • A little input field where you can type a custom zoom percentage (like 150%) and hit Enter to apply it.
  • A dropdown menu with preset zoom levels (50%, 100%, 200%, etc.) and modes like “Fit to Page” or “Fit to Width”.
  • Plus and minus buttons to zoom in or out step-by-step.

The input field syncs with the zoom level using some useState and useEffect magic, and the buttons call provides?.zoomIn() or provides?.zoomOut(). The dropdown uses provides?.requestZoom() to jump to whatever you pick. Super handy for getting the perfect view!

import { useZoom } from "@embedpdf/plugin-zoom/react";
import {
  IconButton,
  Typography,
  Box,
  Menu,
  MenuItem,
  ListItemIcon,
  ListItemText,
  Divider,
  Input,
} from "@mui/material";
import AddCircleOutlineOutlinedIcon from "@mui/icons-material/AddCircleOutlineOutlined";
import RemoveCircleOutlineOutlinedIcon from "@mui/icons-material/RemoveCircleOutlineOutlined";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import WidthNormalIcon from "@mui/icons-material/WidthNormal";
import WidthFullIcon from "@mui/icons-material/WidthFull";
import { useState, MouseEvent, useEffect, FormEvent } from "react";
import { ZoomLevel, ZoomMode } from "@embedpdf/plugin-zoom";
import { SvgIconTypeMap } from "@mui/material/SvgIcon";
import { OverridableComponent } from "@mui/material/OverridableComponent";
import { ToggleIconButton } from "./ToggleIconButton";

interface ZoomModeItem {
  value: ZoomLevel;
  label: string;
  icon: OverridableComponent<SvgIconTypeMap<{}, "svg">> & {
    muiName: string;
  };
}

interface ZoomPresetItem {
  value: number;
  label: string;
}

const ZOOM_PRESETS: ZoomPresetItem[] = [
  { value: 0.5, label: "50%" },
  { value: 1, label: "100%" },
  { value: 1.5, label: "150%" },
  { value: 2, label: "200%" },
  { value: 4, label: "400%" },
  { value: 8, label: "800%" },
  { value: 16, label: "1600%" },
];

const ZOOM_MODES: ZoomModeItem[] = [
  { value: ZoomMode.FitPage, label: "Fit to Page", icon: WidthNormalIcon },
  { value: ZoomMode.FitWidth, label: "Fit to Width", icon: WidthFullIcon },
] as const;

export const ZoomControls = () => {
  const { state, provides } = useZoom();
  const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);

  // State for the controlled input
  const [inputValue, setInputValue] = useState<string>(
    Math.round(state.currentZoomLevel * 100).toString()
  );

  const open = Boolean(anchorEl);

  // Effect to sync input value with external state changes (e.g., from buttons)
  useEffect(() => {
    const zoomPercentage = Math.round(state.currentZoomLevel * 100);
    setInputValue(zoomPercentage.toString());
  }, [state.currentZoomLevel]);

  const handleClick = (event: MouseEvent<HTMLElement>) => {
    setAnchorEl(event.currentTarget);
  };

  const handleClose = () => {
    setAnchorEl(null);
  };

  const handleSelect = (value: ZoomLevel) => {
    provides?.requestZoom(value);
    handleClose();
  };

  const handleCustomZoomSubmit = (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const newZoom = parseInt(inputValue, 10);
    if (!isNaN(newZoom) && newZoom > 0) {
      provides?.requestZoom(newZoom / 100);
    }
  };

  return (
    <Box
      sx={{
        backgroundColor: "rgba(255, 255, 255, 0.2)",
        borderRadius: 1,
        pl: 1,
        pr: 0.3,
        py: 0.3,
        display: "flex",
        alignItems: "center",
        gap: 0.5,
      }}
    >
      {/* Form to handle custom zoom submission on Enter */}
      <Box
        component="form"
        onSubmit={handleCustomZoomSubmit}
        sx={{ display: "flex", alignItems: "center" }}
      >
        <Input
          value={inputValue}
          onChange={(e) => setInputValue(e.target.value.replace(/[^0-9]/g, ""))} // Allow only numbers
          disableUnderline
          inputProps={{
            "aria-label": "Custom zoom percentage",
            style: {
              textAlign: "right",
              padding: 0,
            },
          }}
          sx={{
            color: "white",
            fontWeight: 500,
            width: "35px", // Adjust width as needed
            "& .MuiInputBase-input": {
              fontFamily: "inherit",
              fontSize: "0.875rem", // Corresponds to body2 variant
            },
          }}
        />
        <Typography
          variant="body2"
          sx={{ color: "white", fontWeight: 500, mr: 0.5 }}
        >
          %
        </Typography>
      </Box>

      <ToggleIconButton
        isOpen={open}
        sx={{ color: "white", p: 0.4 }}
        onClick={handleClick}
      >
        <KeyboardArrowDownIcon fontSize="small" />
      </ToggleIconButton>
      <Menu
        anchorEl={anchorEl}
        open={open}
        onClose={handleClose}
        disablePortal
        anchorOrigin={{
          vertical: "bottom",
          horizontal: "left",
        }}
        transformOrigin={{
          vertical: "top",
          horizontal: "left",
        }}
      >
        {/* Zoom Presets */}
        {ZOOM_PRESETS.map(({ value, label }) => (
          <MenuItem
            key={value}
            onClick={() => handleSelect(value)}
            selected={Math.abs(state.currentZoomLevel - value) < 0.01}
          >
            {label}
          </MenuItem>
        ))}
        <Divider />
        {/* Zoom Modes */}
        {ZOOM_MODES.map(({ value, label, icon: Icon }) => (
          <MenuItem
            key={value}
            onClick={() => handleSelect(value)}
            selected={state.zoomLevel === value}
          >
            <ListItemIcon>
              <Icon fontSize="small" />
            </ListItemIcon>
            <ListItemText>{label}</ListItemText>
          </MenuItem>
        ))}
      </Menu>

      <IconButton
        onClick={() => provides?.zoomOut()}
        edge="start"
        size="small"
        sx={{ color: "white", p: 0.4 }}
        aria-label="zoom out"
      >
        <RemoveCircleOutlineOutlinedIcon fontSize="small" />
      </IconButton>
      <IconButton
        onClick={() => provides?.zoomIn()}
        edge="start"
        size="small"
        sx={{ color: "white", p: 0.4 }}
        aria-label="zoom in"
      >
        <AddCircleOutlineOutlinedIcon fontSize="small" />
      </IconButton>
    </Box>
  );
};

Enter fullscreen mode Exit fullscreen mode

PageControls

The PageControls handle moving between pages. It uses the useScroll hook to know the current page, total pages, and how to scroll to a specific page. You’ll see:

  • Previous and next buttons to flip through pages one at a time.
  • An input field where you can type a page number and hit Enter to jump right to it.

The buttons call scrollToPage?.({ pageNumber: currentPage - 1 }) or + 1, and the input checks if the number you typed is valid before scrolling. It’s a simple way to navigate even a huge PDF.

import { Box, IconButton, TextField, Typography } from "@mui/material";
import { useScroll } from "@embedpdf/plugin-scroll/react";
import NavigateNextIcon from "@mui/icons-material/NavigateNext";
import NavigateBeforeIcon from "@mui/icons-material/NavigateBefore";
import { useEffect, useState } from "react";

export const PageControls = () => {
  const { currentPage, totalPages, scrollToPage } = useScroll();
  const [inputValue, setInputValue] = useState<string>(currentPage.toString());

  useEffect(() => {
    setInputValue(currentPage.toString());
  }, [currentPage]);

  const handlePageChange = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);
    const pageStr = formData.get("page") as string;
    const page = parseInt(pageStr);

    if (!isNaN(page) && page >= 1 && page <= totalPages) {
      scrollToPage?.({
        pageNumber: page,
      });
    }
  };

  const handlePreviousPage = (e: React.MouseEvent<HTMLButtonElement>) => {
    e.preventDefault();
    e.currentTarget.blur();
    if (currentPage > 1) {
      scrollToPage?.({
        pageNumber: currentPage - 1,
      });
    }
  };

  const handleNextPage = (e: React.MouseEvent<HTMLButtonElement>) => {
    e.preventDefault();
    e.currentTarget.blur();
    if (currentPage < totalPages) {
      scrollToPage?.({
        pageNumber: currentPage + 1,
      });
    }
  };

  return (
    <Box
      sx={{
        backgroundColor: "rgba(255, 255, 255, 0.2)",
        borderRadius: 1,
        pl: 1,
        pr: 0.3,
        py: 0.3,
        display: "flex",
        alignItems: "center",
        gap: 0.5,
      }}
    >
      <IconButton
        onClick={handlePreviousPage}
        edge="start"
        size="small"
        sx={{ color: "white", p: 0.4 }}
        aria-label="zoom out"
      >
        <NavigateBeforeIcon fontSize="small" />
      </IconButton>
      <form
        onSubmit={handlePageChange}
        style={{ display: "flex", alignItems: "center", gap: 8 }}
      >
        <TextField
          name="page"
          value={inputValue}
          onChange={(e) => {
            const value = e.target.value.replace(/[^0-9]/g, "");
            setInputValue(value);
          }}
          sx={{
            // Target the root of the outlined input
            "& .MuiOutlinedInput-root": {
              // Style the border
              "& .MuiOutlinedInput-notchedOutline": {
                borderColor: "rgba(255, 255, 255, 0.3)", // A slightly transparent white
              },
              // Style the border on hover
              "&:hover .MuiOutlinedInput-notchedOutline": {
                borderColor: "rgba(255, 255, 255, 0.7)",
              },
              // Style the border and boxShadow when focused
              "&.Mui-focused .MuiOutlinedInput-notchedOutline": {
                borderColor: "white",
              },
            },
          }}
          slotProps={{
            input: {
              inputProps: {
                sx: {
                  width: "32px",
                  height: "20px",
                  padding: "4px",
                  textAlign: "center",
                  fontSize: "14px",
                  color: "white",
                },
              },
            },
          }}
          variant="outlined"
          size="small"
        />
        <Typography variant="body2">{totalPages}</Typography>
      </form>
      <IconButton
        onClick={handleNextPage}
        edge="start"
        size="small"
        sx={{ color: "white", p: 0.4 }}
        aria-label="zoom out"
      >
        <NavigateNextIcon fontSize="small" />
      </IconButton>
    </Box>
  );
};

Enter fullscreen mode Exit fullscreen mode

The Toolbar

The Toolbar is like the command center for our PDF viewer. It’s built with MUI’s AppBar and Toolbar components and sits at the top of the viewer. Inside, you’ll find:

  • A menu button (with a little hamburger icon) that opens options like opening a file, downloading the PDF, or toggling fullscreen.
  • A page settings button that lets you rotate pages or switch between page layouts (single, odd, or even pages).
  • Our ZoomControls and PageControls, which we’ll get to in a sec.

To make it work, the Toolbar uses hooks from EmbedPDF like useRotateCapability, useSpread, useFullscreen, useExportCapability, and useLoaderCapability. These hooks connect the buttons to the PDF viewer’s features. For example, clicking “Rotate Clockwise” calls rotateProvider?.rotateForward(), and “Download” triggers exportProvider?.download(). It’s all tied together with some React useState to handle the menus opening and closing

import { useState } from "react";
import { useFullscreen } from "@embedpdf/plugin-fullscreen/react";
import { useExportCapability } from "@embedpdf/plugin-export/react";
import { useLoaderCapability } from "@embedpdf/plugin-loader/react";
import { useRotateCapability } from "@embedpdf/plugin-rotate/react";
import { useSpread } from "@embedpdf/plugin-spread/react";
import { SpreadMode } from "@embedpdf/plugin-spread";
import {
  Box,
  Divider,
  AppBar,
  Toolbar as MuiToolbar,
  Menu,
  MenuItem,
  ListItemIcon,
  ListItemText,
  ListSubheader,
} from "@mui/material";
import MenuIcon from "@mui/icons-material/Menu";
import RotateRightIcon from "@mui/icons-material/RotateRight";
import RotateLeftIcon from "@mui/icons-material/RotateLeft";
import DescriptionOutlinedIcon from "@mui/icons-material/DescriptionOutlined";
import MenuBookOutlinedIcon from "@mui/icons-material/MenuBookOutlined";
import BookOutlinedIcon from "@mui/icons-material/BookOutlined";
import FullscreenOutlinedIcon from "@mui/icons-material/FullscreenOutlined";
import FullscreenExitOutlinedIcon from "@mui/icons-material/FullscreenExitOutlined";
import DownloadOutlinedIcon from "@mui/icons-material/DownloadOutlined";
import FileOpenOutlinedIcon from "@mui/icons-material/FileOpenOutlined";

import { ToggleIconButton } from "./ToggleIconButton";
import { PageSettingsIcon } from "./Icons";
import { ZoomControls } from "./ZoomControls";
import { PageControls } from "./PageControls";

export const Toolbar = () => {
  const { provides: rotateProvider } = useRotateCapability();
  const { spreadMode, provides: spreadProvider } = useSpread();
  const { provides: fullscreenProvider, state: fullscreenState } =
    useFullscreen();
  const { provides: exportProvider } = useExportCapability();
  const { provides: loaderProvider } = useLoaderCapability();

  // Menu state for main menu
  const [menuAnchorEl, setMenuAnchorEl] = useState<null | HTMLElement>(null);
  const menuOpen = Boolean(menuAnchorEl);

  // Menu state for page settings
  const [pageSettingsAnchorEl, setPageSettingsAnchorEl] =
    useState<null | HTMLElement>(null);
  const pageSettingsOpen = Boolean(pageSettingsAnchorEl);

  const handleMenuClick = (event: MouseEvent<HTMLElement>) => {
    setMenuAnchorEl(event.currentTarget);
  };

  const handleMenuClose = () => {
    setMenuAnchorEl(null);
  };

  const handlePageSettingsClick = (event: MouseEvent<HTMLElement>) => {
    setPageSettingsAnchorEl(event.currentTarget);
  };

  const handlePageSettingsClose = () => {
    setPageSettingsAnchorEl(null);
  };

  const handleFullscreenToggle = () => {
    fullscreenProvider?.toggleFullscreen();
    handlePageSettingsClose();
    handleMenuClose();
  };

  const handleDownload = () => {
    exportProvider?.download();
    handleMenuClose();
  };

  const handleOpenFilePicker = () => {
    loaderProvider?.openFileDialog();
    handleMenuClose();
  };

  const handleSpreadModeChange = (mode: SpreadMode) => {
    spreadProvider?.setSpreadMode(mode);
    handlePageSettingsClose();
  };

  const handleRotateForward = () => {
    rotateProvider?.rotateForward();
    setPageSettingsAnchorEl(null); // Close menu after action
  };

  const handleRotateBackward = () => {
    rotateProvider?.rotateBackward();
    setPageSettingsAnchorEl(null); // Close menu after action
  };

  return (
    <AppBar position="static">
      <MuiToolbar variant="dense" disableGutters sx={{ gap: 1.5, px: 1.5 }}>
        <ToggleIconButton isOpen={menuOpen} onClick={handleMenuClick}>
          <MenuIcon fontSize="small" />
        </ToggleIconButton>
        <Menu
          anchorEl={menuAnchorEl}
          open={menuOpen}
          onClose={handleMenuClose}
          disablePortal
          anchorOrigin={{
            vertical: "bottom",
            horizontal: "left",
          }}
          transformOrigin={{
            vertical: "top",
            horizontal: "left",
          }}
        >
          <MenuItem onClick={handleOpenFilePicker}>
            <ListItemIcon>
              <FileOpenOutlinedIcon fontSize="small" />
            </ListItemIcon>
            <ListItemText>Open File</ListItemText>
          </MenuItem>
          <MenuItem onClick={handleDownload}>
            <ListItemIcon>
              <DownloadOutlinedIcon fontSize="small" />
            </ListItemIcon>
            <ListItemText>Download</ListItemText>
          </MenuItem>
          <MenuItem onClick={handleFullscreenToggle}>
            <ListItemIcon>
              {fullscreenState.isFullscreen ? (
                <FullscreenExitOutlinedIcon fontSize="small" />
              ) : (
                <FullscreenOutlinedIcon fontSize="small" />
              )}
            </ListItemIcon>
            <ListItemText>
              {fullscreenState.isFullscreen ? "Exit Fullscreen" : "Fullscreen"}
            </ListItemText>
          </MenuItem>
        </Menu>
        <Divider
          orientation="vertical"
          flexItem
          sx={{ backgroundColor: "white", my: 1.2, opacity: 0.5 }}
        />
        <ToggleIconButton
          isOpen={pageSettingsOpen}
          onClick={handlePageSettingsClick}
        >
          <PageSettingsIcon fontSize="small" />
        </ToggleIconButton>
        <Menu
          anchorEl={pageSettingsAnchorEl}
          open={pageSettingsOpen}
          onClose={handlePageSettingsClose}
          disablePortal
          anchorOrigin={{
            vertical: "bottom",
            horizontal: "left",
          }}
          transformOrigin={{
            vertical: "top",
            horizontal: "left",
          }}
        >
          <ListSubheader>Page Orientation</ListSubheader>
          <MenuItem onClick={handleRotateForward}>
            <ListItemIcon>
              <RotateRightIcon fontSize="small" />
            </ListItemIcon>
            <ListItemText>Rotate Clockwise</ListItemText>
          </MenuItem>
          <MenuItem onClick={handleRotateBackward}>
            <ListItemIcon>
              <RotateLeftIcon fontSize="small" />
            </ListItemIcon>
            <ListItemText>Rotate Counter-clockwise</ListItemText>
          </MenuItem>
          <Divider />
          <ListSubheader>Page Layout</ListSubheader>
          <MenuItem
            onClick={() => handleSpreadModeChange(SpreadMode.None)}
            selected={spreadMode === SpreadMode.None}
          >
            <ListItemIcon>
              <DescriptionOutlinedIcon fontSize="small" />
            </ListItemIcon>
            <ListItemText>Single Page</ListItemText>
          </MenuItem>
          <MenuItem
            onClick={() => handleSpreadModeChange(SpreadMode.Odd)}
            selected={spreadMode === SpreadMode.Odd}
          >
            <ListItemIcon>
              <MenuBookOutlinedIcon fontSize="small" />
            </ListItemIcon>
            <ListItemText>Odd Pages</ListItemText>
          </MenuItem>
          <MenuItem
            onClick={() => handleSpreadModeChange(SpreadMode.Even)}
            selected={spreadMode === SpreadMode.Even}
          >
            <ListItemIcon>
              <BookOutlinedIcon fontSize="small" />
            </ListItemIcon>
            <ListItemText>Even Pages</ListItemText>
          </MenuItem>
          <Divider />
          <MenuItem onClick={handleFullscreenToggle}>
            <ListItemIcon>
              {fullscreenState.isFullscreen ? (
                <FullscreenExitOutlinedIcon fontSize="small" />
              ) : (
                <FullscreenOutlinedIcon fontSize="small" />
              )}
            </ListItemIcon>
            <ListItemText>
              {fullscreenState.isFullscreen ? "Exit Fullscreen" : "Fullscreen"}
            </ListItemText>
          </MenuItem>
        </Menu>
        <Divider
          orientation="vertical"
          flexItem
          sx={{ backgroundColor: "white", my: 1.2, opacity: 0.5 }}
        />
        <ZoomControls />
        <Divider
          orientation="vertical"
          flexItem
          sx={{ backgroundColor: "white", my: 1.2, opacity: 0.5 }}
        />
        <PageControls />
      </MuiToolbar>
    </AppBar>
  );
};

Enter fullscreen mode Exit fullscreen mode

Putting It All Together

To hook these components into our viewer, we just need to tweak the PdfViewer component a bit. Add the Toolbar right above the Viewport, and make sure it’s all wrapped nicely in the FullscreenProvider. Here’s how it looks:

<EmbedPDF onInitialized={async () => {}} engine={engine} plugins={plugins}>
  {({ pluginsReady }) => (
    <FullscreenProvider>
      <Box
        sx={{
          height: "100%",
          width: "100%",
          display: "flex",
          flexDirection: "column",
          position: "relative",
          userSelect: "none",
        }}
      >
        <Toolbar />
        <Box sx={{ display: "flex", flexGrow: 1, overflow: "hidden" }}>
          <Viewport
            style={{
              width: "100%",
              height: "100%",
              flexGrow: 1,
              backgroundColor: "#f1f3f5",
              overflow: "auto",
            }}
          >
            {pluginsReady ? (
              <Scroller
                renderPage={({ pageIndex, scale, width, height, document }) => (
                  <Rotate pageSize={{ width, height }}>
                    <Box
                      key={document?.id}
                      sx={{
                        width,
                        height,
                        position: "relative",
                        backgroundColor: "white",
                      }}
                    >
                      <RenderLayer pageIndex={pageIndex} />
                      <TilingLayer pageIndex={pageIndex} scale={scale} />
                    </Box>
                  </Rotate>
                )}
              />
            ) : (
              <Loading />
            )}
          </Viewport>
        </Box>
      </Box>
      <Download />
      <FilePicker />
    </FullscreenProvider>
  )}
</EmbedPDF>
Enter fullscreen mode Exit fullscreen mode

Check out the final result in this sandbox:

Wrapping Up

There you go! With these MUI components, you’ve got a PDF viewer that’s not only functional but also looks right at home in your React MUI project. You can zoom, flip pages, rotate, switch layouts, and more—all with a clean, familiar interface. This setup saved me tons of time, and I hope it does the same for you. If you hit any snags, the Embed PDF website is a great place to dig deeper. Now, go build something awesome with your new PDF viewer!

Top comments (0)