AG & Dev

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

9 июня 2024 г.

Другие статьи

Dockerfile для Keystone CMS 6
Dockerfile и compose.yaml для развёртывания Keystone CMS 6
Аутентификация в Keystatic CMS
Настройка аутентификации в Keystatic CMS (Local mode) в проекте Next.js
Валидация формы на стороне сервера
Валидация формы на стороне сервера при помощи React-хука useActionState
Глобальное состояние в React
Глобальное состояние в React без подключения сторонних библиотек
Яндекс Карты в проекте Next.js
Подключение Яндекс Карты (API 3) к проекту Next.js (App Router)
Подключение к Headless CMS из Next.js
Пример подключения к Headless CMS (Wordpress) из приложения Next.js посредством REST API
Устранение FOUC в Next.js
Устранение FOUC-эффекта при использовании тёмной темы сайта в Next.js
Подключение Payload CMS к проекту Next.js
Как подключить Payload CMS к существующему проекту Next.js
Создание своего Rehype-плагина
Создание Rehype-плагина для корректировки HTML-кода при конвертировании из Markdown
Яндекс Метрика в проекте SvelteKit
Подключение счётчика Яндекс Метрики к проекту на SvelteKit (Svelte 5)
Все статьи

Технологический стек

AG & Dev

Разработка быстрых и надёжных веб-сайтов на базе самых современных технологий

Профиль на GitHub

Инструменты

React
Next.js
Tailwind

Платформы

Node.js
Docker
Nginx

CMS

Directus
Keystone
Keystatic

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

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

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

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

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

Решение

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

Page.svelte
<script>
  import Sticky from './Sticky.svelte';
  import Sidebar from './Sidebar.svelte';
</script>
 
<Sticky>
  <Sidebar />
</Sticky>

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

Sticky.svelte
<script>
  import { scrollY, innerHeight } from 'svelte/reactivity/window';
 
  let { children } = $props();
 
  // отступы от окна
  const offset = {
    top: 80,
    bottom: 30
  };
 
  // родительский элемент
  let parent = $state({
    el: undefined,
    height: undefined,
    top: undefined
  });
 
  // липкий элемент
  let child = $state({
    el: undefined,
    height: undefined,
    top: undefined,
    style: {
      top: offset.top
    }
  });
 
  // позиции скроллинга окна
  let posY = $state({
    current: 0,
    previous: 0
  });
 
  // обработчик скроллинга
  const onscroll = () => {
    // если высота элемента равна высоте родителя
    if (child.height === parent.height) return;
 
    // текущая и предыдущая позиции скроллинга окна
    posY.previous = posY.current;
    posY.current = scrollY.current;
 
    // позиции элемента и родителя относительно окна
    parent.top = parent.el.getBoundingClientRect().top;
    child.top = child.el.getBoundingClientRect().top;
 
    // если скроллинг вниз
    if (posY.current > posY.previous) {
      // если нижний край элемента выходит за нижнюю границу
      if (child.height + child.top + offset.bottom > innerHeight.current) {
        // если родительский элемент выше
        if (parent.top < child.top) {
          // поднимаем элемент вместе со скроллом
          child.style.top = child.top - (posY.current - posY.previous);
          // корректировка крайнего положения
          if (child.style.top < innerHeight.current - child.height - offset.bottom) {
            child.style.top = innerHeight.current - child.height - offset.bottom;
          }
        }
      }
 
      // если скроллинг вверх
    } else {
      // если верхний край элемента выходит за верхнюю границу
      if (child.top < offset.top) {
        // если родительский элемент ниже
        if (child.top + child.height < parent.top + parent.height) {
          // опускаем элемент вместе со скроллом
          child.style.top = child.top + (posY.previous - posY.current);
          // корректировка крайнего положения
          if (child.style.top > offset.top) {
            child.style.top = offset.top;
          }
        }
      }
    }
  };
</script>
 
<svelte:window {onscroll} />
 
<div bind:this={parent.el} bind:offsetHeight={parent.height} class="h-full w-full">
  <div
    bind:this={child.el}
    bind:offsetHeight={child.height}
    class="sticky"
    style:top={child.style.top + 'px'}>
    {@render children()}
  </div>
</div>