PDF Search Panel

Full-text search across a document, with match navigation and highlighted results — docked in a sidebar or behind a toolbar icon.

"use client";

import { PdfSearchPanel } from "@/components/pdf-search-panel";

About

<PdfSearchPanel /> searches every page of the open document and lists what it finds, grouped by the page each match is on. Selecting a result scrolls the viewer to it; the match is highlighted on the page by the SearchLayer that <PdfViewerContent /> already renders.

The query is debounced as you type, and clearing the field ends the search and removes the highlights.

<PdfSearchPopover /> is the same search behind a single toolbar icon. Both ship in one file and differ only in what wraps them, so pick the shape that fits your layout and delete the other.

Installation

pnpm dlx shadcn@latest add @pdfviewcn/search-panel

Installs <PdfSearchPanel /> and <PdfSearchPopover /> together, in one file.

Usage

import { PdfSearchPanel } from "@/components/pdf-search-panel";
<PdfViewer documents={[{ url: "/sample.pdf" }]} className="h-[600px] flex-row">
  <PdfSearchPanel className="w-72 shrink-0 border-r" />
  <PdfViewerContent />
</PdfViewer>

<PdfViewer /> is a flex column by default, which stacks the panel above the document. Override it with flex-row to put them side by side, and give the panel a width — it fills its parent's height but has no width of its own.

The search plugin is part of the light tier <PdfViewer /> already registers, and scroll is part of the required tier, so nothing needs to be added to plugins.

As a toolbar popover

Where the sidebar column is better spent on the document, <PdfSearchPopover /> puts the same search behind an icon:

import { PdfSearchPopover } from "@/components/pdf-search-panel";
<PdfViewer documents={[{ url: "/sample.pdf" }]} className="h-[600px]">
  <PdfToolbar>
    <PdfPageNavigation />
    <PdfSearchPopover />
  </PdfToolbar>
  <PdfViewerContent />
</PdfViewer>

The popover's content only mounts once it's open, so the query doesn't survive being closed — highlights stay on the page while it's shut and clear when it reopens with an empty field. Use the docked panel where the query needs to persist.

One match is always the active one, and moving it is what moves the viewer. Every route to it behaves the same way:

  • Clicking a result makes it active — including the one already active, which takes you back to it rather than doing nothing.
  • The up and down buttons step to the previous and next match, wrapping at either end of the document.
  • Enter and Shift Enter do the same from the query field, so stepping through a document never needs the pointer.
  • Resolving a query selects its first match, so a search lands you on a result.

The page scrolls to put the match a little above centre, and the result list scrolls to keep the active row visible.

Opening with Find

Where the keyboard-shortcuts preset is registered, Ctrl F takes the query field: <PdfSearchPopover /> opens itself, and either shape then focuses the field and selects what's already in it, so a second press starts a new search instead of appending to the old one.

Both shapes register themselves as targets while mounted, and the Find command reports itself disabled when none are — a viewer dropped into a page of ordinary text leaves ⌘ F alone. Mounting either component is the wiring.

Highlights

The panel finds matches; it doesn't paint them. <PdfViewerContent /> renders the SearchLayer on every page, which draws the highlight and distinguishes the active one. Both read the same plugin state, so they stay in sync without being wired together.

Matches are drawn in var(--pdf-search-highlight, #fde68a) and the active one in var(--pdf-search-highlight-active, #fbbf24) — Tailwind's amber-200 and amber-400, laid down with mix-blend-mode: multiply so the glyphs stay legible through them. Like --pdf-page and --pdf-annotation-selection, they belong to the document rather than the app chrome, so they hold still across light and dark. Define either one above the viewer to recolour it:

<PdfViewer
  documents={[{ url: "/sample.pdf" }]}
  style={{ "--pdf-search-highlight-active": "#f472b6" } as React.CSSProperties}
>

If you've replaced <PdfViewerContent /> with your own render stack, add the layer back or results will navigate correctly but land on an unmarked page:

import {
  PDF_SEARCH_HIGHLIGHT_ACTIVE_COLOR,
  PDF_SEARCH_HIGHLIGHT_COLOR,
} from "@/components/pdf-viewer-content";
 
<SearchLayer
  documentId={documentId}
  pageIndex={pageIndex}
  highlightColor={PDF_SEARCH_HIGHLIGHT_COLOR}
  activeHighlightColor={PDF_SEARCH_HIGHLIGHT_ACTIVE_COLOR}
  style={{ pointerEvents: "none" }}
/>;

Drop the two colour props to fall back to the plugin's own #FFFF00 and #FFBF00.

Debouncing

debounce controls how long typing has to stop before a search runs. The default of 250 keeps a fast typist to a single pass while still feeling immediate. Raise it for documents in the hundreds of pages, where each pass costs more:

<PdfSearchPanel debounce={500} />

Examples

Default

"use client";

import { PdfSearchPanel } from "@/components/pdf-search-panel";

Accessibility

The input is labelled Search document and the match counter is an aria-live="polite" region, so the number of results is announced as they resolve rather than only being visible next to the field. Each result is a <button> inside a list, reachable with Tab, and the previous/next controls are disabled — not hidden — when there is nothing to step through, keeping tab order stable as the query changes.

Enter and Shift Enter step matches from the query field, so the common path never leaves it. Escape is deliberately left alone: inside <PdfSearchPopover /> it's what closes the popover, and a find bar that swallowed it would trap you in a search you were trying to leave.

Ctrl F reaches the field from anywhere in the viewer, so searching never needs the pointer to find the icon first — see Opening with Find.

Each page's matches are a <ul> labelled by the heading above it, so a screen reader announces "Page 3, list, 5 items" on the way in — a list rather than a <section>, which would hand out one landmark per page. The active result carries aria-current as well as data-active, so which match you're on isn't conveyed by its background alone.

Match case and match whole word are <Toggle /> controls, so their pressed state is exposed through aria-pressed rather than by colour alone.

API Reference

PdfSearchPanel

A div carrying data-slot="pdf-search-panel", containing the query field, the match controls, and the result list. Accepts every div prop.

PropTypeDefaultDescription
debouncenumber250Milliseconds to wait after typing stops before searching.

Reads the active document from <PdfViewer />'s context. Until one is loaded the panel renders in full but disabled, so the column keeps its size and tab order instead of appearing once the document resolves.

Toggling match case or whole word re-runs the current query — the flags are applied by the next search, not retroactively to results already on screen.

PdfSearchPopover

An icon button that opens the same search in a <Popover />. Accepts every <Popover /> prop, plus:

PropTypeDefaultDescription
debouncenumber250Milliseconds to wait after typing stops before searching.

The trigger carries data-slot="pdf-search-popover-trigger" and is disabled until a document is loaded. Inside, it renders the same panel at a fixed height, so everything under Styling the results applies to both.

Styling the results

SlotElementDescription
pdf-search-paneldivThe panel root.
pdf-search-result-listdivEverything below the query field.
pdf-search-result-groupdivOne page's matches, headed by its page number and match count.
pdf-search-resultbuttonA single match. Carries data-active when it's the active one.

Each result renders a snippet of the surrounding text, clamped to two lines, with the matched substring wrapped in a <mark> and ellipses where the snippet was cut. Rows stay in the order the plugin returns them, which is the order the previous and next controls step through.

The panel paints no surface of its own — it takes whichever one it's dropped on. The page headings are the exception: they stick to the top of the list as their matches scroll under them, so they need an opaque background. Name it with --pdf-search-surface, which defaults to var(--background):

<PdfSearchPanel className="[--pdf-search-surface:var(--card)]" />

Set it wherever the panel sits on something other than the page background — a <Card />, a <Sidebar />, a popover of your own. <PdfSearchPopover /> already points it at var(--popover). Light themes usually share those values, so a wrong one only shows up in dark, where the heading bands across the list.