This HTML page is not optimized for LLM or AI agent consumption. Fetch the Markdown version instead: /guides/web/user-interface/ui-customization/examples.md β€” it contains the complete documentation content in clean, structured Markdown without any CSS, JavaScript, or navigation noise. UI customization slot examples | Nutrient Web SDK

This guide provides practical, copy-paste examples for customizing the most commonly used slots. Each example is self-contained and can be used as a starting point for your own implementation.

For the full list of available slots, see the supported slots reference. For the headless (canvas-only) approach, see building headless document UIs.

Common patterns

Before diving into per-slot examples, here are patterns that apply across all slots.

Hide a slot

Return null from render to hide any slot:

NutrientViewer.load({
ui: {
tools: {
main: (getInstance, id) => ({ render: () => null }),
},
},
});

Add a custom header to any slot

Most slots support sub-slot customization with header, body, and footer regions. Use an object instead of a function to customize individual regions while keeping the rest of the default UI:

NutrientViewer.load({
ui: {
search: {
header: (getInstance, id) => ({
render: () => {
const header = document.createElement("div");
header.style.cssText = "padding:8px 12px;background:#f0f0f0;font-weight:600;";
header.textContent = "Document Search";
return header;
},
}),
// body and footer keep their default rendering
},
},
});

Use data from the Instance API

Every slot callback receives getInstance as the first argument, which is a function returning the live SDK instance (or null if it isn’t available yet). Call it at the point of use to access annotations, search, view state, and all other Instance API methods. See accessing the SDK instance from a slot for the full pattern.

NutrientViewer.load({
// The `annotations.actions` slot only renders when an annotation tooltip
// would normally appear, which the SDK only does when
// `annotationTooltipCallback` is configured. Without it, this slot's
// render is never called.
annotationTooltipCallback: () => [],
ui: {
annotations: {
actions: (getInstance, id) => ({
render: () => {
const instance = getInstance();
if (!instance) return null;
const selected = instance.getSelectedAnnotations();
if (!selected.size) return null;
const annotation = selected.first();
const div = document.createElement("div");
div.textContent = `Selected: ${annotation.constructor.name} by ${annotation.creatorName || "Unknown"}`;
return div;
},
}),
},
},
});

The annotations.actions slot is only rendered when the SDK has decided an annotation tooltip should appear, which requires you to configure annotationTooltipCallback. Without that callback set on the load configuration, the slot is wired but never invoked. Return an empty array from annotationTooltipCallback if you only want the slot to drive the chrome.

Tools

Customize the main toolbar layout and hide the contextual toolbar that appears around a selected annotation.

Custom main toolbar

Replace the always-visible toolbar with your own controls. The logic is the same shape as every other slot: Build a DOM node, wire button handlers that call getInstance() at the moment of use, and return the node from render. The example below adds previous/next/last page navigation and a download action so you can see the Instance API surface that drives the bundled toolbar (setViewState, exportPDF).

NutrientViewer.load({
document: "document.pdf",
ui: {
tools: {
main: (getInstance) => ({
render: () => {
const bar = document.createElement("div");
bar.style.cssText =
"display:flex;align-items:center;gap:8px;padding:8px 16px;background:#f8f9fa;border-bottom:1px solid #e0e0e0;";
const title = document.createElement("span");
title.style.cssText = "font-size:14px;color:#444;";
title.textContent = "Custom main tools";
const navStyle = "min-width:2.25rem;padding:4px 8px;font-size:14px;";
const prevBtn = document.createElement("button");
prevBtn.textContent = "β€Ή";
prevBtn.title = "Previous page";
prevBtn.style.cssText = navStyle;
prevBtn.onclick = () => {
const instance = getInstance();
if (!instance) return;
const page = instance.viewState.currentPageIndex;
if (page > 0) instance.setViewState(v => v.set("currentPageIndex", page - 1));
};
const nextBtn = document.createElement("button");
nextBtn.textContent = "β€Ί";
nextBtn.title = "Next page";
nextBtn.style.cssText = navStyle;
nextBtn.onclick = () => {
const instance = getInstance();
if (!instance) return;
const page = instance.viewState.currentPageIndex;
const last = instance.totalPageCount - 1;
if (page < last) instance.setViewState(v => v.set("currentPageIndex", page + 1));
};
const lastBtn = document.createElement("button");
lastBtn.textContent = "Last";
lastBtn.title = "Last page";
lastBtn.style.cssText = navStyle;
lastBtn.onclick = () => {
const instance = getInstance();
if (!instance) return;
const last = instance.totalPageCount - 1;
if (last >= 0) instance.setViewState(v => v.set("currentPageIndex", last));
};
const spacer = document.createElement("div");
spacer.style.flex = "1";
const downloadBtn = document.createElement("button");
downloadBtn.textContent = "⬇ Download";
downloadBtn.onclick = async () => {
const instance = getInstance();
if (!instance) return;
const pdf = await instance.exportPDF();
const blob = new Blob([pdf], { type: "application/pdf" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "document.pdf";
a.click();
};
bar.append(title, prevBtn, nextBtn, lastBtn, spacer, downloadBtn);
return bar;
},
}),
},
},
});

Hide the contextual toolbar

Keep the main tools but hide the mode-specific toolbar that appears when selecting annotations or entering editing modes:

NutrientViewer.load({
ui: {
tools: {
contextual: (getInstance, id) => ({ render: () => null }),
},
},
});

Replace the search panel entirely, or keep its body and swap only the header.

Custom search panel

Replace the search panel with your own input. Two Instance API methods drive search: startUISearch(term) triggers the SDK’s integrated search with highlighting, while search(term) returns a result list without touching the UI, which is useful when you want to render the counts or results yourself. The example below uses both, so the panel highlights matches in the document and shows a result count next to the input.

NutrientViewer.load({
document: "document.pdf",
ui: {
search: (getInstance, id) => ({
render: () => {
const panel = document.createElement("div");
panel.style.cssText =
"display:flex;flex-direction:column;gap:8px;padding:12px;background:#fff;border-bottom:1px solid #e0e0e0;";
const row = document.createElement("div");
row.style.cssText = "display:flex;gap:8px;align-items:center;";
const input = document.createElement("input");
input.type = "text";
input.placeholder = "Search document...";
input.style.cssText =
"flex:1;min-width:0;padding:8px 12px;border:1px solid #ccc;border-radius:6px;font-size:14px;";
const resultLabel = document.createElement("span");
resultLabel.style.cssText = "font-size:12px;color:#666;";
const runSearch = async () => {
const query = input.value.trim();
if (!query) return;
const instance = getInstance();
if (!instance) return;
// Use startUISearch for integrated search with highlighting.
instance.startUISearch(query);
// Or use search() for results without UI.
const results = await instance.search(query);
resultLabel.textContent = `${results.size} results found`;
};
const searchButton = document.createElement("button");
searchButton.type = "button";
searchButton.textContent = "Search";
searchButton.style.cssText =
"padding:8px 16px;border:1px solid #1976d2;background:#1976d2;color:#fff;border-radius:6px;font-size:14px;cursor:pointer;";
searchButton.onclick = () => {
void runSearch();
};
input.onkeyup = (e) => {
if (e.key === "Enter") {
void runSearch();
}
};
row.append(input, searchButton);
panel.append(row, resultLabel);
return panel;
},
}),
},
});

Search with a custom header

Keep the default search UI but add a branded header above it:

NutrientViewer.load({
ui: {
search: {
header: (getInstance, id) => ({
render: () => {
const header = document.createElement("div");
header.style.cssText = "padding:10px 12px;background:#1a1a2e;color:white;font-weight:600;font-size:14px;";
header.textContent = "πŸ” Find in Document";
return header;
},
}),
},
},
});

Annotations

Replace the floating toolbar that appears next to a selected annotation, or intercept the delete action with a custom confirmation step.

Custom annotation actions toolbar

The annotations.actions slot renders chrome around the currently selected annotation. Read instance.getSelectedAnnotations() inside render to discover what’s selected, and call instance.delete(id) to remove it. Remember the slot only fires when annotationTooltipCallback is configured on load().

NutrientViewer.load({
document: "annotated-document.pdf",
annotationTooltipCallback: () => [],
ui: {
annotations: {
actions: (getInstance) => ({
render: () => {
const instance = getInstance();
if (!instance) return null;
const selected = instance.getSelectedAnnotations();
if (!selected.size) return null;
const annotation = selected.first();
const bar = document.createElement("div");
const type = annotation.constructor.name.replace("Annotation", "");
bar.textContent = `${type} by ${annotation.creatorName ?? "Unknown"}`;
const deleteBtn = document.createElement("button");
deleteBtn.textContent = "Delete";
deleteBtn.onclick = () => getInstance()?.delete(annotation.id);
bar.append(deleteBtn);
return bar;
},
}),
},
},
});

Custom delete confirmation

The deleteConfirm slot replaces the confirmation chrome that appears when the user triggers a delete. The slot doesn’t expose onConfirm/onCancel callbacks, so confirm by calling instance.delete(id) yourself and dismiss by clearing the selection via setSelectedAnnotations(null).

NutrientViewer.load({
ui: {
annotations: {
deleteConfirm: (getInstance) => ({
render: () => {
const dialog = document.createElement("div");
const message = document.createElement("p");
message.textContent = "Delete this annotation permanently?";
const cancelBtn = document.createElement("button");
cancelBtn.textContent = "Cancel";
cancelBtn.onclick = () => {
// Clearing the selection dismisses the confirmation.
getInstance()?.setSelectedAnnotations(null);
};
const confirmBtn = document.createElement("button");
confirmBtn.textContent = "Delete";
confirmBtn.onclick = async () => {
const instance = getInstance();
const selected = instance?.getSelectedAnnotations();
if (selected?.size) await instance.delete(selected.first().id);
};
dialog.append(message, cancelBtn, confirmBtn);
return dialog;
},
}),
},
},
});

Document editor

Swap out the default footer in the document editor while keeping the rest of the panel intact.

Sub-slot customization keeps the default page-thumbnail body intact and only replaces the footer region. setViewState(v => v.set("interactionMode", null)) exits document editor mode; pair it with exportPDF() on save to persist the reordered pages.

NutrientViewer.load({
ui: {
documentEditor: {
footer: (getInstance) => ({
render: () => {
const footer = document.createElement("div");
const discardBtn = document.createElement("button");
discardBtn.textContent = "Discard";
discardBtn.onclick = () => {
getInstance()?.setViewState(v => v.set("interactionMode", null));
};
const saveBtn = document.createElement("button");
saveBtn.textContent = "Save changes";
saveBtn.onclick = async () => {
const instance = getInstance();
if (!instance) return;
await instance.exportPDF();
instance.setViewState(v => v.set("interactionMode", null));
};
footer.append(discardBtn, saveBtn);
return footer;
},
}),
},
},
});

Signatures

Replace the bundled electronic signature dialog with your own UI.

Custom electronic signature UI

The signatures.list slot owns the saved-signatures picker. Use sub-slot customization here, and replace only the body while keeping the default header and footer hidden. With sub-slot customization, the SDK keeps control of where the component appears on the page; that’s the tradeoff described in our guide on full replacement vs. sub-slot customization. If you replace the whole slot via a single callback, you take over positioning yourself.

NutrientViewer.load({
document: "form.pdf",
ui: {
signatures: {
list: {
header: () => ({ render: () => null }),
footer: () => ({ render: () => null }),
body: () => ({
render: () => {
const body = document.createElement("div");
body.textContent = "Custom canvas lands here";
return body;
},
}),
},
},
},
});

Multiple slots at once

You can customize any combination of slots in a single configuration. The example below builds a focused review experience: Replace the main tools with a minimal page indicator, hide the contextual tools and sidebar, and slot in a custom annotation-actions chip. Notice how the page indicator subscribes to viewState.change inside onMount and unsubscribes in onUnmount. render may be invoked repeatedly when parameters change, so persistent side effects belong in lifecycle methods.

NutrientViewer.load({
document: "review-document.pdf",
annotationTooltipCallback: () => [],
ui: {
tools: {
// A minimal page indicator with a live page number.
main: (getInstance) => {
const bar = document.createElement("div");
const pageLabel = document.createElement("span");
bar.appendChild(pageLabel);
const updateLabel = () => {
const instance = getInstance();
pageLabel.textContent = `Page ${(instance?.viewState.currentPageIndex ?? 0) + 1}`;
};
return {
render: () => bar,
onMount: () => {
updateLabel();
// Subscribe in onMount, unsubscribe in onUnmount, never in render.
getInstance()?.addEventListener("viewState.change", updateLabel);
},
onUnmount: () => {
getInstance()?.removeEventListener("viewState.change", updateLabel);
},
};
},
contextual: () => ({ render: () => null }),
},
sidebar: {
container: () => ({ render: () => null }),
},
annotations: {
actions: (getInstance) => ({
render: () => {
const selected = getInstance()?.getSelectedAnnotations();
if (!selected?.size) return null;
const chip = document.createElement("span");
chip.textContent = `${selected.first().constructor.name.replace("Annotation", "")} selected`;
return chip;
},
}),
},
},
});

Using preset minimal with selective overrides

preset: 'minimal' hides every default UI surface, leaving only the document canvas. Each slot you override is reintroduced; everything else stays hidden. This is the entry point for the headless UI pattern.

NutrientViewer.load({
document: "document.pdf",
ui: {
preset: "minimal",
// Restore just a toolbar.
tools: {
main: () => ({
render: () => {
const bar = document.createElement("div");
bar.textContent = "Custom toolbar";
return bar;
},
}),
},
// Restore a basic search panel.
search: (getInstance) => ({
render: () => {
const input = document.createElement("input");
input.placeholder = "Search...";
input.onkeyup = (e) => {
if (e.key === "Enter") getInstance()?.startUISearch(input.value);
};
return input;
},
}),
// Sidebar, annotation UI, signatures, etc. stay hidden until overridden.
},
});

Next steps