Skip to content

Jump to the Next Bbox Highlight Area



Scenario

When the PDF document is loaded, Vue PDF Viewer programmatically highlights specific regions using bounding box coordinates and allows users to jump to the next highlight area:

  1. Highlight multiple regions on page 1 (first page) with exact coordinates
  2. Highlight additional regions on page 2 (second page) with exact coordinates
  3. Each highlighted region uses a different color to distinguish different areas of interest
  4. Automatically scales highlights when users zoom in or out
  5. Allow users to jump to the next highlight area by clicking an item in the left sidebar

The bounding box coordinates define precise rectangular areas on the PDF pages, making it ideal for:

  • Highlighting table cells, form fields, or specific document sections
  • Marking regions detected by OCR or document analysis tools
  • Displaying annotations from external data sources with precise positioning
  • Visualizing search results or text/table extraction

What to Use

Use the src prop on VPdfViewer to load a PDF file. To draw bbox overlays on each page, use the #pageOverlay slot.

Slot

NameObjective
pageIndexZero-based index of the current page. Filter which highlights render on this page.
scaleCurrent scale/zoom level of the page from the viewport. Multiply PDF-point bbox values so overlays stay aligned when zoom changes.

To navigate to a page with bbox highlight, use pageControl on the viewer instance (via ref).

Instance API

NameObjective
goToPageForce the viewer to go to a certain page when the user selects a highlight from the sidebar. Uses 1-based page numbers (highlight.pageIndex + 1).
totalPagesReturn the total number of pages after the PDF loads. Used to mark highlights whose pageIndex is out of range as unavailable in the sidebar.
vue
<script setup lang="ts">
import { nextTick, ref, type CSSProperties, type Ref } from 'vue'
import { VPdfViewer } from '@vue-pdf-viewer/viewer'

// Bbox values are in PDF page space (points), origin top-left of the page.
// They are multiplied by `scale` from the pageOverlay slot when rendered.

interface BoundingBox {
  x: number
  y: number
  width: number
  height: number
}

interface HighlightDefinition {
  id: string
  pageIndex: number // zero-based; matches pageOverlay `pageIndex`
  index: number // per-page sequence; used with id for DOM scroll target
  bbox: BoundingBox
  highlightColor?: string
}

const highlightDataList: HighlightDefinition[] = [
  {
    id: 'highlight-1',
    pageIndex: 0,
    index: 0,
    bbox: { x: 70, y: 70, width: 470, height: 50 },
    highlightColor: 'rgba(255, 179, 0, 0.5)',
  },
  {
    id: 'highlight-2',
    pageIndex: 0,
    index: 1,
    bbox: { x: 45, y: 330, width: 255, height: 100 },
    highlightColor: 'rgba(0, 255, 255, 0.5)',
  },
  {
    id: 'highlight-3',
    pageIndex: 0,
    index: 2,
    bbox: { x: 310, y: 684, width: 250, height: 45 },
    highlightColor: 'rgba(0, 0, 255, 0.5)',
  },
  {
    id: 'highlight-4',
    pageIndex: 1,
    index: 0,
    bbox: { x: 310, y: 142, width: 255, height: 30 },
    highlightColor: 'rgba(255, 0, 255, 0.5)',
  },
]

// Highlights for one page; called from pageOverlay for each rendered page.
function getHighlightsForPage(pageIndex: number): HighlightDefinition[] {
  return highlightDataList.filter((h) => h.pageIndex === pageIndex)
}

const DEFAULT_HIGHLIGHT_COLOR = 'rgba(255, 165, 0, 0.5)'

// Map PDF-point bbox to CSS pixels for the current zoom (`scale` from pageOverlay).
function getBboxHighlightStyle(
  bbox: BoundingBox,
  scale: number,
  highlightColor?: string,
): CSSProperties {
  return {
    position: 'absolute',
    left: `${bbox.x * scale}px`,
    top: `${bbox.y * scale}px`,
    width: `${bbox.width * scale}px`,
    height: `${bbox.height * scale}px`,
    backgroundColor: highlightColor ?? DEFAULT_HIGHLIGHT_COLOR,
    // Overlays must not steal clicks from the PDF canvas underneath.
    pointerEvents: 'none',
  }
}


type ViewerInstance = InstanceType<typeof VPdfViewer>

// Jump to a highlight: change page, wait for overlay DOM, then 
// scroll the bbox into view.
function useHighlightNavigation(viewerRef: Ref<ViewerInstance | null>) {
  async function navigateToHighlight(highlight: HighlightDefinition): Promise<void> {
    const viewer = viewerRef.value
    if (!viewer) return

    // pageControl uses 1-based page numbers. highlight.pageIndex is 0-based.
    viewer.pageControl.goToPage(highlight.pageIndex + 1)
    // Let Vue re-render pageOverlay for the new page before we query the DOM.
    await nextTick()

    // One frame after paint so layout/scroll containers have settled (zoom, page height).
    requestAnimationFrame(() => {
      const el = document.querySelector<HTMLElement>(
        // data-* attrs are set on each .bbox-highlight in #pageOverlay (see template).
        // CSS.escape keeps ids with special characters safe in the selector.
        `[data-highlight-id="${CSS.escape(highlight.id)}"][data-highlight-index="${highlight.index}"]`,
      )
      el?.scrollIntoView({ block: 'center', behavior: 'smooth' })
    })
  }

  return { navigateToHighlight }
}

// Sidebar is inlined here so the example doc can show one self-contained App.vue file.
function isUnavailable(id: string): boolean {
  return invalidHighlightIds.value.has(id)
}

const pdfFileSource =
  'https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf'

const viewerRef = ref<ViewerInstance | null>(null)
// Highlights whose pageIndex is out of range after load (sidebar shows disabled).
const invalidHighlightIds = ref<Set<string>>(new Set())
const activeHighlightId = ref<string | null>(null)

const { navigateToHighlight } = useHighlightNavigation(viewerRef)

// After PDF metadata is ready, mark highlights that reference non-existent pages.
function onPdfLoaded() {
  const totalPages = viewerRef.value?.pageControl.totalPages ?? 0
  const invalid = new Set<string>()
  for (const highlight of highlightDataList) {
    if (highlight.pageIndex >= totalPages) {
      invalid.add(highlight.id)
    }
  }
  invalidHighlightIds.value = invalid
}

async function onSelectHighlight(highlight: HighlightDefinition) {
  if (invalidHighlightIds.value.has(highlight.id)) return
  activeHighlightId.value = highlight.id
  await navigateToHighlight(highlight)
}
</script>

<template>
  <h1>Jump to the Next Bbox Highlight Area</h1>
  <div class="app-layout">
    <aside class="highlight-sidebar">
      <p v-if="highlightDataList.length === 0" class="highlight-sidebar__empty">
        No highlights configured.
      </p>
      <ol v-else class="highlight-sidebar__list">
        <li
          v-for="highlight in highlightDataList"
          :key="highlight.id"
          class="highlight-sidebar__item"
        >
          <button
            type="button"
            class="highlight-sidebar__row"
            :class="{
              'highlight-sidebar__row--active': activeHighlightId === highlight.id,
              'highlight-sidebar__row--disabled': isUnavailable(highlight.id),
            }"
            :disabled="isUnavailable(highlight.id)"
            :title="isUnavailable(highlight.id) ? 'Page not available' : undefined"
            @click="onSelectHighlight(highlight)"
          >
            <div class="highlight-sidebar__page">page: {{ highlight.pageIndex + 1 }}</div>
            <span class="highlight-sidebar__id">id: {{ highlight.id }}</span>
            <div class="highlight-sidebar__bbox">
              bbox: x:{{ highlight.bbox.x }}, y:{{ highlight.bbox.y }}, w:{{
                highlight.bbox.width
              }}, h:{{ highlight.bbox.height }}
            </div>
            <p v-if="isUnavailable(highlight.id)" class="highlight-sidebar__unavailable">
              Page not available
            </p>
          </button>
        </li>
      </ol>
    </aside>
    <div class="viewer-panel">
      <!-- pageOverlay runs once per visible page; only render highlights for that pageIndex. -->
      <VPdfViewer ref="viewerRef" :src="pdfFileSource" @loaded="onPdfLoaded">
        <template #pageOverlay="{ pageIndex, scale }">
          <!-- data-* attributes pair with navigateToHighlight's querySelector. -->
          <div
            v-for="highlight in getHighlightsForPage(pageIndex)"
            :key="highlight.id"
            class="bbox-highlight"
            :data-highlight-id="highlight.id"
            :data-highlight-index="highlight.index"
            :style="getBboxHighlightStyle(highlight.bbox, scale, highlight.highlightColor)"
          />
        </template>
      </VPdfViewer>
    </div>
  </div>
</template>

<style scoped>
h1 {
  text-align: center;
  margin-bottom: 20px;
}

.app-layout {
  --sidebar-border: rgba(100, 100, 100, 0.35);
  display: flex;
  flex-direction: row;
  min-height: 600px;
  width: 100%;
}

.viewer-panel {
  flex: 1;
  min-width: 0;
  height: 700px;
}

.bbox-highlight {
  box-sizing: border-box;
}

.highlight-sidebar {
  width: 240px;
  flex-shrink: 0;
  padding: 8px;
  overflow: auto;
  border-right: 1px solid var(--sidebar-border, rgba(100, 100, 100, 0.35));
}

.highlight-sidebar__empty {
  margin: 12px 0;
  font-size: 12px;
  color: var(--sidebar-muted, #666);
}

.highlight-sidebar__list {
  list-style: none;
  padding: 0;
  margin: 0;
}

.highlight-sidebar__item {
  margin-top: 12px;
}

.highlight-sidebar__row {
  display: block;
  width: 100%;
  padding: 5px;
  font-size: 12px;
  text-align: left;
  cursor: pointer;
  pointer-events: auto;
  background: rgba(100, 100, 100, 0.2);
  border: none;
  border-radius: 4px;
  transition: background 0.2s ease;
}

.highlight-sidebar__row:hover:not(:disabled) {
  background: rgba(100, 100, 100, 0.35);
}

.highlight-sidebar__row--active {
  background: rgba(100, 100, 100, 0.45);
}

.highlight-sidebar__row--disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.highlight-sidebar__page {
  padding-right: 5px;
}

.highlight-sidebar__unavailable {
  margin: 4px 0 0;
  font-size: 11px;
  color: #c62828;
}
</style>
vue
<script setup>
import { nextTick, ref } from 'vue'
import { VPdfViewer } from '@vue-pdf-viewer/viewer'

// Bbox values are in PDF page space (points), origin top-left of the page.
// They are multiplied by `scale` from the pageOverlay slot when rendered.

const highlightDataList = [
  {
    id: 'highlight-1',
    pageIndex: 0,
    index: 0,
    bbox: { x: 70, y: 70, width: 470, height: 50 },
    highlightColor: 'rgba(255, 179, 0, 0.5)',
  },
  {
    id: 'highlight-2',
    pageIndex: 0,
    index: 1,
    bbox: { x: 45, y: 330, width: 255, height: 100 },
    highlightColor: 'rgba(0, 255, 255, 0.5)',
  },
  {
    id: 'highlight-3',
    pageIndex: 0,
    index: 2,
    bbox: { x: 310, y: 684, width: 250, height: 45 },
    highlightColor: 'rgba(0, 0, 255, 0.5)',
  },
  {
    id: 'highlight-4',
    pageIndex: 1,
    index: 0,
    bbox: { x: 310, y: 142, width: 255, height: 30 },
    highlightColor: 'rgba(255, 0, 255, 0.5)',
  },
]

// Highlights for one page; called from pageOverlay for each rendered page.
function getHighlightsForPage(pageIndex) {
  return highlightDataList.filter((h) => h.pageIndex === pageIndex)
}

const DEFAULT_HIGHLIGHT_COLOR = 'rgba(255, 165, 0, 0.5)'

// Map PDF-point bbox to CSS pixels for the current zoom (`scale` from pageOverlay).
function getBboxHighlightStyle(bbox, scale, highlightColor) {
  return {
    position: 'absolute',
    left: `${bbox.x * scale}px`,
    top: `${bbox.y * scale}px`,
    width: `${bbox.width * scale}px`,
    height: `${bbox.height * scale}px`,
    backgroundColor: highlightColor ?? DEFAULT_HIGHLIGHT_COLOR,
    // Overlays must not steal clicks from the PDF canvas underneath.
    pointerEvents: 'none',
  }
}

// Jump to a highlight: change page, wait for overlay DOM, then
// scroll the bbox into view.
function useHighlightNavigation(viewerRef) {
  async function navigateToHighlight(highlight) {
    const viewer = viewerRef.value
    if (!viewer) return

    // pageControl uses 1-based page numbers. highlight.pageIndex is 0-based.
    viewer.pageControl.goToPage(highlight.pageIndex + 1)
    // Let Vue re-render pageOverlay for the new page before we query the DOM.
    await nextTick()

    // One frame after paint so layout/scroll containers have settled (zoom, page height).
    requestAnimationFrame(() => {
      const el = document.querySelector(
        // data-* attrs are set on each .bbox-highlight in #pageOverlay (see template).
        // CSS.escape keeps ids with special characters safe in the selector.
        `[data-highlight-id="${CSS.escape(highlight.id)}"][data-highlight-index="${highlight.index}"]`,
      )
      el?.scrollIntoView({ block: 'center', behavior: 'smooth' })
    })
  }

  return { navigateToHighlight }
}

// Sidebar is inlined here so the example doc can show one self-contained App.vue file.
function isUnavailable(id) {
  return invalidHighlightIds.value.has(id)
}

const pdfFileSource =
  'https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf'

const viewerRef = ref(null)
// Highlights whose pageIndex is out of range after load (sidebar shows disabled).
const invalidHighlightIds = ref(new Set())
const activeHighlightId = ref(null)

const { navigateToHighlight } = useHighlightNavigation(viewerRef)

// After PDF metadata is ready, mark highlights that reference non-existent pages.
function onPdfLoaded() {
  const totalPages = viewerRef.value?.pageControl.totalPages ?? 0
  const invalid = new Set()
  for (const highlight of highlightDataList) {
    if (highlight.pageIndex >= totalPages) {
      invalid.add(highlight.id)
    }
  }
  invalidHighlightIds.value = invalid
}

async function onSelectHighlight(highlight) {
  if (invalidHighlightIds.value.has(highlight.id)) return
  activeHighlightId.value = highlight.id
  await navigateToHighlight(highlight)
}
</script>

<template>
  <h1>Jump to the Next Bbox Highlight Area</h1>
  <div class="app-layout">
    <aside class="highlight-sidebar">
      <p v-if="highlightDataList.length === 0" class="highlight-sidebar__empty">
        No highlights configured.
      </p>
      <ol v-else class="highlight-sidebar__list">
        <li
          v-for="highlight in highlightDataList"
          :key="highlight.id"
          class="highlight-sidebar__item"
        >
          <button
            type="button"
            class="highlight-sidebar__row"
            :class="{
              'highlight-sidebar__row--active': activeHighlightId === highlight.id,
              'highlight-sidebar__row--disabled': isUnavailable(highlight.id),
            }"
            :disabled="isUnavailable(highlight.id)"
            :title="isUnavailable(highlight.id) ? 'Page not available' : undefined"
            @click="onSelectHighlight(highlight)"
          >
            <div class="highlight-sidebar__page">page: {{ highlight.pageIndex + 1 }}</div>
            <span class="highlight-sidebar__id">id: {{ highlight.id }}</span>
            <div class="highlight-sidebar__bbox">
              bbox: x:{{ highlight.bbox.x }}, y:{{ highlight.bbox.y }}, w:{{
                highlight.bbox.width
              }}, h:{{ highlight.bbox.height }}
            </div>
            <p v-if="isUnavailable(highlight.id)" class="highlight-sidebar__unavailable">
              Page not available
            </p>
          </button>
        </li>
      </ol>
    </aside>
    <div class="viewer-panel">
      <!-- pageOverlay runs once per visible page; only render highlights for that pageIndex. -->
      <VPdfViewer ref="viewerRef" :src="pdfFileSource" @loaded="onPdfLoaded">
        <template #pageOverlay="{ pageIndex, scale }">
          <!-- data-* attributes pair with navigateToHighlight's querySelector. -->
          <div
            v-for="highlight in getHighlightsForPage(pageIndex)"
            :key="highlight.id"
            class="bbox-highlight"
            :data-highlight-id="highlight.id"
            :data-highlight-index="highlight.index"
            :style="getBboxHighlightStyle(highlight.bbox, scale, highlight.highlightColor)"
          />
        </template>
      </VPdfViewer>
    </div>
  </div>
</template>

<style scoped>
h1 {
  text-align: center;
  margin-bottom: 20px;
}

.app-layout {
  --sidebar-border: rgba(100, 100, 100, 0.35);
  display: flex;
  flex-direction: row;
  min-height: 600px;
  width: 100%;
}

.viewer-panel {
  flex: 1;
  min-width: 0;
  height: 700px;
}

.bbox-highlight {
  box-sizing: border-box;
}

.highlight-sidebar {
  width: 240px;
  flex-shrink: 0;
  padding: 8px;
  overflow: auto;
  border-right: 1px solid var(--sidebar-border, rgba(100, 100, 100, 0.35));
}

.highlight-sidebar__empty {
  margin: 12px 0;
  font-size: 12px;
  color: var(--sidebar-muted, #666);
}

.highlight-sidebar__list {
  list-style: none;
  padding: 0;
  margin: 0;
}

.highlight-sidebar__item {
  margin-top: 12px;
}

.highlight-sidebar__row {
  display: block;
  width: 100%;
  padding: 5px;
  font-size: 12px;
  text-align: left;
  cursor: pointer;
  pointer-events: auto;
  background: rgba(100, 100, 100, 0.2);
  border: none;
  border-radius: 4px;
  transition: background 0.2s ease;
}

.highlight-sidebar__row:hover:not(:disabled) {
  background: rgba(100, 100, 100, 0.35);
}

.highlight-sidebar__row--active {
  background: rgba(100, 100, 100, 0.45);
}

.highlight-sidebar__row--disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.highlight-sidebar__page {
  padding-right: 5px;
}

.highlight-sidebar__unavailable {
  margin: 4px 0 0;
  font-size: 11px;
  color: #c62828;
}
</style>
vue
<script lang="ts">
import { defineComponent, nextTick, ref, type CSSProperties, type Ref } from 'vue'
import { VPdfViewer } from '@vue-pdf-viewer/viewer'

interface BoundingBox {
  x: number
  y: number
  width: number
  height: number
}

interface HighlightDefinition {
  id: string
  pageIndex: number
  index: number
  bbox: BoundingBox
  highlightColor?: string
}

const highlightDataList: HighlightDefinition[] = [
  {
    id: 'highlight-1',
    pageIndex: 0,
    index: 0,
    bbox: { x: 70, y: 70, width: 470, height: 50 },
    highlightColor: 'rgba(255, 179, 0, 0.5)',
  },
  {
    id: 'highlight-2',
    pageIndex: 0,
    index: 1,
    bbox: { x: 45, y: 330, width: 255, height: 100 },
    highlightColor: 'rgba(0, 255, 255, 0.5)',
  },
  {
    id: 'highlight-3',
    pageIndex: 0,
    index: 2,
    bbox: { x: 310, y: 684, width: 250, height: 45 },
    highlightColor: 'rgba(0, 0, 255, 0.5)',
  },
  {
    id: 'highlight-4',
    pageIndex: 1,
    index: 0,
    bbox: { x: 310, y: 142, width: 255, height: 30 },
    highlightColor: 'rgba(255, 0, 255, 0.5)',
  },
]

const DEFAULT_HIGHLIGHT_COLOR = 'rgba(255, 165, 0, 0.5)'

type ViewerInstance = InstanceType<typeof VPdfViewer>

function getHighlightsForPage(pageIndex: number): HighlightDefinition[] {
  return highlightDataList.filter((h) => h.pageIndex === pageIndex)
}

function getBboxHighlightStyle(
  bbox: BoundingBox,
  scale: number,
  highlightColor?: string,
): CSSProperties {
  return {
    position: 'absolute',
    left: `${bbox.x * scale}px`,
    top: `${bbox.y * scale}px`,
    width: `${bbox.width * scale}px`,
    height: `${bbox.height * scale}px`,
    backgroundColor: highlightColor ?? DEFAULT_HIGHLIGHT_COLOR,
    pointerEvents: 'none',
  }
}

function useHighlightNavigation(viewerRef: Ref<ViewerInstance | null>) {
  async function navigateToHighlight(highlight: HighlightDefinition): Promise<void> {
    const viewer = viewerRef.value
    if (!viewer) return

    viewer.pageControl.goToPage(highlight.pageIndex + 1)
    await nextTick()

    requestAnimationFrame(() => {
      const el = document.querySelector<HTMLElement>(
        `[data-highlight-id="${CSS.escape(highlight.id)}"][data-highlight-index="${highlight.index}"]`,
      )
      el?.scrollIntoView({ block: 'center', behavior: 'smooth' })
    })
  }

  return { navigateToHighlight }
}

export default defineComponent({
  components: { VPdfViewer },
  setup() {
    const pdfFileSource =
      'https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf'

    const viewerRef = ref<ViewerInstance | null>(null)
    const invalidHighlightIds = ref<Set<string>>(new Set())
    const activeHighlightId = ref<string | null>(null)

    const { navigateToHighlight } = useHighlightNavigation(viewerRef)

    function isUnavailable(id: string): boolean {
      return invalidHighlightIds.value.has(id)
    }

    function onPdfLoaded() {
      const totalPages = viewerRef.value?.pageControl.totalPages ?? 0
      const invalid = new Set<string>()
      for (const highlight of highlightDataList) {
        if (highlight.pageIndex >= totalPages) {
          invalid.add(highlight.id)
        }
      }
      invalidHighlightIds.value = invalid
    }

    async function onSelectHighlight(highlight: HighlightDefinition) {
      if (invalidHighlightIds.value.has(highlight.id)) return
      activeHighlightId.value = highlight.id
      await navigateToHighlight(highlight)
    }

    return {
      highlightDataList,
      getHighlightsForPage,
      getBboxHighlightStyle,
      isUnavailable,
      pdfFileSource,
      viewerRef,
      activeHighlightId,
      onPdfLoaded,
      onSelectHighlight,
    }
  },
})
</script>

<template>
  <h1>Jump to the Next Bbox Highlight Area</h1>
  <div class="app-layout">
    <aside class="highlight-sidebar">
      <p v-if="highlightDataList.length === 0" class="highlight-sidebar__empty">
        No highlights configured.
      </p>
      <ol v-else class="highlight-sidebar__list">
        <li
          v-for="highlight in highlightDataList"
          :key="highlight.id"
          class="highlight-sidebar__item"
        >
          <button
            type="button"
            class="highlight-sidebar__row"
            :class="{
              'highlight-sidebar__row--active': activeHighlightId === highlight.id,
              'highlight-sidebar__row--disabled': isUnavailable(highlight.id),
            }"
            :disabled="isUnavailable(highlight.id)"
            :title="isUnavailable(highlight.id) ? 'Page not available' : undefined"
            @click="onSelectHighlight(highlight)"
          >
            <div class="highlight-sidebar__page">page: {{ highlight.pageIndex + 1 }}</div>
            <span class="highlight-sidebar__id">id: {{ highlight.id }}</span>
            <div class="highlight-sidebar__bbox">
              bbox: x:{{ highlight.bbox.x }}, y:{{ highlight.bbox.y }}, w:{{
                highlight.bbox.width
              }}, h:{{ highlight.bbox.height }}
            </div>
            <p v-if="isUnavailable(highlight.id)" class="highlight-sidebar__unavailable">
              Page not available
            </p>
          </button>
        </li>
      </ol>
    </aside>
    <div class="viewer-panel">
      <VPdfViewer ref="viewerRef" :src="pdfFileSource" @loaded="onPdfLoaded">
        <template #pageOverlay="{ pageIndex, scale }">
          <div
            v-for="highlight in getHighlightsForPage(pageIndex)"
            :key="highlight.id"
            class="bbox-highlight"
            :data-highlight-id="highlight.id"
            :data-highlight-index="highlight.index"
            :style="getBboxHighlightStyle(highlight.bbox, scale, highlight.highlightColor)"
          />
        </template>
      </VPdfViewer>
    </div>
  </div>
</template>

<style scoped>
h1 {
  text-align: center;
  margin-bottom: 20px;
}

.app-layout {
  --sidebar-border: rgba(100, 100, 100, 0.35);
  display: flex;
  flex-direction: row;
  min-height: 600px;
  width: 100%;
}

.viewer-panel {
  flex: 1;
  min-width: 0;
  height: 700px;
}

.bbox-highlight {
  box-sizing: border-box;
}

.highlight-sidebar {
  width: 240px;
  flex-shrink: 0;
  padding: 8px;
  overflow: auto;
  border-right: 1px solid var(--sidebar-border, rgba(100, 100, 100, 0.35));
}

.highlight-sidebar__empty {
  margin: 12px 0;
  font-size: 12px;
  color: var(--sidebar-muted, #666);
}

.highlight-sidebar__list {
  list-style: none;
  padding: 0;
  margin: 0;
}

.highlight-sidebar__item {
  margin-top: 12px;
}

.highlight-sidebar__row {
  display: block;
  width: 100%;
  padding: 5px;
  font-size: 12px;
  text-align: left;
  cursor: pointer;
  pointer-events: auto;
  background: rgba(100, 100, 100, 0.2);
  border: none;
  border-radius: 4px;
  transition: background 0.2s ease;
}

.highlight-sidebar__row:hover:not(:disabled) {
  background: rgba(100, 100, 100, 0.35);
}

.highlight-sidebar__row--active {
  background: rgba(100, 100, 100, 0.45);
}

.highlight-sidebar__row--disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.highlight-sidebar__page {
  padding-right: 5px;
}

.highlight-sidebar__unavailable {
  margin: 4px 0 0;
  font-size: 11px;
  color: #c62828;
}
</style>
vue
<script>
import { defineComponent, nextTick, ref } from 'vue'
import { VPdfViewer } from '@vue-pdf-viewer/viewer'

const highlightDataList = [
  {
    id: 'highlight-1',
    pageIndex: 0,
    index: 0,
    bbox: { x: 70, y: 70, width: 470, height: 50 },
    highlightColor: 'rgba(255, 179, 0, 0.5)',
  },
  {
    id: 'highlight-2',
    pageIndex: 0,
    index: 1,
    bbox: { x: 45, y: 330, width: 255, height: 100 },
    highlightColor: 'rgba(0, 255, 255, 0.5)',
  },
  {
    id: 'highlight-3',
    pageIndex: 0,
    index: 2,
    bbox: { x: 310, y: 684, width: 250, height: 45 },
    highlightColor: 'rgba(0, 0, 255, 0.5)',
  },
  {
    id: 'highlight-4',
    pageIndex: 1,
    index: 0,
    bbox: { x: 310, y: 142, width: 255, height: 30 },
    highlightColor: 'rgba(255, 0, 255, 0.5)',
  },
]

const DEFAULT_HIGHLIGHT_COLOR = 'rgba(255, 165, 0, 0.5)'

function getHighlightsForPage(pageIndex) {
  return highlightDataList.filter((h) => h.pageIndex === pageIndex)
}

function getBboxHighlightStyle(bbox, scale, highlightColor) {
  return {
    position: 'absolute',
    left: `${bbox.x * scale}px`,
    top: `${bbox.y * scale}px`,
    width: `${bbox.width * scale}px`,
    height: `${bbox.height * scale}px`,
    backgroundColor: highlightColor ?? DEFAULT_HIGHLIGHT_COLOR,
    pointerEvents: 'none',
  }
}

function useHighlightNavigation(viewerRef) {
  async function navigateToHighlight(highlight) {
    const viewer = viewerRef.value
    if (!viewer) return

    viewer.pageControl.goToPage(highlight.pageIndex + 1)
    await nextTick()

    requestAnimationFrame(() => {
      const el = document.querySelector(
        `[data-highlight-id="${CSS.escape(highlight.id)}"][data-highlight-index="${highlight.index}"]`,
      )
      el?.scrollIntoView({ block: 'center', behavior: 'smooth' })
    })
  }

  return { navigateToHighlight }
}

export default defineComponent({
  components: { VPdfViewer },
  setup() {
    const pdfFileSource =
      'https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf'

    const viewerRef = ref(null)
    const invalidHighlightIds = ref(new Set())
    const activeHighlightId = ref(null)

    const { navigateToHighlight } = useHighlightNavigation(viewerRef)

    function isUnavailable(id) {
      return invalidHighlightIds.value.has(id)
    }

    function onPdfLoaded() {
      const totalPages = viewerRef.value?.pageControl.totalPages ?? 0
      const invalid = new Set()
      for (const highlight of highlightDataList) {
        if (highlight.pageIndex >= totalPages) {
          invalid.add(highlight.id)
        }
      }
      invalidHighlightIds.value = invalid
    }

    async function onSelectHighlight(highlight) {
      if (invalidHighlightIds.value.has(highlight.id)) return
      activeHighlightId.value = highlight.id
      await navigateToHighlight(highlight)
    }

    return {
      highlightDataList,
      getHighlightsForPage,
      getBboxHighlightStyle,
      isUnavailable,
      pdfFileSource,
      viewerRef,
      activeHighlightId,
      onPdfLoaded,
      onSelectHighlight,
    }
  },
})
</script>

<template>
  <h1>Jump to the Next Bbox Highlight Area</h1>
  <div class="app-layout">
    <aside class="highlight-sidebar">
      <p v-if="highlightDataList.length === 0" class="highlight-sidebar__empty">
        No highlights configured.
      </p>
      <ol v-else class="highlight-sidebar__list">
        <li
          v-for="highlight in highlightDataList"
          :key="highlight.id"
          class="highlight-sidebar__item"
        >
          <button
            type="button"
            class="highlight-sidebar__row"
            :class="{
              'highlight-sidebar__row--active': activeHighlightId === highlight.id,
              'highlight-sidebar__row--disabled': isUnavailable(highlight.id),
            }"
            :disabled="isUnavailable(highlight.id)"
            :title="isUnavailable(highlight.id) ? 'Page not available' : undefined"
            @click="onSelectHighlight(highlight)"
          >
            <div class="highlight-sidebar__page">page: {{ highlight.pageIndex + 1 }}</div>
            <span class="highlight-sidebar__id">id: {{ highlight.id }}</span>
            <div class="highlight-sidebar__bbox">
              bbox: x:{{ highlight.bbox.x }}, y:{{ highlight.bbox.y }}, w:{{
                highlight.bbox.width
              }}, h:{{ highlight.bbox.height }}
            </div>
            <p v-if="isUnavailable(highlight.id)" class="highlight-sidebar__unavailable">
              Page not available
            </p>
          </button>
        </li>
      </ol>
    </aside>
    <div class="viewer-panel">
      <VPdfViewer ref="viewerRef" :src="pdfFileSource" @loaded="onPdfLoaded">
        <template #pageOverlay="{ pageIndex, scale }">
          <div
            v-for="highlight in getHighlightsForPage(pageIndex)"
            :key="highlight.id"
            class="bbox-highlight"
            :data-highlight-id="highlight.id"
            :data-highlight-index="highlight.index"
            :style="getBboxHighlightStyle(highlight.bbox, scale, highlight.highlightColor)"
          />
        </template>
      </VPdfViewer>
    </div>
  </div>
</template>

<style scoped>
h1 {
  text-align: center;
  margin-bottom: 20px;
}

.app-layout {
  --sidebar-border: rgba(100, 100, 100, 0.35);
  display: flex;
  flex-direction: row;
  min-height: 600px;
  width: 100%;
}

.viewer-panel {
  flex: 1;
  min-width: 0;
  height: 700px;
}

.bbox-highlight {
  box-sizing: border-box;
}

.highlight-sidebar {
  width: 240px;
  flex-shrink: 0;
  padding: 8px;
  overflow: auto;
  border-right: 1px solid var(--sidebar-border, rgba(100, 100, 100, 0.35));
}

.highlight-sidebar__empty {
  margin: 12px 0;
  font-size: 12px;
  color: var(--sidebar-muted, #666);
}

.highlight-sidebar__list {
  list-style: none;
  padding: 0;
  margin: 0;
}

.highlight-sidebar__item {
  margin-top: 12px;
}

.highlight-sidebar__row {
  display: block;
  width: 100%;
  padding: 5px;
  font-size: 12px;
  text-align: left;
  cursor: pointer;
  pointer-events: auto;
  background: rgba(100, 100, 100, 0.2);
  border: none;
  border-radius: 4px;
  transition: background 0.2s ease;
}

.highlight-sidebar__row:hover:not(:disabled) {
  background: rgba(100, 100, 100, 0.35);
}

.highlight-sidebar__row--active {
  background: rgba(100, 100, 100, 0.45);
}

.highlight-sidebar__row--disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.highlight-sidebar__page {
  padding-right: 5px;
}

.highlight-sidebar__unavailable {
  margin: 4px 0 0;
  font-size: 11px;
  color: #c62828;
}
</style>

Notes

  • goToPage uses 1-based page numbers: Add 1 to the zero-based pageIndex when calling pageControl.goToPage(highlight.pageIndex + 1).
  • pageIndex and index are zero-based: pageIndex is the page in the highlight catalog. index is the highlight’s order on that page. Use both in data-highlight-id and data-highlight-index so scrollIntoView can target the correct overlay when multiple highlights share a page.
  • Scale: Multiply every bbox coordinate (x, y, width, height) by the slot scale value so overlays stay aligned at any zoom level.
  • Pointer events: Set pointerEvents: 'none' on highlight rectangles so users can still select text and interact with the PDF underneath.
  • Invalid pages: On the @loaded event, compare each highlight’s pageIndex to totalPages and disable sidebar entries that reference missing pages.
  • Remote PDF: The sample uses an HTTPS URL. Ensure the host allows cross-origin fetch if you load PDFs from another domain.