Claude
Skills
Sign in
Back

pdf-viewer

Included with Lifetime
$97 forever

PDF renderer with page navigation, zoom, and programmatic highlight — the core of DEBRIEF's citation UX. Use this skill when the user says "add pdf viewer", "pdf rendering", "pdf highlights", "view pdf", or "citation viewer".

Design

What this skill does


# PDF Viewer Skill

Renders PDFs with page navigation, zoom controls, and programmatic highlight overlays. Designed as the citation UX backbone: highlights are positioned as percentage-based overlays on the page canvas, and an imperative ref API lets parent components scroll to any highlight by ID.

## Prerequisites

- Next.js app with App Router (no `src/` directory)
- `add-shadcn` skill applied

## Installation

```bash
bun add react-pdf pdfjs-dist
```

## What Gets Created

```
components/
└── pdf-viewer/
    ├── pdf-viewer.tsx          # Main component (use client, react-pdf)
    ├── pdf-viewer-wrapper.tsx  # SSR-safe wrapper with dynamic import + ssr:false
    └── use-pdf-viewer.ts       # Hook for highlight state management
```

Plus a required modification to `next.config.ts`.

## Setup Steps

### Step 1: Update `next.config.ts`

Add `pdfjs-dist` to `transpilePackages` so Next.js compiles the ESM worker correctly.

Find this in `next.config.ts`:

```typescript
const nextConfig: NextConfig = {
```

Replace with:

```typescript
const nextConfig: NextConfig = {
  transpilePackages: ["pdfjs-dist"],
```

### Step 2: Create `components/pdf-viewer/use-pdf-viewer.ts`

```typescript
import { useState, useCallback } from "react";

export type PdfHighlight = {
  id: string;
  page: number; // 1-indexed
  top: number; // percentage 0-100
  left: number; // percentage 0-100
  width: number; // percentage 0-100
  height: number; // percentage 0-100
  color?: string; // default "rgba(255, 235, 59, 0.4)"
};

export type PdfViewerHandle = {
  scrollToHighlight: (id: string) => void;
  goToPage: (page: number) => void;
};

type UsePdfViewerOptions = {
  initialPage?: number;
  initialZoom?: number;
};

type UsePdfViewerReturn = {
  currentPage: number;
  zoom: number;
  highlights: PdfHighlight[];
  activeHighlightId: string | undefined;
  totalPages: number;
  setTotalPages: (n: number) => void;
  goToPage: (page: number) => void;
  nextPage: () => void;
  prevPage: () => void;
  zoomIn: () => void;
  zoomOut: () => void;
  resetZoom: () => void;
  addHighlight: (highlight: PdfHighlight) => void;
  removeHighlight: (id: string) => void;
  clearHighlights: () => void;
  setActiveHighlightId: (id: string | undefined) => void;
};

const ZOOM_STEP = 0.25;
const ZOOM_MIN = 0.5;
const ZOOM_MAX = 2.0;

export function usePdfViewer(options: UsePdfViewerOptions = {}): UsePdfViewerReturn {
  const { initialPage = 1, initialZoom = 1.0 } = options;

  const [currentPage, setCurrentPage] = useState(initialPage);
  const [totalPages, setTotalPages] = useState(0);
  const [zoom, setZoom] = useState(initialZoom);
  const [highlights, setHighlights] = useState<PdfHighlight[]>([]);
  const [activeHighlightId, setActiveHighlightId] = useState<string | undefined>(undefined);

  const goToPage = useCallback(
    (page: number) => {
      if (totalPages === 0) return;
      setCurrentPage(Math.min(Math.max(1, page), totalPages));
    },
    [totalPages]
  );

  const nextPage = useCallback(() => {
    setCurrentPage((prev) => Math.min(prev + 1, totalPages));
  }, [totalPages]);

  const prevPage = useCallback(() => {
    setCurrentPage((prev) => Math.max(prev - 1, 1));
  }, []);

  const zoomIn = useCallback(() => {
    setZoom((prev) => Math.min(parseFloat((prev + ZOOM_STEP).toFixed(2)), ZOOM_MAX));
  }, []);

  const zoomOut = useCallback(() => {
    setZoom((prev) => Math.max(parseFloat((prev - ZOOM_STEP).toFixed(2)), ZOOM_MIN));
  }, []);

  const resetZoom = useCallback(() => {
    setZoom(1.0);
  }, []);

  const addHighlight = useCallback((highlight: PdfHighlight) => {
    setHighlights((prev) => {
      const exists = prev.some((h) => h.id === highlight.id);
      if (exists) return prev;
      return [...prev, highlight];
    });
  }, []);

  const removeHighlight = useCallback((id: string) => {
    setHighlights((prev) => prev.filter((h) => h.id !== id));
  }, []);

  const clearHighlights = useCallback(() => {
    setHighlights([]);
  }, []);

  return {
    currentPage,
    zoom,
    highlights,
    activeHighlightId,
    totalPages,
    setTotalPages,
    goToPage,
    nextPage,
    prevPage,
    zoomIn,
    zoomOut,
    resetZoom,
    addHighlight,
    removeHighlight,
    clearHighlights,
    setActiveHighlightId,
  };
}
```

### Step 3: Create `components/pdf-viewer/pdf-viewer.tsx`

```typescript
"use client";

import {
  useState,
  useEffect,
  useRef,
  useImperativeHandle,
  forwardRef,
  useCallback,
  useId,
} from "react";
import { Document, Page, pdfjs } from "react-pdf";
import { MagnifyingGlassMinus, MagnifyingGlassPlus, ArrowLeft, ArrowRight } from "@phosphor-icons/react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { PdfHighlight, PdfViewerHandle } from "./use-pdf-viewer";

import "react-pdf/dist/Page/AnnotationLayer.css";
import "react-pdf/dist/Page/TextLayer.css";

pdfjs.GlobalWorkerOptions.workerSrc = new URL(
  "pdfjs-dist/build/pdf.worker.min.mjs",
  import.meta.url
).toString();

type PdfViewerProps = {
  url: string;
  highlights?: PdfHighlight[];
  activeHighlightId?: string;
  onPageChange?: (page: number) => void;
  className?: string;
};

const ZOOM_STEP = 0.25;
const ZOOM_MIN = 0.5;
const ZOOM_MAX = 2.0;
const BASE_PAGE_WIDTH = 800;

export const PdfViewer = forwardRef<PdfViewerHandle, PdfViewerProps>(
  function PdfViewer(
    { url, highlights = [], activeHighlightId, onPageChange, className },
    ref
  ) {
    const listId = useId();
    const [totalPages, setTotalPages] = useState(0);
    const [currentPage, setCurrentPage] = useState(1);
    const [zoom, setZoom] = useState(1.0);
    const containerRef = useRef<HTMLDivElement>(null);
    const highlightRefs = useRef<Map<string, HTMLDivElement>>(new Map());

    const goToPage = useCallback(
      (page: number) => {
        const clamped = Math.min(Math.max(1, page), totalPages || 1);
        setCurrentPage(clamped);
        onPageChange?.(clamped);
      },
      [totalPages, onPageChange]
    );

    const scrollToHighlight = useCallback((id: string) => {
      const el = highlightRefs.current.get(id);
      if (el) {
        el.scrollIntoView({ behavior: "smooth", block: "center" });
      }
    }, []);

    useImperativeHandle(ref, () => ({ scrollToHighlight, goToPage }), [
      scrollToHighlight,
      goToPage,
    ]);

    useEffect(() => {
      if (activeHighlightId) {
        const highlight = highlights.find((h) => h.id === activeHighlightId);
        if (highlight && highlight.page !== currentPage) {
          goToPage(highlight.page);
        }
        const tryScroll = () => {
          const el = highlightRefs.current.get(activeHighlightId);
          if (el) {
            el.scrollIntoView({ behavior: "smooth", block: "center" });
          }
        };
        const timer = setTimeout(tryScroll, 150);
        return () => clearTimeout(timer);
      }
    }, [activeHighlightId, highlights, currentPage, goToPage]);

    const pageHighlights = highlights.filter((h) => h.page === currentPage);

    const zoomIn = () =>
      setZoom((prev) => Math.min(parseFloat((prev + ZOOM_STEP).toFixed(2)), ZOOM_MAX));
    const zoomOut = () =>
      setZoom((prev) => Math.max(parseFloat((prev - ZOOM_STEP).toFixed(2)), ZOOM_MIN));
    const resetZoom = () => setZoom(1.0);

    return (
      <div className={cn("flex flex-col bg-muted rounded-lg overflow-hidden", className)}>
        {/* Page canvas area */}
        <div
          ref={containerRef}
          className="flex-1 overflow-auto flex justify-center p-4 min-h-0"
        >
          <div className="relative inline-block">
            <Document
              file={url}
              onLoadSuccess={({ numPages }) => setTotalPages(numPages)}
              loading={
                <div className="flex items-center justify-center w-[800px] h-[1000px] bg-card border border-border rounded">
                  <span className="text-muted-foreground text-sm">Loading PDF...</span>
   
Files: 1
Size: 15.3 KB
Complexity: 24/100
Category: Design

Related in Design