0

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, the way PDFSlick presents it. Both ship in one file — they share the markup and the plugin wiring, 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 pdfcn/search-panel

That installs <PdfSearchPanel /> and <PdfSearchPopover /> together, in one file. Delete whichever shape you don't render.

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 an unopened search costs nothing — no query runs and no plugin state is touched until someone clicks the icon. That also means the query doesn't survive being closed: the highlights stay on the page while the popover is shut, and clear when it's reopened 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 instead of leaving you where you started.

The page scrolls to put the match a little above centre, and the result list scrolls to keep the active row visible — the two stay in step, so the highlighted row is always the highlighted match.

Opening with ⌘F

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 whatever is already in it. The select is the point rather than an extra — pressing it on an open find bar means "let me search for something else" in every browser and editor there is, and a bare focus would leave the old query for you to clear by hand.

Both shapes register themselves as targets while mounted, and the Find command reports itself disabled when none are — so a viewer dropped into a page of ordinary text leaves F alone rather than swallowing it. Nothing to wire up; mounting either component is the wiring.

Highlights

The panel finds matches; it doesn't paint them. <PdfViewerContent /> renders the SearchLayer on every page, which is what draws the highlight over the match and distinguishes the active one. Both components 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, the yellow-match/amber-active convention every find bar shares, 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 to the app chrome and so hold still across light and dark: a page is paper in either mode. Define either one anywhere 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 ⌘F.

Each page's matches are a list labelled by the heading above it, so a screen reader announces "Page 3, list, 5 items" on the way in. That's a labelled <ul> rather than a <section> on purpose — a named section is a landmark, and a long document would hand out one 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 leading or trailing ellipses where the snippet was cut. The page number lives on the group heading rather than on every row, so a run of matches on one page reads as a run rather than as the same label repeated.

Rows stay in the order the plugin returns them, which is the order the previous and next controls step through — the list and the traversal never disagree.

Rows and page headings run edge to edge, and 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 have to be opaque, and the only way to be opaque without inventing a colour is to name the surface underneath. That's --pdf-search-surface, and it 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). In light themes those tokens share a value and nothing looks wrong either way; in dark ones they don't, and a heading left on the default bands across the list.