---
title: "UI customization example | Nutrient Web SDK"
canonical_url: "https://www.nutrient.io/guides/web/user-interface/ui-customization/comment-thread-example/"
md_url: "https://www.nutrient.io/guides/web/user-interface/ui-customization/comment-thread-example.md"
last_updated: "2026-05-20T19:49:34.911Z"
description: "Learn how to build comment thread UI with the customization API using React and Baseline UI."
---

# Building a comment thread UI with the customization API

This guide demonstrates building a custom comment thread user interface (UI) with all the core functionality for viewing, adding, editing, and deleting comments.

It also showcases how you can use a framework like React to simplify the custom UI code and use Nutrient’s design system, [Baseline UI](https://www.nutrient.io/baseline-ui/), for out-of-the-box React components that match the look and feel of Nutrient’s default UI.

**View code**

Prefer to jump to the code? Check out the full example on GitHub.

[Read more](https://github.com/PSPDFKit/awesome-nutrient/tree/master/web/ui-customization/)

## Setup

This guide uses React, but you can use any frontend framework that works with DOM nodes. Read more about the customization API in the [introduction](https://www.nutrient.io/guides/web/user-interface/ui-customization/introduction.md) guide.

Follow the [getting started](https://www.nutrient.io/sdk/web/getting-started/react-vite.md) guide to set up a React + Vite project with Nutrient Web SDK.

## Installing Baseline UI

**Steps:**

1. Install Baseline UI packages:

   ```bash

   npm install @baseline-ui/core @baseline-ui/icons @baseline-ui/tokens
   # or

   yarn add @baseline-ui/core @baseline-ui/icons @baseline-ui/tokens
   # or

   pnpm install @baseline-ui/core @baseline-ui/icons @baseline-ui/tokens
   ```

2. Include Baseline UI stylesheets in your app. Add the following to a CSS file that is being used in your project (e.g. `App.css` or `<your component>.css`):

   ```css

   /* App.css */
   @import "@baseline-ui/tokens/dist/index.css";
   @import "@baseline-ui/core/dist/index.css";

   @layer bui-reset {
     * {
       box-sizing: border-box;
       margin: 0;
       padding: 0;
       -moz-osx-font-smoothing: grayscale;
       -webkit-font-smoothing: antialiased;
     }
   }
   ```

3. You’re now ready to use Baseline UI components in your React app.

## Rendering a React component into a slot

Start by rendering a simple React component into the `commentThread` slot.

**Steps:**

1. Create a new React component at `src/CommentThread.tsx`:

   ```tsx

   // CommentThread.tsx
   import React from "react";

   const CommentThread: React.FC = () => {
     return (
       <div className="comment-thread-container">
         This is a custom comment thread UI
       </div>
     );
   };

   export default CommentThread;
   ```

2. Create a CSS file in a public directory (`public/comment-thread.css` for Vite), which will be passed to the SDK via the `styleSheets` configuration:

   ```css

   /* comment-thread.css */.comment-thread-container {
     min-width: 240px;
     background-color: white;
   }
   ```

3. Render the `CommentThread` component into the `commentThread` slot using the customization API. The code in `src/App.tsx` builds upon the code from the [getting started](https://www.nutrient.io/sdk/web/getting-started/react-vite.md) guide, with important changes highlighted. Update your `App.tsx` file:

   ```tsx

   // App.tsx
   import { useEffect, useRef } from "react";

   // Use `createRoot` to render our React component
   // as an isolated React app into the slot.
   import { createRoot } from "react-dom/client";

   import "./App.css";

   import CommentThread from "./CommentThread";

   function App() {
     const containerRef = useRef(null);

     useEffect(() => {
       const container = containerRef.current;
       let cleanup = () => {};

       (async () => {
         const NutrientViewer = (await import("@nutrient-sdk/viewer")).default;

         // Ensure there's only one `NutrientViewer` instance.
         NutrientViewer.unload(container);

         const baseUrl = `${window.location.protocol}//${window.location.host}/${
           import.meta.env.PUBLIC_URL?? ""
         }`;

         if (container && NutrientViewer) {
           NutrientViewer.load({
             container,
             document:
               "https://www.nutrient.io/downloads/nutrient-web-demo.pdf",
             baseUrl,
             // Pass custom stylesheets path for the custom comment thread UI.
             styleSheets: [`${baseUrl}comment-thread.css`],
             // Pass the UI customization config.
             ui: {
               commentThread: (getInstance, id) => {
                 const container = document.createElement("div");
                 const root = createRoot(container);

                 return {
                   // Provide the DOM node; the UI is handled by `CommentThread` component.
                   render: () => container,
                   onMount: () => {
                     // Render the React component once the slot is mounted.
                     // `getInstance()` returns the live SDK instance.
                     root.render(<CommentThread instance={getInstance()} id={id} />);
                   },
                   onUnmount: () => {
                     // Clean up the React root when the slot is unmounted.
                     root.unmount();
                   },
                 };
               },
             },
           });
         }

         cleanup = () => {
           NutrientViewer.unload(container);
         };
       })();

       return cleanup;
     }, []);

     // Set the container height and width.
     return (
       <div ref={containerRef} style={{ height: "100vh", width: "100vw" }} />
     );
   }

   export default App;
   ```

4. When you open a comment thread, you’ll see the custom UI — powered by React — instead of the default UI.

## Building the comment thread UI

To build the fully functional comment thread UI, use APIs available on [`instance`](https://www.nutrient.io/api/web/classes/NutrientViewer.Instance.html), the mounted document instance returned by [`NutrientViewer.load`](https://www.nutrient.io/api/web/functions/NutrientViewer.load.html), along with components from Baseline UI.

Use the following:

- [`getComments`](https://www.nutrient.io/api/web/classes/NutrientViewer.Instance.html#getcomments) — To fetch all comments and display them in the UI.

- [`getAnnotations`](https://www.nutrient.io/api/web/classes/NutrientViewer.Instance.html#getannotations) — To fetch annotations for updating the root annotation associated with the comment thread.

- [`comments.change` event](https://www.nutrient.io/api/web/enums/NutrientViewer.EventName.html#comments_change) — To listen for changes in comments and update the UI accordingly.

- [`instance.create`](https://www.nutrient.io/api/web/classes/NutrientViewer.Instance.html#create) — To add new comments.

- [`instance.update`](https://www.nutrient.io/api/web/classes/NutrientViewer.Instance.html#update) — To edit comments.

- [`instance.delete`](https://www.nutrient.io/api/web/classes/NutrientViewer.Instance.html#delete) — To delete comments.

- [Baseline UI](https://www.nutrient.io/baseline-ui/) components — `ActionButton`, `Avatar`, `Box`, `Editor`, and `Text` to build the UI.

Update the `CommentThread` component to handle viewing, adding, editing, and deleting comments. The code is annotated to explain the concepts inline:

```tsx

// CommentThread.tsx

import {
  ActionButton,
  Avatar,
  Box,
  Editor,
  FrameProvider,
  I18nProvider,
  Text,
  ThemeProvider,
} from "@baseline-ui/core";
import { themes } from "@baseline-ui/tokens";
import { Comment, type Instance, type List } from "@nutrient-sdk/viewer";
import React, { useEffect } from "react";

interface CommentThreadProps {
  instance: Instance | null;
  id: string;
}

const CommentThread = (props: CommentThreadProps) => {
  const { instance, id } = props;
  const [comments, setComments] = React.useState<null | List<Comment>>(null);
  // We only allow editing one comment at a time
  const [editCommentId, setEditCommentId] = React.useState<string | null>(null);

  useEffect(() => {
    const syncComments = () => {
      // `getComments` lets us fetch all comments.
      instance?.getComments().then((initialComments) => {
        // Filter comments that belong to this thread using the ID passed to the component via props.
        const commentsInThread = initialComments.filter((c) => c.rootId === id);
        setComments(commentsInThread);
      });
    };

    syncComments();

    // Since comments can change due to various factors, we add a listener to sync them.
    instance?.addEventListener("comments.change", syncComments);
  }, [instance, id]);

  return (
    <ThemeProvider theme={themes.base.light}>
      <FrameProvider>
        <I18nProvider shouldLogMissingMessages={false} locale="en-US">
          <Box
            padding="md"
            backgroundColor="background.primary.subtle"
            borderRadius="sm"
            boxShadow="low"
            className="comment-thread-container"
          >
            <Text size="sm" type="label" className="comment-thread-counter">
              💬 Total comments: {comments?.size?? 0}
            </Text>
            {comments?.map((comment) => (
              <div key={comment.id} className="comment-container">
                <div className="comment-creator">
                  <Avatar
                    name={comment.creatorName?? "Anonymous"}
                    showInitials
                  />
                  <Text type="label" size="md">
                    {comment.creatorName?? "Anonymous"}
                  </Text>
                </div>
                <div className="comment-date">
                  <Text type="helper" size="sm">
                    📅 Date: {comment.createdAt?.toLocaleString()}
                  </Text>
                </div>
                {editCommentId === comment.id? (
                  <Editor
                    placeholder="Edit your comment"
                    autoFocus
                    clearOnSave
                    clearOnCancel
                    saveOnEnter
                    aria-label="Comment"
                    defaultValue={comment.text.value?? ""}
                    onSave={(value) => {
                      const updatedComment = comment.set("text", {
                        format: "plain",
                        value,
                      });

                      // Use the update API to save the edited comment.
                      instance?.update(updatedComment).then(() => {
                        setEditCommentId(null);
                      });
                    }}
                  />
                ) : (
                  <>
                    <div className="comment-content">{comment.text.value}</div>
                    <ActionButton
                      label="Edit"
                      onClick={() => setEditCommentId(comment.id)}
                      className="comment-button"
                    />
                    <ActionButton
                      label="Delete"
                      variant="error"
                      // Use the delete API to remove the comment.
                      onClick={() => instance?.delete(comment)}
                      className="comment-button"
                    />
                  </>
                )}
              </div>
            ))}
            <Editor
              placeholder="Add a comment"
              aria-label="Comment"
              autoFocus
              clearOnSave
              saveOnEnter
              onSave={async (value) => {
                if (!instance) {
                  return;
                }

                const commentsInThread = (await instance.getComments()).filter(
                  (c) => c.rootId === id,
                );

                const isFirstComment = commentsInThread.size === 0;

                if (isFirstComment) {
                  /*
                   * In case of first comment, the SDK already creates a draft comment along with `CommentMarkerAnnotation`.
                   * So we need to update that draft comment instead of creating a new one.
                   */

                  const draftCommentInThread: Comment =
                    // `includeDrafts` option helps us fetch draft comments.
                    (await instance.getComments({ includeDrafts: true })).filter((c) => c.rootId === id && c.pageIndex === null).first();

                  const annotations = await instance.getAnnotations(
                    instance.viewState.currentPageIndex,
                  );

                  /* We also need to mark the associated `CommentMarkerAnnotation` as the root of the comment thread. */
                  const rootAnnotation = annotations.find((a) => a.id === id)?.set("isCommentThreadRoot", true);

                  if (!rootAnnotation) {
                    return;
                  }

                  // Update the draft comment with the content and `pageIndex`, which publishes it.
                  const newComment = draftCommentInThread.set("text", {
                      format: "plain",
                      value,
                    }).set("pageIndex", instance.viewState.currentPageIndex);

                  // Use update API to update both the draft comment and the root annotation.
                  await instance.update([newComment, rootAnnotation]);
                } else {
                  /* For subsequent comments, we can create new comments without relying on draft comments. */
                  const newComment = new Comment({
                    rootId: id,
                    text: {
                      format: "plain",
                      value,
                    },
                    pageIndex: instance.viewState.currentPageIndex,
                    createdAt: new Date(),
                  });

                  // Use create API to add the new comment.
                  await instance.create(newComment);
                }
              }}
            />
          </Box>
        </I18nProvider>
      </FrameProvider>
    </ThemeProvider>
  );
};

export default CommentThread;

```

There are additional styles you can add to `public/comment-thread.css`:

```css

/* comment-thread.css */.comment-thread-container {
  min-width: 240px;
}.comment-thread-counter {
  margin-bottom: 8px;
}.comment-button {
  margin-right: 4px;
  margin-bottom: 8px;
}.comment-creator {
  display: flex;
  gap: 4px;
  align-items: center;
  margin: 8px 0;
}.comment-content {
  margin: 8px 0;
}

```

After these changes, you’ll have a fully functional custom comment thread UI that allows viewing, adding, editing, and deleting comments.

## Conclusion

You’ve successfully built a custom comment thread UI using Nutrient Web SDK’s customization API, React, and Baseline UI components.

The customization API lets you customize various parts of the Nutrient Web SDK UI to fit your application’s needs. It also makes it easier to maintain custom code by letting you author your custom UI with a framework of your choice that can work with DOM nodes.

**View code**

Check out the full example on GitHub.

[Read more](https://github.com/PSPDFKit/awesome-nutrient/tree/master/web/ui-customization/)
---

## Related pages

- [Slot customization examples](/guides/web/user-interface/ui-customization/examples.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)
- [Customizing the Nutrient Web SDK UI](/guides/web/user-interface/ui-customization/introduction.md)
- [Set UI customization configuration](/guides/web/user-interface/ui-customization/set-ui.md)
- [Supported slots for UI customization](/guides/web/user-interface/ui-customization/supported-slots.md)

