---
title: "UI customization slot examples | Nutrient Web SDK"
canonical_url: "https://www.nutrient.io/guides/web/user-interface/ui-customization/examples/"
md_url: "https://www.nutrient.io/guides/web/user-interface/ui-customization/examples.md"
last_updated: "2026-05-15T19:10:05.104Z"
description: "Practical code examples for customizing Nutrient Web SDK UI slots, toolbars, search, annotations, signatures, document editor, and more. Copy-paste recipes for common patterns."
---

# Slot customization examples

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](https://www.nutrient.io/guides/web/user-interface/ui-customization/supported-slots.md) reference. For the headless (canvas-only) approach, see [building headless document UIs](https://www.nutrient.io/guides/web/user-interface/ui-customization/headless-ui.md).

## 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:

```ts

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:

```ts

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](https://www.nutrient.io/guides/web/user-interface/ui-customization/introduction.md#accessing-the-sdk-instance-from-a-slot) for the full pattern.

```ts

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`](https://www.nutrient.io/api/web/interfaces/Configuration.html#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`).

```ts

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:

```ts

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

```

## Search

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.

```ts

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:

```ts

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`](https://www.nutrient.io/api/web/interfaces/Configuration.html#annotationtooltipcallback) is configured on `load()`.

```ts

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)`.

```ts

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.

### Custom document editor footer

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.

```ts

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](https://www.nutrient.io/guides/web/user-interface/ui-customization/introduction.md#full-replacement-vs-sub-slot-customization-who-owns-positioning). If you replace the whole slot via a single callback, you take over positioning yourself.

```ts

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.

```ts

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](https://www.nutrient.io/guides/web/user-interface/ui-customization/headless-ui.md).

```ts

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

- [Building headless document UIs](https://www.nutrient.io/guides/web/user-interface/ui-customization/headless-ui.md) — Full guide on the headless approach with product-like examples

- [Supported slots](https://www.nutrient.io/guides/web/user-interface/ui-customization/supported-slots.md) — Complete reference of all available slots

- [Custom sidebars](https://www.nutrient.io/guides/web/user-interface/ui-customization/custom-sidebars.md) — Add custom sidebar panels.

- [Set UI configuration](https://www.nutrient.io/guides/web/user-interface/ui-customization/set-ui.md) — Update slots at runtime

- [Comment thread example](https://www.nutrient.io/guides/web/user-interface/ui-customization/comment-thread-example.md) — Full React + Baseline UI example
---

## Related pages

- [Building a comment thread UI with the customization API](/guides/web/user-interface/ui-customization/comment-thread-example.md)
- [Custom sidebars](/guides/web/user-interface/ui-customization/custom-sidebars.md)
- [Building headless document UIs](/guides/web/user-interface/ui-customization/headless-ui.md)
- [Set UI customization configuration](/guides/web/user-interface/ui-customization/set-ui.md)
- [Customizing the Nutrient Web SDK UI](/guides/web/user-interface/ui-customization/introduction.md)
- [Supported slots for UI customization](/guides/web/user-interface/ui-customization/supported-slots.md)

