ResizeObserver chat example

ResizeObserver not detected. Demo will not work.

Chatbot delay:
Example 1. Scroll to bottom on every resize.
Example 2. Example 1 + preserve user scroll position.
Example 3. Example 2 + smooth scroll.

The problem

ResizeObserver API can be used to solve "how to keep chat window scrolled to the bottom" problem.

By default, browsers preserve scroll position from the top. This is because important content is at the top.

In chat window, important content is at the bottom: the last message. Instead of preserving top scroll position, we need to keep user pinned to bottom scroll position when user has scrolled all the way down. We can do this by scrolling to the bottom on every resize.

How to scroll to bottom on resize?

This is easily implemented with ResizeObserver.

The simplest implementation is:

.chat {
  overflow: scroll;
}

<div class="chat">  <!-- chat has the scrollbar -->
  <div class="chat-text"> <!-- chat-text contains chat text -->
    <div>jack: hi </div>
    <div>jill: hi </div>
  </div>
</div

let ro = new ResizeObserver( entries => {
  for (let e of entries) {
    let chat = e.target.parentNode;
    chat.scrollTop = chat.scrollHeight - chat.clientHeight;
  }
});
ro.observe(document.querySelector('.chat-text'))
chat has the scrollbar, and chat-text contains the chat text. ResizeObserver observes chat-text's size, and scrolls chat to bottom on every resize. Example 1 implements this. You can try it out by in live demo at the bottom of this page by clicking on Chat/Chatbot buttons.

Appending new messages to chat-text will also trigger a resize, and scroll to the bottom, which is great.

But, there is a problem. What happens if user has scrolled higher up trying to read older messages? Scrolling to the bottom would make user lose text they were reading. Our chat window implementation has a new constraint:

How to not lose user's scroll position?

We can use scroll event to detect when user has scrolled. There is no API to distinguish whether scroll event was initiated by user, or javascript. This is problematic, because we only want to save user-initated scroll. The hacky solution is to use flags to distinguish between user and programatic scroll. It is implemented as an Example 2.

let ro = new ResizeObserver();

function initScrollPositionChat(chat) {
  let chatText = chat.firstElementChild;
  chatText.resizeHandler = entry => {
    let chat = entry.target.parentNode;
    // Scroll to bottom, unless user has scrolled.
    if (!chat.saveUserScroll) {
      chat.isResizeScrollEvent = true;
      chat.scrollTop = chat.scrollHeight - chat.clientHeight;
    }
  }
  ro.observe(chatText);
  chat.addEventListener('scroll', ev => {
    // Ignore scrolls generated by ResizeObserver
    if (chat.isResizeScrollEvent) {
      delete chat.isResizeScrollEvent;
      return;
    }
    // Save user's position unless scrolled to the bottom,
    if (chat.scrollTop != chat.scrollHeight - chat.clientHeight) {
      chat.saveUserScroll = true;
    } else {
      chat.saveUserScroll = false;
    }
  });
}

Example 3 extends Example 2 with smooth scrolling. Unfortunatelly, it has to implement smooth scrolling from scratch, instead of using scrollIntoView(behavior:'smooth'). scrollIntoView fires scroll events that cannot be distinguished from user initiated events.

For completeness, examples also implemented chat pruning to prevent chat window from getting too big.