Skip to content

Add a Custom Search Bar on the Top Bar

Custom search bar on the Top Bar

Scenario

Some applications need a search bar on the toolbar of a Vue PDF Component instead of inside a side panel. In this example, we replace the default page-navigation cluster with a custom debounced search bar so users can:

  • Type a keyword and have the search fire automatically (debounced)
  • See how many matches were found and which one they are currently on
  • Navigate through matches with previous or next buttons

What to Use

Use the navigatable flag of toolbarOptions to hide the built-in navigation cluster and integrate the custom search using the searchControl from the Instance API on Vue PDF Viewer.

NameObjective
navigatableHide the default page-navigation cluster so the custom bar can take its place
searchRun a text search whenever the debounced input fires
searchingShow a loading indicator while a search is in progress
searchMatchesRead the total number of matches for the current query
nextSearchMatchJump to the next match when the user clicks Next
prevSearchMatchJump to the previous match when the user clicks Previous

Code example

vue
<!-- Script -->
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from "vue";
import { VPdfViewer } from "@vue-pdf-viewer/viewer";

// Type of the search control object
type SearchControl = {
  search: (value: string) => void;
  nextSearchMatch: () => void;
  prevSearchMatch: () => void;
  searching?: boolean;
  searchMatches?: {
    totalMatches: number;
    matches: Array<{ index: number; page: number }>;
  };
};

const SEARCH_DEBOUNCE_MS = 300;

const toolbarOptions = { navigatable: false } as const;
const pdfViewerRef = ref<InstanceType<typeof VPdfViewer>>();
const searchKeyword = ref("");
const currentMatchIndex = ref(0);

// Return the search control object
const searchControl = computed(
  () => pdfViewerRef.value?.searchControl as SearchControl | undefined
);
// Return the searching state
const isSearching = computed(() => searchControl.value?.searching ?? false);
// Return the total number of matches for the current query
const totalMatches = computed(
  () => searchControl.value?.searchMatches?.totalMatches ?? 0
);

// Debounce keyword input so we don't trigger a search on every keystroke
let debounceTimer: ReturnType<typeof setTimeout> | null = null;

watch(searchKeyword, (value) => {
  if (debounceTimer) clearTimeout(debounceTimer);
  debounceTimer = setTimeout(() => {
    const trimmed = value.trim();
    searchControl.value?.search(trimmed);
    // Reset the visible counter to the first match (or hide it when empty)
    currentMatchIndex.value = trimmed ? 1 : 0;
  }, SEARCH_DEBOUNCE_MS);
});

onBeforeUnmount(() => {
  if (debounceTimer) clearTimeout(debounceTimer);
});

const goToPrevMatch = () => {
  if (totalMatches.value === 0) return;
  searchControl.value?.prevSearchMatch();
  currentMatchIndex.value =
    currentMatchIndex.value <= 1
      ? totalMatches.value
      : currentMatchIndex.value - 1;
};

const goToNextMatch = () => {
  if (totalMatches.value === 0) return;
  searchControl.value?.nextSearchMatch();
  currentMatchIndex.value =
    currentMatchIndex.value >= totalMatches.value
      ? 1
      : currentMatchIndex.value + 1;
};
</script>

<!-- Template -->
<template>
  <div id="vpv">
    <div class="pdf-viewer-wrapper">
      <!-- PDF Viewer -->
      <VPdfViewer
        ref="pdfViewerRef"
        src="https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf"
        :toolbar-options="toolbarOptions"
      />

      <!-- Custom Search Bar on the Top Bar -->
      <div class="pdf-custom-search-bar">
        <input
          v-model="searchKeyword"
          type="text"
          class="pdf-custom-search-input"
          placeholder="Search..."
        />

        <span v-if="isSearching" class="pdf-custom-search-status">
          Searching...
        </span>
        <span
          v-else-if="totalMatches > 0"
          class="pdf-custom-search-status"
        >
          {{ currentMatchIndex }} of {{ totalMatches }}
        </span>
        <span
          v-else-if="searchKeyword.trim()"
          class="pdf-custom-search-status"
        >
          No matches
        </span>

        <button
          type="button"
          class="pdf-custom-search-nav-btn"
          :disabled="totalMatches === 0"
          title="Previous match"
          @click="goToPrevMatch"
        >

        </button>
        <button
          type="button"
          class="pdf-custom-search-nav-btn"
          :disabled="totalMatches === 0"
          title="Next match"
          @click="goToNextMatch"
        >

        </button>
      </div>
    </div>
  </div>
</template>

<!-- Style -->
<style scoped>
#vpv {
  width: 100%;
  height: 700px;
  margin: 0 auto;
}

.pdf-viewer-wrapper {
  position: relative;
  width: 100%;
  height: 100%;
}

/* Sit inline with the top toolbar where the default navigation cluster used to be */
.pdf-custom-search-bar {
  position: absolute;
  top: 6px;
  left: 0;
  z-index: 15;
  display: flex;
  align-items: center;
  gap: 4px;
  height: 32px;
  padding: 0 4px;
  background: transparent;
}

.pdf-custom-search-input {
  width: 120px;
  height: 24px;
  border: 1px solid #cbd5e1;
  border-radius: 4px;
  padding: 0 8px;
  background: #fff;
  font-size: 13px;
  outline: none;
}

.pdf-custom-search-input:focus {
  border-color: #2563eb;
}

.pdf-custom-search-status {
  font-size: 12px;
  color: #475569;
  min-width: 70px;
  text-align: center;
}

.pdf-custom-search-nav-btn {
  width: 24px;
  height: 24px;
  border: 1px solid #d1d5db;
  border-radius: 4px;
  background: #fff;
  font-size: 12px;
  cursor: pointer;
}

.pdf-custom-search-nav-btn:hover:not(:disabled) {
  background: #f3f4f6;
}

.pdf-custom-search-nav-btn:disabled {
  opacity: 0.4;
  cursor: not-allowed;
}
</style>
vue
<!-- Script -->
<script setup>
import { computed, onBeforeUnmount, ref, watch } from "vue";
import { VPdfViewer } from "@vue-pdf-viewer/viewer";

const SEARCH_DEBOUNCE_MS = 300;

const toolbarOptions = { navigatable: false, searchable: false };
const pdfViewerRef = ref(null);
const searchKeyword = ref("");
const currentMatchIndex = ref(0);

// Return the search control object
const searchControl = computed(() => pdfViewerRef.value?.searchControl);
// Return the searching state
const isSearching = computed(() => searchControl.value?.searching ?? false);
// Return the total number of matches for the current query
const totalMatches = computed(
  () => searchControl.value?.searchMatches?.totalMatches ?? 0
);

// Debounce keyword input so we don't trigger a search on every keystroke
let debounceTimer = null;

watch(searchKeyword, (value) => {
  if (debounceTimer) clearTimeout(debounceTimer);
  debounceTimer = setTimeout(() => {
    const trimmed = value.trim();
    searchControl.value?.search(trimmed);
    // Reset the visible counter to the first match (or hide it when empty)
    currentMatchIndex.value = trimmed ? 1 : 0;
  }, SEARCH_DEBOUNCE_MS);
});

onBeforeUnmount(() => {
  if (debounceTimer) clearTimeout(debounceTimer);
});

const goToPrevMatch = () => {
  if (totalMatches.value === 0) return;
  searchControl.value?.prevSearchMatch();
  currentMatchIndex.value =
    currentMatchIndex.value <= 1
      ? totalMatches.value
      : currentMatchIndex.value - 1;
};

const goToNextMatch = () => {
  if (totalMatches.value === 0) return;
  searchControl.value?.nextSearchMatch();
  currentMatchIndex.value =
    currentMatchIndex.value >= totalMatches.value
      ? 1
      : currentMatchIndex.value + 1;
};
</script>

<!-- Template -->
<template>
  <div id="vpv">
    <div class="pdf-viewer-wrapper">
      <!-- PDF Viewer -->
      <VPdfViewer
        ref="pdfViewerRef"
        src="https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf"
        :toolbar-options="toolbarOptions"
      />

      <!-- Custom Search Bar on the Top Bar -->
      <div class="pdf-custom-search-bar">
        <input
          v-model="searchKeyword"
          type="text"
          class="pdf-custom-search-input"
          placeholder="Search..."
        />

        <span v-if="isSearching" class="pdf-custom-search-status">
          Searching...
        </span>
        <span
          v-else-if="totalMatches > 0"
          class="pdf-custom-search-status"
        >
          {{ currentMatchIndex }} of {{ totalMatches }}
        </span>
        <span
          v-else-if="searchKeyword.trim()"
          class="pdf-custom-search-status"
        >
          No matches
        </span>

        <button
          type="button"
          class="pdf-custom-search-nav-btn"
          :disabled="totalMatches === 0"
          title="Previous match"
          @click="goToPrevMatch"
        >

        </button>
        <button
          type="button"
          class="pdf-custom-search-nav-btn"
          :disabled="totalMatches === 0"
          title="Next match"
          @click="goToNextMatch"
        >

        </button>
      </div>
    </div>
  </div>
</template>

<!-- Style -->
<style scoped>
#vpv {
  width: 100%;
  height: 700px;
  margin: 0 auto;
}

.pdf-viewer-wrapper {
  position: relative;
  width: 100%;
  height: 100%;
}

/* Sit inline with the top toolbar where the default navigation cluster used to be */
.pdf-custom-search-bar {
  position: absolute;
  top: 6px;
  left: 0;
  z-index: 15;
  display: flex;
  align-items: center;
  gap: 4px;
  height: 32px;
  padding: 0 4px;
  background: transparent;
}

.pdf-custom-search-input {
  width: 120px;
  height: 24px;
  border: 1px solid #cbd5e1;
  border-radius: 4px;
  padding: 0 8px;
  background: #fff;
  font-size: 13px;
  outline: none;
}

.pdf-custom-search-input:focus {
  border-color: #2563eb;
}

.pdf-custom-search-status {
  font-size: 12px;
  color: #475569;
  min-width: 70px;
  text-align: center;
}

.pdf-custom-search-nav-btn {
  width: 24px;
  height: 24px;
  border: 1px solid #d1d5db;
  border-radius: 4px;
  background: #fff;
  font-size: 12px;
  cursor: pointer;
}

.pdf-custom-search-nav-btn:hover:not(:disabled) {
  background: #f3f4f6;
}

.pdf-custom-search-nav-btn:disabled {
  opacity: 0.4;
  cursor: not-allowed;
}
</style>
vue
<!-- Script -->
<script lang="ts">
import { computed, defineComponent, onBeforeUnmount, ref, watch } from "vue";
import { VPdfViewer } from "@vue-pdf-viewer/viewer";

// Type of the search control object
type SearchControl = {
  search: (value: string) => void;
  nextSearchMatch: () => void;
  prevSearchMatch: () => void;
  searching?: boolean;
  searchMatches?: {
    totalMatches: number;
    matches: Array<{ index: number; page: number }>;
  };
};

const SEARCH_DEBOUNCE_MS = 300;

export default defineComponent({
  components: {
    VPdfViewer,
  },
  setup() {
    const toolbarOptions = { navigatable: false } as const;
    const pdfViewerRef = ref<InstanceType<typeof VPdfViewer>>();
    const searchKeyword = ref("");
    const currentMatchIndex = ref(0);

    const searchControl = computed(
      () => pdfViewerRef.value?.searchControl as SearchControl | undefined
    );
    const isSearching = computed(() => searchControl.value?.searching ?? false);
    const totalMatches = computed(
      () => searchControl.value?.searchMatches?.totalMatches ?? 0
    );

    let debounceTimer: ReturnType<typeof setTimeout> | null = null;

    watch(searchKeyword, (value) => {
      if (debounceTimer) clearTimeout(debounceTimer);
      debounceTimer = setTimeout(() => {
        const trimmed = value.trim();
        searchControl.value?.search(trimmed);
        currentMatchIndex.value = trimmed ? 1 : 0;
      }, SEARCH_DEBOUNCE_MS);
    });

    onBeforeUnmount(() => {
      if (debounceTimer) clearTimeout(debounceTimer);
    });

    const goToPrevMatch = () => {
      if (totalMatches.value === 0) return;
      searchControl.value?.prevSearchMatch();
      currentMatchIndex.value =
        currentMatchIndex.value <= 1
          ? totalMatches.value
          : currentMatchIndex.value - 1;
    };

    const goToNextMatch = () => {
      if (totalMatches.value === 0) return;
      searchControl.value?.nextSearchMatch();
      currentMatchIndex.value =
        currentMatchIndex.value >= totalMatches.value
          ? 1
          : currentMatchIndex.value + 1;
    };

    return {
      toolbarOptions,
      pdfViewerRef,
      searchKeyword,
      currentMatchIndex,
      isSearching,
      totalMatches,
      goToPrevMatch,
      goToNextMatch,
    };
  },
});
</script>

<!-- Template -->
<template>
  <div id="vpv">
    <div class="pdf-viewer-wrapper">
      <!-- PDF Viewer -->
      <VPdfViewer
        ref="pdfViewerRef"
        src="https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf"
        :toolbar-options="toolbarOptions"
      />

      <!-- Custom Search Bar on the Top Bar -->
      <div class="pdf-custom-search-bar">
        <input
          v-model="searchKeyword"
          type="text"
          class="pdf-custom-search-input"
          placeholder="Search..."
        />

        <span v-if="isSearching" class="pdf-custom-search-status">
          Searching...
        </span>
        <span
          v-else-if="totalMatches > 0"
          class="pdf-custom-search-status"
        >
          {{ currentMatchIndex }} of {{ totalMatches }}
        </span>
        <span
          v-else-if="searchKeyword.trim()"
          class="pdf-custom-search-status"
        >
          No matches
        </span>

        <button
          type="button"
          class="pdf-custom-search-nav-btn"
          :disabled="totalMatches === 0"
          title="Previous match"
          @click="goToPrevMatch"
        >

        </button>
        <button
          type="button"
          class="pdf-custom-search-nav-btn"
          :disabled="totalMatches === 0"
          title="Next match"
          @click="goToNextMatch"
        >

        </button>
      </div>
    </div>
  </div>
</template>

<!-- Style -->
<style scoped>
#vpv {
  width: 100%;
  height: 700px;
  margin: 0 auto;
}

.pdf-viewer-wrapper {
  position: relative;
  width: 100%;
  height: 100%;
}

/* Sit inline with the top toolbar where the default navigation cluster used to be */
.pdf-custom-search-bar {
  position: absolute;
  top: 6px;
  left: 0;
  z-index: 15;
  display: flex;
  align-items: center;
  gap: 4px;
  height: 32px;
  padding: 0 4px;
  background: transparent;
}

.pdf-custom-search-input {
  width: 120px;
  height: 24px;
  border: 1px solid #cbd5e1;
  border-radius: 4px;
  padding: 0 8px;
  background: #fff;
  font-size: 13px;
  outline: none;
}

.pdf-custom-search-input:focus {
  border-color: #2563eb;
}

.pdf-custom-search-status {
  font-size: 12px;
  color: #475569;
  min-width: 70px;
  text-align: center;
}

.pdf-custom-search-nav-btn {
  width: 24px;
  height: 24px;
  border: 1px solid #d1d5db;
  border-radius: 4px;
  background: #fff;
  font-size: 12px;
  cursor: pointer;
}

.pdf-custom-search-nav-btn:hover:not(:disabled) {
  background: #f3f4f6;
}

.pdf-custom-search-nav-btn:disabled {
  opacity: 0.4;
  cursor: not-allowed;
}
</style>
vue
<!-- Script -->
<script>
import { computed, defineComponent, onBeforeUnmount, ref, watch } from "vue";
import { VPdfViewer } from "@vue-pdf-viewer/viewer";

const SEARCH_DEBOUNCE_MS = 300;

export default defineComponent({
  components: {
    VPdfViewer,
  },
  setup() {
    const toolbarOptions = { navigatable: false };
    const pdfViewerRef = ref(null);
    const searchKeyword = ref("");
    const currentMatchIndex = ref(0);

    const searchControl = computed(() => pdfViewerRef.value?.searchControl);
    const isSearching = computed(() => searchControl.value?.searching ?? false);
    const totalMatches = computed(
      () => searchControl.value?.searchMatches?.totalMatches ?? 0
    );

    let debounceTimer = null;

    watch(searchKeyword, (value) => {
      if (debounceTimer) clearTimeout(debounceTimer);
      debounceTimer = setTimeout(() => {
        const trimmed = value.trim();
        searchControl.value?.search(trimmed);
        currentMatchIndex.value = trimmed ? 1 : 0;
      }, SEARCH_DEBOUNCE_MS);
    });

    onBeforeUnmount(() => {
      if (debounceTimer) clearTimeout(debounceTimer);
    });

    const goToPrevMatch = () => {
      if (totalMatches.value === 0) return;
      searchControl.value?.prevSearchMatch();
      currentMatchIndex.value =
        currentMatchIndex.value <= 1
          ? totalMatches.value
          : currentMatchIndex.value - 1;
    };

    const goToNextMatch = () => {
      if (totalMatches.value === 0) return;
      searchControl.value?.nextSearchMatch();
      currentMatchIndex.value =
        currentMatchIndex.value >= totalMatches.value
          ? 1
          : currentMatchIndex.value + 1;
    };

    return {
      toolbarOptions,
      pdfViewerRef,
      searchKeyword,
      currentMatchIndex,
      isSearching,
      totalMatches,
      goToPrevMatch,
      goToNextMatch,
    };
  },
});
</script>

<!-- Template -->
<template>
  <div id="vpv">
    <div class="pdf-viewer-wrapper">
      <!-- PDF Viewer -->
      <VPdfViewer
        ref="pdfViewerRef"
        src="https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf"
        :toolbar-options="toolbarOptions"
      />

      <!-- Custom Search Bar on the Top Bar -->
      <div class="pdf-custom-search-bar">
        <input
          v-model="searchKeyword"
          type="text"
          class="pdf-custom-search-input"
          placeholder="Search..."
        />

        <span v-if="isSearching" class="pdf-custom-search-status">
          Searching...
        </span>
        <span
          v-else-if="totalMatches > 0"
          class="pdf-custom-search-status"
        >
          {{ currentMatchIndex }} of {{ totalMatches }}
        </span>
        <span
          v-else-if="searchKeyword.trim()"
          class="pdf-custom-search-status"
        >
          No matches
        </span>

        <button
          type="button"
          class="pdf-custom-search-nav-btn"
          :disabled="totalMatches === 0"
          title="Previous match"
          @click="goToPrevMatch"
        >

        </button>
        <button
          type="button"
          class="pdf-custom-search-nav-btn"
          :disabled="totalMatches === 0"
          title="Next match"
          @click="goToNextMatch"
        >

        </button>
      </div>
    </div>
  </div>
</template>

<!-- Style -->
<style scoped>
#vpv {
  width: 100%;
  height: 700px;
  margin: 0 auto;
}

.pdf-viewer-wrapper {
  position: relative;
  width: 100%;
  height: 100%;
}

/* Sit inline with the top toolbar where the default navigation cluster used to be */
.pdf-custom-search-bar {
  position: absolute;
  top: 6px;
  left: 0;
  z-index: 15;
  display: flex;
  align-items: center;
  gap: 4px;
  height: 32px;
  padding: 0 4px;
  background: transparent;
}

.pdf-custom-search-input {
  width: 120px;
  height: 24px;
  border: 1px solid #cbd5e1;
  border-radius: 4px;
  padding: 0 8px;
  background: #fff;
  font-size: 13px;
  outline: none;
}

.pdf-custom-search-input:focus {
  border-color: #2563eb;
}

.pdf-custom-search-status {
  font-size: 12px;
  color: #475569;
  min-width: 70px;
  text-align: center;
}

.pdf-custom-search-nav-btn {
  width: 24px;
  height: 24px;
  border: 1px solid #d1d5db;
  border-radius: 4px;
  background: #fff;
  font-size: 12px;
  cursor: pointer;
}

.pdf-custom-search-nav-btn:hover:not(:disabled) {
  background: #f3f4f6;
}

.pdf-custom-search-nav-btn:disabled {
  opacity: 0.4;
  cursor: not-allowed;
}
</style>