Build Your Own Toolbar with Quasar Framework
This guide walks through building a custom PDF viewer toolbar with Quasar and @vue-pdf-viewer/viewer. You can integrate Quasar in two ways:
- Build with Quasar using the default toolbar - Use the built-in toolbar from
VPdfViewer, control which tools are visible withtoolbar-options, and replace default toolbar icons through viewer slots with Quasar icons. - Build with Quasar using Instance API - Build a full custom toolbar UI and connect each action (zoom, pagination, search, print, download) through the
VPdfViewerinstance controls.
Build with Quasar using the default toolbar
The default toolbar layout and behavior are already provided by @vue-pdf-viewer/viewer. Use this approach when you want a quick setup with the built-in viewer experience while keeping the UI consistent with Quasar.
Step 1: Define constants and configure toolbar-options
Create your component and define a title, the PDF source URL, icon size, and a TOOLBAR_OPTIONS object. Then pass TOOLBAR_OPTIONS to VPdfViewer through :toolbar-options to control which default tools appear.
In this setup:
- Enabled: Search, page navigation, zoom, print, and download.
- Disabled: Sidebar, theme switch, fullscreen, rotate, thumbnail, and other advanced tools.
This keeps the toolbar compact and focused on common reading actions.
<script setup lang="ts">
import { VPdfViewer, type ToolbarOptions } from "@vue-pdf-viewer/viewer";
const TITLE = "Build with Quasar using the default toolbar";
const DEFAULT_PDF_URL =
"https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf";
const ICON_SIZE = "20px";
// Keep only core reading actions in the default toolbar.
const TOOLBAR_OPTIONS: ToolbarOptions = {
sidebarEnable: false,
themeSwitchable: false,
printable: true,
fullscreen: false,
downloadable: true,
newFileOpenable: false,
searchable: true,
navigatable: true,
zoomable: true,
thumbnailViewable: false,
rotatable: false,
docPropertiesEnabled: false,
pointerSwitchable: false,
jumpNavigatable: false,
scrollingSwitchable: false,
pageViewSwitchable: false,
commentPanelEnabled: false,
};
</script><script setup>
import { VPdfViewer } from "@vue-pdf-viewer/viewer";
const TITLE = "Build with Quasar using the default toolbar";
const DEFAULT_PDF_URL =
"https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf";
const ICON_SIZE = "20px";
// Keep only core reading actions in the default toolbar.
const TOOLBAR_OPTIONS = {
sidebarEnable: false,
themeSwitchable: false,
printable: true,
fullscreen: false,
downloadable: true,
newFileOpenable: false,
searchable: true,
navigatable: true,
zoomable: true,
thumbnailViewable: false,
rotatable: false,
docPropertiesEnabled: false,
pointerSwitchable: false,
jumpNavigatable: false,
scrollingSwitchable: false,
pageViewSwitchable: false,
commentPanelEnabled: false,
};
</script><script lang="ts">
import { defineComponent } from "vue";
import { VPdfViewer, type ToolbarOptions } from "@vue-pdf-viewer/viewer";
export default defineComponent({
name: "CustomizeDefaultToolbar",
components: { VPdfViewer },
data() {
return {
TITLE: "Build with Quasar using the default toolbar",
DEFAULT_PDF_URL:
"https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf",
ICON_SIZE: "20px",
// Keep only core reading actions in the default toolbar.
TOOLBAR_OPTIONS: {
sidebarEnable: false,
themeSwitchable: false,
printable: true,
fullscreen: false,
downloadable: true,
newFileOpenable: false,
searchable: true,
navigatable: true,
zoomable: true,
thumbnailViewable: false,
rotatable: false,
docPropertiesEnabled: false,
pointerSwitchable: false,
jumpNavigatable: false,
scrollingSwitchable: false,
pageViewSwitchable: false,
commentPanelEnabled: false,
} as ToolbarOptions,
};
},
});
</script><script>
import { VPdfViewer } from "@vue-pdf-viewer/viewer";
export default {
name: "CustomizeDefaultToolbar",
components: { VPdfViewer },
data() {
return {
TITLE: "Build with Quasar using the default toolbar",
DEFAULT_PDF_URL:
"https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf",
ICON_SIZE: "20px",
// Keep only core reading actions in the default toolbar.
TOOLBAR_OPTIONS: {
sidebarEnable: false,
themeSwitchable: false,
printable: true,
fullscreen: false,
downloadable: true,
newFileOpenable: false,
searchable: true,
navigatable: true,
zoomable: true,
thumbnailViewable: false,
rotatable: false,
docPropertiesEnabled: false,
pointerSwitchable: false,
jumpNavigatable: false,
scrollingSwitchable: false,
pageViewSwitchable: false,
commentPanelEnabled: false,
},
};
},
};
</script>Step 2: Override default toolbar icons with slots
Use icon slots to replace the default viewer icons with Quasar q-icon components. Each slot maps to one built-in toolbar action.
<template>
<q-layout>
<q-page-container>
<q-page padding>
<h2 class="text-center">{{ TITLE }}</h2>
<!-- Override icon slots only; keep viewer behavior from the default toolbar. -->
<VPdfViewer
style="height: 500px;"
:src="DEFAULT_PDF_URL"
:toolbar-options="TOOLBAR_OPTIONS"
>
<template #iconSearch>
<q-icon name="search" class="toolbar-icon" :size="ICON_SIZE" />
</template>
<template #iconPrevPage>
<q-icon
name="keyboard_arrow_up"
class="toolbar-icon"
:size="ICON_SIZE"
/>
</template>
<template #iconNextPage>
<q-icon
name="keyboard_arrow_down"
class="toolbar-icon"
:size="ICON_SIZE"
/>
</template>
<template #iconZoomOut>
<q-icon name="zoom_out" class="toolbar-icon" :size="ICON_SIZE" />
</template>
<template #iconZoomIn>
<q-icon name="zoom_in" class="toolbar-icon" :size="ICON_SIZE" />
</template>
<template #iconDownload>
<q-icon name="download" class="toolbar-icon" :size="ICON_SIZE" />
</template>
<template #iconPrint>
<q-icon name="print" class="toolbar-icon" :size="ICON_SIZE" />
</template>
</VPdfViewer>
</q-page>
</q-page-container>
</q-layout>
</template>Step 3: Apply Quasar primary color to toolbar icons
Add scoped styles so toolbar icon color follows Quasar's active primary color token.
<style scoped>
.toolbar-icon {
/* Use Quasar primary token so icon theme follows app color changes. */
color: var(--q-primary);
}
</style>Step 4: Put it together in one component
You can keep this full default-toolbar customization in one Vue SFC (Single File Component).
<script setup lang="ts">
import { VPdfViewer, type ToolbarOptions } from "@vue-pdf-viewer/viewer";
const TITLE = "Build with Quasar using the default toolbar";
const DEFAULT_PDF_URL =
"https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf";
const ICON_SIZE = "20px";
// Keep only core reading actions in the default toolbar.
const TOOLBAR_OPTIONS: ToolbarOptions = {
searchable: true,
navigatable: true,
zoomable: true,
printable: true,
downloadable: true,
sidebarEnable: false,
themeSwitchable: false,
fullscreen: false,
newFileOpenable: false,
thumbnailViewable: false,
rotatable: false,
docPropertiesEnabled: false,
pointerSwitchable: false,
jumpNavigatable: false,
scrollingSwitchable: false,
pageViewSwitchable: false,
commentPanelEnabled: false,
};
</script><script setup>
import { VPdfViewer } from "@vue-pdf-viewer/viewer";
const TITLE = "Build with Quasar using the default toolbar";
const DEFAULT_PDF_URL =
"https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf";
const ICON_SIZE = "20px";
// Keep only core reading actions in the default toolbar.
const TOOLBAR_OPTIONS = {
searchable: true,
navigatable: true,
zoomable: true,
printable: true,
downloadable: true,
sidebarEnable: false,
themeSwitchable: false,
fullscreen: false,
newFileOpenable: false,
thumbnailViewable: false,
rotatable: false,
docPropertiesEnabled: false,
pointerSwitchable: false,
jumpNavigatable: false,
scrollingSwitchable: false,
pageViewSwitchable: false,
commentPanelEnabled: false,
};
</script><script lang="ts">
import { defineComponent } from "vue";
import { VPdfViewer, type ToolbarOptions } from "@vue-pdf-viewer/viewer";
export default defineComponent({
name: "CustomizeDefaultToolbar",
components: { VPdfViewer },
data() {
return {
TITLE: "Build with Quasar using the default toolbar",
DEFAULT_PDF_URL:
"https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf",
ICON_SIZE: "20px",
// Keep only core reading actions in the default toolbar.
TOOLBAR_OPTIONS: {
sidebarEnable: false,
themeSwitchable: false,
printable: true,
fullscreen: false,
downloadable: true,
newFileOpenable: false,
searchable: true,
navigatable: true,
zoomable: true,
thumbnailViewable: false,
rotatable: false,
docPropertiesEnabled: false,
pointerSwitchable: false,
jumpNavigatable: false,
scrollingSwitchable: false,
pageViewSwitchable: false,
commentPanelEnabled: false,
} as ToolbarOptions,
};
},
});
</script><script>
import { VPdfViewer } from "@vue-pdf-viewer/viewer";
export default {
name: "CustomizeDefaultToolbar",
components: { VPdfViewer },
data() {
return {
TITLE: "Build with Quasar using the default toolbar",
DEFAULT_PDF_URL:
"https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf",
ICON_SIZE: "20px",
// Keep only core reading actions in the default toolbar.
TOOLBAR_OPTIONS: {
sidebarEnable: false,
themeSwitchable: false,
printable: true,
fullscreen: false,
downloadable: true,
newFileOpenable: false,
searchable: true,
navigatable: true,
zoomable: true,
thumbnailViewable: false,
rotatable: false,
docPropertiesEnabled: false,
pointerSwitchable: false,
jumpNavigatable: false,
scrollingSwitchable: false,
pageViewSwitchable: false,
commentPanelEnabled: false,
},
};
},
};
</script><template>
<q-layout>
<q-page-container>
<q-page padding>
<h2 class="text-center">{{ TITLE }}</h2>
<!-- Override icon slots only; keep viewer behavior from the default toolbar. -->
<VPdfViewer
style="height: 500px;"
:src="DEFAULT_PDF_URL"
:toolbar-options="TOOLBAR_OPTIONS"
>
<template #iconSearch>
<q-icon name="search" class="toolbar-icon" :size="ICON_SIZE" />
</template>
<template #iconPrevPage>
<q-icon
name="keyboard_arrow_up"
class="toolbar-icon"
:size="ICON_SIZE"
/>
</template>
<template #iconNextPage>
<q-icon
name="keyboard_arrow_down"
class="toolbar-icon"
:size="ICON_SIZE"
/>
</template>
<template #iconZoomOut>
<q-icon name="zoom_out" class="toolbar-icon" :size="ICON_SIZE" />
</template>
<template #iconZoomIn>
<q-icon name="zoom_in" class="toolbar-icon" :size="ICON_SIZE" />
</template>
<template #iconDownload>
<q-icon name="download" class="toolbar-icon" :size="ICON_SIZE" />
</template>
<template #iconPrint>
<q-icon name="print" class="toolbar-icon" :size="ICON_SIZE" />
</template>
</VPdfViewer>
</q-page>
</q-page-container>
</q-layout>
</template>Notes
- Keep the default toolbar when you only need visibility control and icon customization.
- Override only icon slots to match Quasar style while preserving built-in behavior.
Build with Quasar using Instance API
This approach builds the entire toolbar yourself and controls the viewer through VPdfViewer instance APIs.
Instance controls to use
The Instance API version relies on these controls from VPdfViewer:
| Toolbar feature | Instance control | Objective |
|---|---|---|
| Zoom | zoomControl | Manage zoom level state and update viewer scale. |
| Pagination | pageControl | Track current or total pages and perform page navigation. |
| Search | searchControl | Execute keyword search and navigate match results. |
printControl | Start or cancel print flow and report print lifecycle events. | |
| Download | downloadControl | Trigger file download and report download completion or failure. |
Step 1: Create the viewer ref and control accessors
Use a ref for VPdfViewer and expose each instance control (zoomControl, pageControl, searchControl, printControl, and downloadControl) with computed accessors.
<script setup lang="ts">
import { computed, ref } from "vue";
import { VPdfViewer } from "@vue-pdf-viewer/viewer";
const vpvRef = ref<InstanceType<typeof VPdfViewer>>();
// Each computed getter safely exposes a viewer control once the ref is mounted.
const zoomControl = computed(() => vpvRef.value?.zoomControl);
const pageControl = computed(() => vpvRef.value?.pageControl);
const searchControl = computed(() => vpvRef.value?.searchControl);
const printControl = computed(() => vpvRef.value?.printControl);
const downloadControl = computed(() => vpvRef.value?.downloadControl);
</script><script setup>
import { computed, ref } from "vue";
const vpvRef = ref();
// Each computed getter safely exposes a viewer control once the ref is mounted.
const zoomControl = computed(() => vpvRef.value?.zoomControl);
const pageControl = computed(() => vpvRef.value?.pageControl);
const searchControl = computed(() => vpvRef.value?.searchControl);
const printControl = computed(() => vpvRef.value?.printControl);
const downloadControl = computed(() => vpvRef.value?.downloadControl);
</script><script lang="ts">
import { defineComponent } from "vue";
import { VPdfViewer } from "@vue-pdf-viewer/viewer";
export default defineComponent({
components: { VPdfViewer },
computed: {
zoomControl() {
// Read from template ref safely before the viewer is mounted.
return (this.$refs.vpvRef as any)?.zoomControl;
},
pageControl() {
return (this.$refs.vpvRef as any)?.pageControl;
},
searchControl() {
return (this.$refs.vpvRef as any)?.searchControl;
},
printControl() {
return (this.$refs.vpvRef as any)?.printControl;
},
downloadControl() {
return (this.$refs.vpvRef as any)?.downloadControl;
},
},
});
</script><script>
import { VPdfViewer } from "@vue-pdf-viewer/viewer";
export default {
components: { VPdfViewer },
computed: {
zoomControl() {
// Read from template ref safely before the viewer is mounted.
return this.$refs.vpvRef?.zoomControl;
},
pageControl() {
return this.$refs.vpvRef?.pageControl;
},
searchControl() {
return this.$refs.vpvRef?.searchControl;
},
printControl() {
return this.$refs.vpvRef?.printControl;
},
downloadControl() {
return this.$refs.vpvRef?.downloadControl;
},
},
};
</script>Step 2: Implement zoom and pagination logic
Implement zoom in or out, previous or next page navigation, and page input handling by calling zoomControl and pageControl APIs.
<script setup lang="ts">
import { computed, unref } from "vue";
const currentScale = computed(() => zoomControl.value?.scale);
const handleZoomTool = (type: "in" | "out") => {
// Read reactive refs once, then apply a fixed zoom step.
const c = unref(zoomControl);
const s = unref(currentScale);
if (!c || !s) return;
c.zoom(type === "in" ? s + 0.25 : s - 0.25);
};
</script><script setup>
import { computed, unref } from "vue";
const currentScale = computed(() => zoomControl.value?.scale);
const handleZoomTool = (type) => {
// Read reactive refs once, then apply a fixed zoom step.
const c = unref(zoomControl);
const s = unref(currentScale);
if (!c || !s) return;
c.zoom(type === "in" ? s + 0.25 : s - 0.25);
};
</script><script lang="ts">
import { defineComponent } from "vue";
export default defineComponent({
computed: {
currentScale(): number | undefined {
return this.zoomControl?.scale;
},
},
methods: {
handleZoomTool(type: "in" | "out") {
// Guard against undefined control/state during first render.
if (!this.zoomControl || !this.currentScale) return;
this.zoomControl.zoom(
type === "in" ? this.currentScale + 0.25 : this.currentScale - 0.25,
);
},
},
});
</script><script>
export default {
computed: {
currentScale() {
return this.zoomControl?.scale;
},
},
methods: {
handleZoomTool(type) {
// Guard against undefined control/state during first render.
if (!this.zoomControl || !this.currentScale) return;
this.zoomControl.zoom(
type === "in" ? this.currentScale + 0.25 : this.currentScale - 0.25,
);
},
},
};
</script>Step 3: Implement search keyword and result navigation
Create search state (searchKeyword, current match index, and total matches), then wire search input and previous or next match actions to the searchControl.
<script setup lang="ts">
import { computed, ref } from "vue";
const searchKeyword = ref("");
const currentSearchMatchIndex = ref(0);
// Fallback to 0 before search data is available.
const totalMatches = computed(
() => searchControl.value?.searchMatches?.totalMatches ?? 0,
);
const searchMatchLabel = computed(() =>
totalMatches.value
? `${currentSearchMatchIndex.value}/${totalMatches.value}`
: "0/0",
);
</script><script setup>
import { computed, ref } from "vue";
const searchKeyword = ref("");
const currentSearchMatchIndex = ref(0);
// Fallback to 0 before search data is available.
const totalMatches = computed(
() => searchControl.value?.searchMatches?.totalMatches ?? 0,
);
const searchMatchLabel = computed(() =>
totalMatches.value
? `${currentSearchMatchIndex.value}/${totalMatches.value}`
: "0/0",
);
</script><script lang="ts">
import { defineComponent } from "vue";
export default defineComponent({
data() {
// Keep keyword and 1-based match index together for search navigation.
return { searchKeyword: "", currentSearchMatchIndex: 0 };
},
computed: {
// Fallback to 0 before search data is available.
totalMatches(): number {
return this.searchControl?.searchMatches?.totalMatches ?? 0;
},
searchMatchLabel(): string {
return this.totalMatches
? `${this.currentSearchMatchIndex}/${this.totalMatches}`
: "0/0";
},
isSearchNavDisabled(): boolean {
return this.totalMatches === 0;
},
},
watch: {
// Keep current index in range whenever match count changes.
totalMatches(total: number) {
if (!total) this.currentSearchMatchIndex = 0;
else if (!this.currentSearchMatchIndex) this.currentSearchMatchIndex = 1;
else if (this.currentSearchMatchIndex > total)
this.currentSearchMatchIndex = total;
},
},
methods: {
handleSearchInput() {
if (!this.searchControl) return;
const keyword = this.searchKeyword.trim();
this.searchControl.search(keyword);
if (!keyword) this.currentSearchMatchIndex = 0;
},
selectNextSearchMatch() {
if (!this.searchControl || !this.totalMatches) return;
this.searchControl.nextSearchMatch();
this.currentSearchMatchIndex =
!this.currentSearchMatchIndex ||
this.currentSearchMatchIndex >= this.totalMatches
? 1
: this.currentSearchMatchIndex + 1;
},
selectPrevSearchMatch() {
if (!this.searchControl || !this.totalMatches) return;
this.searchControl.prevSearchMatch();
this.currentSearchMatchIndex =
!this.currentSearchMatchIndex || this.currentSearchMatchIndex <= 1
? this.totalMatches
: this.currentSearchMatchIndex - 1;
},
},
});
</script><script>
export default {
data() {
// Keep keyword and 1-based match index together for search navigation.
return { searchKeyword: "", currentSearchMatchIndex: 0 };
},
computed: {
// Fallback to 0 before search data is available.
totalMatches() {
return this.searchControl?.searchMatches?.totalMatches ?? 0;
},
searchMatchLabel() {
return this.totalMatches
? `${this.currentSearchMatchIndex}/${this.totalMatches}`
: "0/0";
},
isSearchNavDisabled() {
return this.totalMatches === 0;
},
},
watch: {
// Keep current index in range whenever match count changes.
totalMatches(total) {
if (!total) this.currentSearchMatchIndex = 0;
else if (!this.currentSearchMatchIndex) this.currentSearchMatchIndex = 1;
else if (this.currentSearchMatchIndex > total)
this.currentSearchMatchIndex = total;
},
},
methods: {
handleSearchInput() {
if (!this.searchControl) return;
const keyword = this.searchKeyword.trim();
this.searchControl.search(keyword);
if (!keyword) this.currentSearchMatchIndex = 0;
},
selectNextSearchMatch() {
if (!this.searchControl || !this.totalMatches) return;
this.searchControl.nextSearchMatch();
this.currentSearchMatchIndex =
!this.currentSearchMatchIndex ||
this.currentSearchMatchIndex >= this.totalMatches
? 1
: this.currentSearchMatchIndex + 1;
},
selectPrevSearchMatch() {
if (!this.searchControl || !this.totalMatches) return;
this.searchControl.prevSearchMatch();
this.currentSearchMatchIndex =
!this.currentSearchMatchIndex || this.currentSearchMatchIndex <= 1
? this.totalMatches
: this.currentSearchMatchIndex - 1;
},
},
};
</script>Step 4: Add print and download handlers
Use printControl and downloadControl to trigger print or download actions, and optionally add lifecycle callbacks for progress, success, and error handling.
<script setup lang="ts">
// `visibleDefaultProgress` shows built-in feedback during long print preparation.
const handlePrintTool = () =>
printControl.value?.print({ visibleDefaultProgress: true });
const handleDownloadFile = () => downloadControl.value?.download();
</script><script setup>
// `visibleDefaultProgress` shows built-in feedback during long print preparation.
const handlePrintTool = () =>
printControl.value?.print({ visibleDefaultProgress: true });
const handleDownloadFile = () => downloadControl.value?.download();
</script><script lang="ts">
import { defineComponent } from "vue";
export default defineComponent({
methods: {
handlePrintTool() {
// `visibleDefaultProgress` shows built-in feedback during long print preparation.
this.printControl?.print({ visibleDefaultProgress: true });
},
cancelPrint() {
this.printControl?.cancel();
},
handleDownloadFile() {
this.downloadControl?.download();
},
},
});
</script><script>
export default {
methods: {
handlePrintTool() {
// `visibleDefaultProgress` shows built-in feedback during long print preparation.
this.printControl?.print({ visibleDefaultProgress: true });
},
cancelPrint() {
this.printControl?.cancel();
},
handleDownloadFile() {
this.downloadControl?.download();
},
},
};
</script>Step 5: Build the custom toolbar template and disable default toolbar
First, render your custom Quasar toolbar layout. Then disable the built-in toolbar by passing :toolbar-options="false" to VPdfViewer.
<template>
<q-layout>
<q-page-container>
<q-page padding>
<h2 class="text-center p-0">{{ TITLE }}</h2>
<!-- Left side: navigation actions. Right side: search actions. -->
<div class="row items-center justify-between q-mb-md">
<div
class="toolbar-container row items-center q-pa-sm rounded-borders"
>
<q-btn
flat
dense
round
icon="zoom_out"
:size="ICON_SIZE"
@click="handleZoomTool('out')"
/>
<q-btn
flat
dense
round
icon="zoom_in"
:size="ICON_SIZE"
@click="handleZoomTool('in')"
/>
<q-btn
flat
dense
round
icon="keyboard_arrow_up"
:size="ICON_SIZE"
:disable="isPreviousPageButtonDisable"
@click="prevPage"
/>
<div class="row items-center text-body2 ">
<q-input
v-model="currentPageInput"
dense
outlined
hide-bottom-space
input-class="text-center"
style="width: 3.5rem;"
@keypress="handleKeyPress"
/>
<span class="q-pl-xs">/ {{ pageControl?.totalPages }}</span>
</div>
<q-btn
flat
dense
round
icon="keyboard_arrow_down"
:size="ICON_SIZE"
:disable="isNextPageButtonDisable"
@click="nextPage"
/>
<q-btn
flat
dense
round
icon="print"
:size="ICON_SIZE"
@click="handlePrintTool"
/>
<q-btn
flat
dense
round
icon="download"
:size="ICON_SIZE"
@click="handleDownloadFile"
/>
</div>
<div class="search-toolbar row items-center q-gutter-sm">
<q-input
v-model="searchKeyword"
dense
outlined
placeholder="Enter to search"
style="width: 16rem;"
@update:model-value="handleSearchInput"
>
<template #prepend>
<q-icon name="search" size="18px" />
</template>
</q-input>
<span class="search-counter">{{ searchMatchLabel }}</span>
<q-btn
flat
dense
round
icon="keyboard_arrow_up"
:size="ICON_SIZE"
:disable="isSearchNavDisabled"
@click="selectPrevSearchMatch"
/>
<q-btn
flat
dense
round
icon="keyboard_arrow_down"
:size="ICON_SIZE"
:disable="isSearchNavDisabled"
@click="selectNextSearchMatch"
/>
</div>
</div>
<!-- Disable the built-in toolbar because this page renders a custom one. -->
<VPdfViewer
style="height: 500px;"
ref="vpvRef"
:src="DEFAULT_PDF_URL"
:toolbar-options="false"
/>
</q-page>
</q-page-container>
</q-layout>
</template>Step 6: Add scoped styles for toolbar and search UI
Use scoped styles to align toolbar and search visuals with Quasar theme tokens and keep spacing/readability consistent.
<style scoped>
.toolbar-container {
/* Build a subtle primary-tinted surface using Quasar CSS vars. */
background-color: rgba(var(--q-primary-rgb, 25, 118, 210), 0.08);
color: var(--q-primary);
border: 1px solid rgba(var(--q-primary-rgb, 25, 118, 210), 0.2);
}
.search-counter {
min-width: 2rem;
font-size: 0.95rem;
font-weight: 600;
line-height: 1;
text-align: center;
}
</style>Step 7: Complete Example
This complete example combines all previous steps into one component: instance control setup, toolbar actions, search navigation, custom Quasar UI, and scoped styling.
<script setup lang="ts">
import { computed, ref, unref, watch } from "vue";
import { VPdfViewer, type ZoomLevel } from "@vue-pdf-viewer/viewer";
const TITLE = "Build with Quasar using Instance API";
const DEFAULT_PDF_URL =
"https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf";
const ICON_SIZE = "16px";
const vpvRef = ref<InstanceType<typeof VPdfViewer>>();
// Each computed getter safely exposes a viewer control once the ref is mounted.
const zoomControl = computed(() => vpvRef.value?.zoomControl);
const currentScale = computed(() => zoomControl.value?.scale);
const pageControl = computed(() => vpvRef.value?.pageControl);
const currentPageInput = computed(() => pageControl.value?.currentPage);
const searchControl = computed(() => vpvRef.value?.searchControl);
// Fallback to 0 before search data is available.
const totalMatches = computed(
() => searchControl.value?.searchMatches?.totalMatches,
);
const searchKeyword = ref("");
const currentSearchMatchIndex = ref(0);
const searchMatchLabel = computed(() => {
const total = totalMatches.value ?? 0;
if (!total) return "0/0";
return `${currentSearchMatchIndex.value}/${total}`;
});
const handleZoomTool = (type: "in" | "out") => {
// Read reactive refs once, then apply a fixed zoom step.
const zoomCtrl = unref(zoomControl);
if (!zoomCtrl) return;
const scale = unref(currentScale);
if (type === "in") scale && zoomCtrl.zoom(scale + 0.25);
else if (type === "out") scale && zoomCtrl.zoom(scale - 0.25);
else zoomCtrl.zoom(type as ZoomLevel);
};
const handleSearchInput = () => {
// Trim input to avoid searching meaningless whitespace-only keywords.
const searchCtrl = unref(searchControl);
if (!searchCtrl) return;
const keyword = searchKeyword.value.trim();
searchCtrl.search(keyword);
if (!keyword) currentSearchMatchIndex.value = 0;
};
// Keep the displayed match index valid as total results change.
watch(totalMatches, (total) => {
const normalizedTotal = total ?? 0;
if (!normalizedTotal) currentSearchMatchIndex.value = 0;
else if (!currentSearchMatchIndex.value) currentSearchMatchIndex.value = 1;
else if (currentSearchMatchIndex.value > normalizedTotal)
currentSearchMatchIndex.value = normalizedTotal;
});
</script><script setup>
import { computed, ref, unref, watch } from "vue";
import { VPdfViewer } from "@vue-pdf-viewer/viewer";
const TITLE = "Build with Quasar using Instance API";
const DEFAULT_PDF_URL =
"https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf";
const ICON_SIZE = "16px";
const vpvRef = ref();
// Each computed getter safely exposes a viewer control once the ref is mounted.
const zoomControl = computed(() => vpvRef.value?.zoomControl);
const currentScale = computed(() => zoomControl.value?.scale);
const pageControl = computed(() => vpvRef.value?.pageControl);
const searchControl = computed(() => vpvRef.value?.searchControl);
const searchKeyword = ref("");
const currentSearchMatchIndex = ref(0);
// Fallback to 0 before search data is available.
const totalMatches = computed(
() => searchControl.value?.searchMatches?.totalMatches ?? 0,
);
const searchMatchLabel = computed(() =>
totalMatches.value
? `${currentSearchMatchIndex.value}/${totalMatches.value}`
: "0/0",
);
const handleZoomTool = (type) => {
// Read reactive refs once, then apply a fixed zoom step.
const zoomCtrl = unref(zoomControl);
if (!zoomCtrl) return;
const scale = unref(currentScale);
if (type === "in") scale && zoomCtrl.zoom(scale + 0.25);
else if (type === "out") scale && zoomCtrl.zoom(scale - 0.25);
};
// Keep the displayed match index valid as total results change.
watch(totalMatches, (total) => {
if (!total) currentSearchMatchIndex.value = 0;
});
</script><script lang="ts">
import { defineComponent } from "vue";
import { VPdfViewer } from "@vue-pdf-viewer/viewer";
export default defineComponent({
name: "CreateYourOwnToolbar",
components: { VPdfViewer },
data() {
return {
TITLE: "Build with Quasar using Instance API",
DEFAULT_PDF_URL:
"https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf",
ICON_SIZE: "16px",
searchKeyword: "",
currentSearchMatchIndex: 0,
};
},
computed: {
// Fallback to 0 before search data is available.
totalMatches(): number {
return this.searchControl?.searchMatches?.totalMatches ?? 0;
},
searchMatchLabel(): string {
return this.totalMatches
? `${this.currentSearchMatchIndex}/${this.totalMatches}`
: "0/0";
},
isSearchNavDisabled(): boolean {
return this.totalMatches === 0;
},
},
watch: {
// Keep current index in range whenever match count changes.
totalMatches(total: number) {
if (!total) this.currentSearchMatchIndex = 0;
else if (!this.currentSearchMatchIndex) this.currentSearchMatchIndex = 1;
else if (this.currentSearchMatchIndex > total)
this.currentSearchMatchIndex = total;
},
},
methods: {
handleSearchInput() {
if (!this.searchControl) return;
const keyword = this.searchKeyword.trim();
this.searchControl.search(keyword);
if (!keyword) this.currentSearchMatchIndex = 0;
},
selectNextSearchMatch() {
if (!this.searchControl || !this.totalMatches) return;
this.searchControl.nextSearchMatch();
this.currentSearchMatchIndex =
!this.currentSearchMatchIndex ||
this.currentSearchMatchIndex >= this.totalMatches
? 1
: this.currentSearchMatchIndex + 1;
},
selectPrevSearchMatch() {
if (!this.searchControl || !this.totalMatches) return;
this.searchControl.prevSearchMatch();
this.currentSearchMatchIndex =
!this.currentSearchMatchIndex || this.currentSearchMatchIndex <= 1
? this.totalMatches
: this.currentSearchMatchIndex - 1;
},
},
});
</script><script>
import { VPdfViewer } from "@vue-pdf-viewer/viewer";
export default {
name: "CreateYourOwnToolbar",
components: { VPdfViewer },
data() {
return {
TITLE: "Build with Quasar using Instance API",
DEFAULT_PDF_URL:
"https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf",
ICON_SIZE: "16px",
searchKeyword: "",
currentSearchMatchIndex: 0,
};
},
computed: {
// Fallback to 0 before search data is available.
totalMatches() {
return this.searchControl?.searchMatches?.totalMatches ?? 0;
},
searchMatchLabel() {
return this.totalMatches
? `${this.currentSearchMatchIndex}/${this.totalMatches}`
: "0/0";
},
isSearchNavDisabled() {
return this.totalMatches === 0;
},
},
watch: {
// Keep current index in range whenever match count changes.
totalMatches(total) {
if (!total) this.currentSearchMatchIndex = 0;
else if (!this.currentSearchMatchIndex) this.currentSearchMatchIndex = 1;
else if (this.currentSearchMatchIndex > total)
this.currentSearchMatchIndex = total;
},
},
methods: {
handleSearchInput() {
if (!this.searchControl) return;
const keyword = this.searchKeyword.trim();
this.searchControl.search(keyword);
if (!keyword) this.currentSearchMatchIndex = 0;
},
selectNextSearchMatch() {
if (!this.searchControl || !this.totalMatches) return;
this.searchControl.nextSearchMatch();
this.currentSearchMatchIndex =
!this.currentSearchMatchIndex ||
this.currentSearchMatchIndex >= this.totalMatches
? 1
: this.currentSearchMatchIndex + 1;
},
selectPrevSearchMatch() {
if (!this.searchControl || !this.totalMatches) return;
this.searchControl.prevSearchMatch();
this.currentSearchMatchIndex =
!this.currentSearchMatchIndex || this.currentSearchMatchIndex <= 1
? this.totalMatches
: this.currentSearchMatchIndex - 1;
},
},
};
</script><template>
<q-layout>
<q-page-container>
<q-page padding>
<h2 class="text-center p-0">{{ TITLE }}</h2>
<!-- Left side: navigation actions. Right side: search actions. -->
<div class="row items-center justify-between q-mb-md">
<div
class="toolbar-container row items-center q-pa-sm rounded-borders"
>
<q-btn
flat
dense
round
icon="zoom_out"
:size="ICON_SIZE"
@click="handleZoomTool('out')"
/>
<q-btn
flat
dense
round
icon="zoom_in"
:size="ICON_SIZE"
@click="handleZoomTool('in')"
/>
<q-btn
flat
dense
round
icon="keyboard_arrow_up"
:size="ICON_SIZE"
:disable="isPreviousPageButtonDisable"
@click="prevPage"
/>
<div class="row items-center text-body2 ">
<q-input
v-model="currentPageInput"
dense
outlined
hide-bottom-space
input-class="text-center"
style="width: 3.5rem;"
@keypress="handleKeyPress"
/>
<span class="q-pl-xs">/ {{ pageControl?.totalPages }}</span>
</div>
<q-btn
flat
dense
round
icon="keyboard_arrow_down"
:size="ICON_SIZE"
:disable="isNextPageButtonDisable"
@click="nextPage"
/>
<q-btn
flat
dense
round
icon="print"
:size="ICON_SIZE"
@click="handlePrintTool"
/>
<q-btn
flat
dense
round
icon="download"
:size="ICON_SIZE"
@click="handleDownloadFile"
/>
</div>
<div class="search-toolbar row items-center q-gutter-sm">
<q-input
v-model="searchKeyword"
dense
outlined
placeholder="Enter to search"
style="width: 16rem;"
@update:model-value="handleSearchInput"
>
<template #prepend>
<q-icon name="search" size="18px" />
</template>
</q-input>
<span class="search-counter">{{ searchMatchLabel }}</span>
<q-btn
flat
dense
round
icon="keyboard_arrow_up"
:size="ICON_SIZE"
:disable="isSearchNavDisabled"
@click="selectPrevSearchMatch"
/>
<q-btn
flat
dense
round
icon="keyboard_arrow_down"
:size="ICON_SIZE"
:disable="isSearchNavDisabled"
@click="selectNextSearchMatch"
/>
</div>
</div>
<!-- Disable the built-in toolbar because this page renders a custom one. -->
<VPdfViewer
style="height: 500px;"
ref="vpvRef"
:src="DEFAULT_PDF_URL"
:toolbar-options="false"
/>
</q-page>
</q-page-container>
</q-layout>
</template>Notes
- Use Instance API when you need complete control over layout and interactions.
- Disable the built-in toolbar with
:toolbar-options="false"when rendering your own toolbar.