Search code examples
javascripthtmlreactjsscroll

Jumping content/unnatural move when scrolling in an element with increasing width


I haven't been able to find an answer to this question but I have seen this exact behaviour in many apps (calendars, agendas etc.). As you can see in the snippet below my container expands with scrolling to both sides - new divs are being inserted inside. When you scroll to the right it feels okay and natural, however, when you scroll to the left, it always adds the element and you stay at 0px needing to scroll a bit back and then to the left again to expand some more. Best if you try below:

import React, { useEffect, useState } from 'react';
import ReactDOM from 'react-dom/client';

function Test() {
  const [span, setSpan] = useState<Array<number>>([-1, 0, 1]);

  // Append item to the array - scrolling right
  const append = () => {
    setSpan([
      ...span,
      span[span.length - 1] + 1,
    ]);
  };

  // Prepend item to the array - scrolling left
  const prepend = () => {
    setSpan([
      span[0] - 1,
      ...span,
    ]);
  };

  // Center view on load - to the middle of element '0' - e.i. the center
  useEffect(() => {
    const element = document.getElementById('element-0');
    if (element) {
      element.scrollIntoView({ behavior: 'auto', inline: 'center' });
    }
  }, []);

  // Register 'scroll' listener
  useEffect(() => {
    const element = document.getElementById('container');
    const scrolling = () => {
      if (element) {
        if (element.scrollLeft === 0) {
          prepend();
        }
        if (element.offsetWidth + element.scrollLeft >= (element.scrollWidth - 100)) {
          append();
        }
      }
    };
    element.addEventListener('scroll', scrolling);
    return () => {
      element.removeEventListener('scroll', scrolling);
    };
  }, [span.length]);

  return (
    <div style={{
      display: 'flex', alignItems: 'center', justifyContent: 'center',
    }}
    >
      <div
        id="container"
        style={{
          maxWidth: '50vw', maxHeight: '50vh', overflowX: 'auto', whiteSpace: 'nowrap', backgroundColor: 'red',
        }}
      >
        <div style={{ width: 'fit-content' }}>
          <div style={{ width: 'fit-content' }}>
            <div style={{ display: 'flex' }}>
              {span.map((element) => (
                <div key={`element-${element}`} id={`element-${element}`} style={{ minWidth: '40vw', minHeight: '100vh', border: '1px solid black' }}>
                  { element }
                </div>
              ))}
            </div>
          </div>
        </div>
      </div>
    </div>
  );
}

const root = ReactDOM.createRoot(
  document.getElementById('root')
);
root.render(
  <React.StrictMode>
    <Test />
  </React.StrictMode>
);
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.6.3/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.6.3/umd/react-dom.production.min.js"></script>

I tried programatically scrolling a bit to the right before prepending new item, but it only created more issues. Is there an easy way to solve it?


Solution

  • Prepending an element doesn't make its container's scrollLeft increase by as much as the element's width.

    enter image description here

    Instead, the scrollLeft's value remains the same, and so the new box effectively "pops" into the view:

    enter image description here

    Since, as you mentioned, the scrollLeft remains at zero after insertion, further mouse wheel movement doesn't result in the container's scroll, so the scroll event is not fired, and hence the box insertion logic is not evaluated.

    That can be solved, for example, by listening for the wheel event rather than the scroll. The problem is, that the scrollLeft would still stay at zero, so the boxes would just appear in the view one by one rather than letting the user scroll onto them. Demo. Plus, the mouse wheel is not the only way to scroll.

    As such, by the very definition of the problem, we need to manually adjust the scroll position so that the view remains at the same element as before the insertion. In this case, this amount is simply the offsetWidth of the box, so the solution could be as follows:

    Demo

    const boxWidth = document.getElementById("element-0").offsetWidth;
    if (element.scrollLeft < 100) {
      element.scrollBy(boxWidth, 0);
      prepend();
    }
    else if (/*...*/) { 
    

    I hope this answers your question. :)