Скроллинг «липкого» элемента, превышающего высоту экрана, с помощью Svelte

Чтобы сделать элемент “липким”, например, боковую панель, достаточно этому элементу присвоить стили position: sticky; и top: 70px; (верхний отступ может быть любым). Однако, если высота “липкого” элемента окажется больше высоты экрана, то нижняя его часть будет скрыта пока пользователь не прокрутит страницу до конца родительского элемента. При этом, высота родительского элемента может быть достаточно большой, например, если “липкая” боковая панель находится рядом с длинным текстом или списком товаров. В таком случае, чтобы добраться до нижней части “липкого” элемента, превышающего высоту экрана, придётся пролистать вниз всю страницу.

Найти готовое решение, позволяющее прокручивать “липкий” элемент вверх-вниз сразу, вместе со страницей, мне не удалось. Точнее, те решения, которые я нашёл, работали либо криво, либо требовали использовать конкретную разметку, которая накладывала неприемлемые ограничения на вёрстку, например, нужно было отказаться от display: grid; родительского элемента.

Поэтому, я решил разработать собственное решение, которое было бы лишено тех недостатков, с которыми я столкнулся в готовых решениях, и удовлетворяло бы следующим требованиям:

  • работать “как часы”,
  • не создавать дополнительных полос прокрутки,
  • прокручивать “липкий” элемент вместе со страницей, а не автономно,
  • не искажать другие элементы внутри и снаружи “липкого” элемента,
  • не подрагивать в крайней верхней и крайней нижней позициях,
  • работать с любым “липким” элементом, не требуя строго определённой разметки родительских элементов.

В своём решении я использовал лёгкий реактивный фреймворк Svelte, позволяющий декларативно управлять стилями элемента, но это решение можно легко перенести на любой другой реактивный фреймворк (React, Vue и т.п.) или воспользоваться ванильным JavaScript.

Решение

Создаём компонент Sticky.svelte, в который можно обернуть любой компонент, нуждающийся в “липкости” и скроллинге одновременно, например:

<!-- App.svelte -->
<script>
  import Sticky from "./Sticky.svelte";
  import Sidebar from "./Sidebar.svelte";
</script>

<Sticky>
  <Sidebar />
</Sticky>

Скрипт компонента Sticky.svelte я снабдил подробными комментариями и, надеюсь, в дополнительных пояснениях он не нуждается.

Код Sticky.svelte для 4-ой версии Svelte (ниже код для 5-ой версии):

<script>
  // прокручиваемый липкий элемент и родительский элемент
  let element;
  let parent;

  // текущая и предыдущая позиции скроллинга окна
  let currentY = scrollY;
  let previousY = scrollY;

  // отступ липкого элемента относительно родителя
  let elementParentOffset = 0;

  // верхний и нижний отступы до липкого элемента
  const topOffset = 70;
  const bottomOffeset = 20;

  // стили элемента по умолчанию
  let position = "sticky";
  let top = topOffset + "px";

  // обработчик скроллинга
  const handleScroll = () => {
    // если высота элемента равна высоте родителя
    if (element.offsetHeight === parent.offsetHeight) return;

    // если высота элемента меньше высоты окна
    if (innerHeight - topOffset - bottomOffeset >= element.offsetHeight) return;

    // текущая и предыдущая позиции скроллинга окна
    previousY = currentY;
    currentY = scrollY;

    // если позиция скроллинга окна не изменилась
    if (currentY === previousY) return;

    // позиция элемента и родителя относительно окна
    const elementRect = element.getBoundingClientRect();
    const parentRect = parent.getBoundingClientRect();

    // верхний и нижний отступы элемента от окна
    const elementTopOffset = Math.round(elementRect.top);
    const elementBottomOffset = Math.round(innerHeight - elementRect.bottom);

    // если в настоящее время элемент липкий
    if (position === "sticky") {
      // сохраняем его позицию относительно родителя
      elementParentOffset = elementRect.top - parentRect.top;
    }

    // если скроллинг вниз
    if (currentY > previousY) {
      // если нижний край элемента достиг нижнего отступа
      if (elementBottomOffset >= bottomOffeset) {
        // делаем элемент липким по нижему отступу
        position = "sticky";
        top = innerHeight - element.offsetHeight - bottomOffeset + "px";
      } else {
        // иначе скроллим элемент вместе со страницей
        position = "relative";
        // в текущей позиции относительно родителя
        top = elementParentOffset + "px";
      }
    } else {
      // иначе скроллинг вверх
      // если верхний край элемента достиг верхнего отступа
      if (elementTopOffset >= topOffset) {
        // делаем элемент липким по верхнему отступу
        position = "sticky";
        top = topOffset + "px";
      } else {
        // иначе скроллим элемент вместе со страницей
        position = "relative";
        // в текущей позиции относительно родителя
        top = elementParentOffset + "px";
      }
    }
  };
</script>

<svelte:window on:scroll={handleScroll} />

<div bind:this={parent} style:width="100%" style:height="100%">
  <div bind:this={element} style:position style:top>
    <slot />
  </div>
</div>

Код Sticky.svelte для 5-ой версии Svelte (выше код для 4-ой версии):

<script>
  let { children } = $props();

  // прокручиваемый липкий элемент и родительский элемент
  let element;
  let parent;

  // текущая и предыдущая позиции скроллинга окна
  let currentY = $state(scrollY);
  let previousY = $state(scrollY);

  // отступ липкого элемента относительно родителя
  let elementParentOffset = $state(0);

  // верхний и нижний отступы до липкого элемента
  const topOffset = 70;
  const bottomOffeset = 20;

  // стили элемента по умолчанию
  let position = $state("sticky");
  let top = $state(topOffset + "px");

  // обработчик скроллинга
  const handleScroll = () => {
    // если высота элемента равна высоте родителя
    if (element.offsetHeight === parent.offsetHeight) return;

    // если высота элемента меньше высоты окна
    if (innerHeight - topOffset - bottomOffeset >= element.offsetHeight) return;

    // текущая и предыдущая позиции скроллинга окна
    previousY = currentY;
    currentY = scrollY;

    // если позиция скроллинга окна не изменилась
    if (currentY === previousY) return;

    // позиция элемента и родителя относительно окна
    const elementRect = element.getBoundingClientRect();
    const parentRect = parent.getBoundingClientRect();

    // верхний и нижний отступы элемента от окна
    const elementTopOffset = Math.round(elementRect.top);
    const elementBottomOffset = Math.round(innerHeight - elementRect.bottom);

    // если в настоящее время элемент липкий
    if (position === "sticky") {
      // сохраняем его позицию относительно родителя
      elementParentOffset = elementRect.top - parentRect.top;
    }

    // если скроллинг вниз
    if (currentY > previousY) {
      // если нижний край элемента достиг нижнего отступа
      if (elementBottomOffset >= bottomOffeset) {
        // делаем элемент липким по нижему отступу
        position = "sticky";
        top = innerHeight - element.offsetHeight - bottomOffeset + "px";
      } else {
        // иначе скроллим элемент вместе со страницей
        position = "relative";
        // в текущей позиции относительно родителя
        top = elementParentOffset + "px";
      }
    } else {
      // иначе скроллинг вверх
      // если верхний край элемента достиг верхнего отступа
      if (elementTopOffset >= topOffset) {
        // делаем элемент липким по верхнему отступу
        position = "sticky";
        top = topOffset + "px";
      } else {
        // иначе скроллим элемент вместе со страницей
        position = "relative";
        // в текущей позиции относительно родителя
        top = elementParentOffset + "px";
      }
    }
  };
</script>

<svelte:window on:scroll={handleScroll} />

<div bind:this={parent} style:width="100%" style:height="100%">
  <div bind:this={element} style:position style:top>
    {@render children()}
  </div>
</div>
AG & Dev © 2020 Moscow