---
title: "Headless UI: Build your own document experience | Nutrient Web SDK"
canonical_url: "https://www.nutrient.io/guides/web/user-interface/ui-customization/headless-ui/"
md_url: "https://www.nutrient.io/guides/web/user-interface/ui-customization/headless-ui.md"
last_updated: "2026-05-15T19:10:05.104Z"
description: "Use Nutrient Web SDK in headless mode to build fully custom document interfaces, such as signing workflows, custom readers, or whatever your product needs. Strip the default UI with preset: minimal and build from scratch using the Instance API."
---

# Building headless document UIs

Nutrient Web SDK ships with a complete document viewer UI out of the box. But when you’re building your own product (a signing workflow, a custom reader, or something domain-specific), you probably want full control over the interface.

The **headless UI** approach gives you that control: Strip all default UI, keep the rendering engine and Instance API, and build your own interface on top.

## The headless pattern

Every capability the bundled UI exposes is also available on the Instance API, so the SDK can run fully headless. Your application drives behavior through method calls, and your own UI renders the rest. The bundled UI is one consumer of that API surface, not a privileged path.

The core idea is three steps:

1. **`preset: 'minimal'`** — Hides all default UI (toolbars, sidebars, popovers, modals). Only the bare document canvas remains.

2. **Selective slot overrides** — Restore only the components you need, with your own custom implementations.

3. **Instance API** — Drive everything programmatically, including page navigation, annotations, search, signatures, and form filling. The [Instance API reference](https://www.nutrient.io/api/web/classes/NutrientViewer.Instance.html) is the authoritative list of what’s available; the [`NutrientViewer` module reference](https://www.nutrient.io/api/web/modules/NutrientViewer.html) covers the load-time configuration and supporting types.

```ts

const instance = await NutrientViewer.load({
  container: document.getElementById("viewer"),
  document: "contract.pdf",
  ui: {
    preset: "minimal",
    // Everything is hidden. Selectively override what you need:
    search: (getInstance, id) => ({
      render: () => {
        // Your custom search UI.
      },
    }),
  },
});

// Drive the viewer programmatically:
instance.setViewState((v) => v.set("currentPageIndex", 2));

```

## Canvas-only viewer

This is the simplest headless setup — a document canvas with no UI at all:

```ts

const instance = await NutrientViewer.load({
  container: document.getElementById("viewer"),
  document: "report.pdf",
  ui: { preset: "minimal" },
});

```

This renders the document with zoom and scroll, but no toolbars, sidebars, or any overlays. You control everything through the Instance API:

```ts

// Navigate pages.
instance.setViewState((v) => v.set("currentPageIndex", 5));

// Zoom.
instance.setViewState((v) =>
  v.set("zoomMode", NutrientViewer.ZoomMode.FIT_TO_WIDTH),
);

// Get total page count.
const pageCount = instance.totalPageCount;

```

## Example: Custom read-only viewer

This example shows a minimal document viewer with your own toolbar for page navigation and zoom:

```ts

const container = document.getElementById("viewer");
const controls = document.getElementById("controls");

const instance = await NutrientViewer.load({
  container,
  document: "report.pdf",
  ui: {
    preset: "minimal",
    // Restore only the tools bar with a custom implementation.
    tools: {
      main: (getInstance, id) => ({
        render: () => {
          const bar = document.createElement("div");
          bar.style.cssText =
            "display:flex;align-items:center;gap:12px;padding:8px 16px;background:#fff;border-bottom:1px solid #e0e0e0;";

          // Page navigation.
          const prevBtn = document.createElement("button");
          prevBtn.textContent = "← Prev";
          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 pageLabel = document.createElement("span");
          const initial = getInstance();
          pageLabel.textContent = `Page ${(initial?.viewState.currentPageIndex?? 0) + 1} / ${initial?.totalPageCount?? "?"}`;

          const nextBtn = document.createElement("button");
          nextBtn.textContent = "Next →";
          nextBtn.onclick = () => {
            const instance = getInstance();
            if (!instance) return;
            const page = instance.viewState.currentPageIndex;
            if (page < instance.totalPageCount - 1) {
              instance.setViewState((v) =>
                v.set("currentPageIndex", page + 1),
              );
            }
          };

          // Zoom.
          const zoomSelect = document.createElement("select");
          for (const [label, mode] of [
            ["Fit Width", NutrientViewer.ZoomMode.FIT_TO_WIDTH],
            ["Fit Page", NutrientViewer.ZoomMode.FIT_TO_VIEWPORT],
            ["Auto", NutrientViewer.ZoomMode.AUTO],
          ]) {
            const opt = document.createElement("option");
            opt.textContent = label;
            opt.value = mode;
            zoomSelect.appendChild(opt);
          }
          zoomSelect.onchange = () => {
            const instance = getInstance();
            if (!instance) return;
            instance.setViewState((v) =>
              v.set("zoomMode", zoomSelect.value),
            );
          };

          bar.append(prevBtn, pageLabel, nextBtn, zoomSelect);
          return bar;
        },
      }),
    },
  },
});

// Update page label when the user scrolls.
instance.addEventListener("viewState.change", (viewState) => {
  const label = document.querySelector("span");
  if (label) {
    label.textContent = `Page ${viewState.currentPageIndex + 1} / ${instance.totalPageCount}`;
  }
});

```

## Example: Custom signing experience

A focused signing flow. The document loads with all UI hidden except a custom toolbar with a “Sign” button and a “Done” button.

```ts

const instance = await NutrientViewer.load({
  container: document.getElementById("viewer"),
  document: "contract.pdf",
  ui: {
    preset: "minimal",
    tools: {
      main: (getInstance, id) => ({
        render: () => {
          const bar = document.createElement("div");
          bar.style.cssText =
            "display:flex;justify-content:space-between;align-items:center;padding:12px 24px;background:#1a1a2e;color:white;";

          // Left: title.
          const title = document.createElement("span");
          title.textContent = "Please review and sign";
          title.style.fontWeight = "600";

          // Right: actions.
          const actions = document.createElement("div");
          actions.style.cssText = "display:flex;gap:12px;";

          const signBtn = document.createElement("button");
          signBtn.textContent = "✍️ Sign";
          signBtn.style.cssText =
            "padding:8px 20px;background:#e94560;color:white;border:none;border-radius:6px;cursor:pointer;";

          signBtn.onclick = () => {
            // Switch to signature mode.
            const instance = getInstance();
            if (!instance) return;
            instance.setViewState((v) =>
              v.set(
                "interactionMode",
                NutrientViewer.InteractionMode.SIGNATURE,
              ),
            );
          };

          const doneBtn = document.createElement("button");
          doneBtn.textContent = "Done";
          doneBtn.style.cssText =
            "padding:8px 20px;background:#0f3460;color:white;border:none;border-radius:6px;cursor:pointer;";

          doneBtn.onclick = async () => {
            const instance = getInstance();
            if (!instance) return;
            const pdf = await instance.exportPDF();
            // Send to your backend.
            console.log("Signed PDF exported:", pdf.byteLength, "bytes");
          };

          actions.append(signBtn, doneBtn);
          bar.append(title, actions);
          return bar;
        },
      }),
    },
  },
});

```

`preset: 'minimal'` doesn’t expose a per-slot “restore the default” option, and returning `null` from `render` hides the slot but doesn’t bring back the SDK’s built-in UI. With the preset applied, every slot stays hidden unless you explicitly replace it with your own DOM. To get the full default UI back at runtime, call `instance.setUI({})`.

## Example: Annotation review tool

This example shows a read-only viewer where reviewers can see annotations but interact with them through a custom panel instead of the default popovers:

```ts

const instance = await NutrientViewer.load({
  container: document.getElementById("viewer"),
  document: "reviewed-document.pdf",
  ui: {
    preset: "minimal",
    // Custom annotation actions, show metadata instead of edit tools.
    annotations: {
      actions: (getInstance, id) => ({
        render: () => {
          const instance = getInstance();
          if (!instance) return null;

          const selected = instance.getSelectedAnnotations();
          const annotation = selected.size? selected.first() : null;
          if (!annotation) return null;

          const panel = document.createElement("div");
          panel.style.cssText =
            "padding:8px 12px;background:#f8f9fa;border:1px solid #dee2e6;border-radius:8px;font-size:13px;";

          panel.innerHTML = `
            <div style="margin-bottom:4px;font-weight:600;">
              ${annotation.name || annotation.constructor.name}
            </div>
            <div style="color:#666;">

              Author: ${annotation.creatorName || "Unknown"}<br/>
              Page: ${annotation.pageIndex + 1}<br/>
              Created: ${annotation.createdAt?.toLocaleDateString() || "N/A"}
            </div>
          `;

          return panel;
        },
      }),
    },
  },
});

```

## Combining headless with the full API

The headless approach works with the full Instance API. Here are common operations you’ll use when building custom UIs:

### Page navigation and zoom

```ts

// Go to page (zero-indexed).
instance.setViewState((v) => v.set("currentPageIndex", 0));

// Zoom modes.
instance.setViewState((v) =>
  v.set("zoomMode", NutrientViewer.ZoomMode.FIT_TO_WIDTH),
);

// Custom zoom level (1.0 = 100%).
instance.setViewState((v) => v.set("zoom", 1.5));

// Layout.
instance.setViewState((v) =>
  v.set("layoutMode", NutrientViewer.LayoutMode.DOUBLE),
);

```

### Search

```ts

// Open search UI (if you have a custom search slot).
instance.setViewState((v) =>
  v.set("interactionMode", NutrientViewer.InteractionMode.SEARCH),
);

// Programmatic search (returns results without UI).
const results = await instance.search("contract");

// Search with UI integration.
instance.startUISearch("contract");

```

### Annotations

```ts

// Get all annotations on a page.
const annotations = await instance.getAnnotations(0);

// Get selected annotations.
const selected = instance.getSelectedAnnotations();

// Delete an annotation.
await instance.delete(annotationId);

// Create a highlight.
const highlight = new NutrientViewer.Annotations.HighlightAnnotation({
  pageIndex: 0,
  rects: NutrientViewer.Immutable.List([
    new NutrientViewer.Geometry.Rect({ left: 50, top: 100, width: 200, height: 20 }),
  ]),
  color: NutrientViewer.Color.YELLOW,
});
await instance.create(highlight);

```

### Export

```ts

// Export as PDF.
const pdfBuffer = await instance.exportPDF();

// Export as PDF/A (defaults to PDF/A-2B).
const pdfaBuffer = await instance.exportPDF({ outputFormat: true });

// Or specify the conformance explicitly.
const pdfa2bBuffer = await instance.exportPDF({
  outputFormat: { conformance: NutrientViewer.Conformance.PDFA_2B },
});

// Download.
const blob = new Blob([pdfBuffer], { type: "application/pdf" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "document.pdf";
a.click();

```

## Restoring default UI at runtime

You can switch between headless and full UI mode at runtime using `instance.setUI()`:

```ts

// Switch to headless:
instance.setUI({ preset: "minimal" });

// Restore full default UI:
instance.setUI({});

// Headless with just search:
instance.setUI({
  preset: "minimal",
  search: (getInstance, id) => ({
    render: () => {
      const div = document.createElement("div");
      div.textContent = "Custom search";
      return div;
    },
  }),
});

```

## Using with React

When building complex headless UIs, use a framework like React to manage your custom components. The pattern is the same as the [comment thread example](https://www.nutrient.io/guides/web/user-interface/ui-customization/comment-thread-example.md) — create a React root inside the slot’s DOM container:

```tsx

import { createRoot } from "react-dom/client";

NutrientViewer.load({
  container: document.getElementById("viewer"),
  document: "document.pdf",
  ui: {
    preset: "minimal",
    tools: {
      main: (getInstance, id) => {
        const container = document.createElement("div");
        const root = createRoot(container);

        return {
          render: () => container,
          onMount: () => {
            // `getInstance()` returns the live SDK instance at mount time.
            root.render(<MyCustomToolbar instance={getInstance()} />);
          },
          onUnmount: () => {
            root.unmount();
          },
        };
      },
    },
  },
});

```

This pattern works with any framework that can render into a DOM node — React, Vue, Svelte, Solid, or vanilla JavaScript.

## Next steps

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

- [Slot examples](https://www.nutrient.io/guides/web/user-interface/ui-customization/examples.md) — Per-domain code examples for tools, annotations, search, and more.

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

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

## 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)
- [Slot customization examples](/guides/web/user-interface/ui-customization/examples.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)

