Скроллинг «липкого» элемента, превышающего высоту экрана, с помощью Svelte
Чтобы сделать элемент “липким”, например, боковую панель, достаточно этому элементу присвоить стили position: sticky;
и top: 80px;
(верхний отступ может быть любым). Однако, если высота “липкого” элемента окажется больше высоты экрана, то нижняя его часть будет скрыта пока пользователь не прокрутит страницу до конца родительского элемента. При этом, высота родительского элемента может быть достаточно большой, например, если “липкая” боковая панель находится рядом с длинным текстом или списком товаров. В таком случае, чтобы добраться до нижней части “липкого” элемента, превышающего высоту экрана, придётся пролистать вниз всю страницу.
Найти готовое решение, позволяющее прокручивать “липкий” элемент вверх-вниз сразу, вместе со страницей, мне не удалось. Точнее, те решения, которые я нашёл, работали либо криво, либо требовали использовать конкретную разметку, которая накладывала неприемлемые ограничения на вёрстку, например, нужно было отказаться от display: grid;
родительского элемента.
Поэтому, я решил разработать собственное решение, которое было бы лишено тех недостатков, с которыми я столкнулся в готовых решениях, и удовлетворяло бы следующим требованиям:
- работать “как часы”,
- не создавать дополнительных полос прокрутки,
- прокручивать “липкий” элемент вместе со страницей, а не автономно,
- не искажать другие элементы внутри и снаружи “липкого” элемента,
- не подрагивать в крайней верхней и крайней нижней позициях,
- работать с любым “липким” элементом, не требуя строго определённой разметки родительских элементов.
В своём решении я использовал лёгкий реактивный фреймворк Svelte, позволяющий управлять элементом декларативно, и стили Tailwind. Тем не менее, это решение можно перенести на любой другой реактивный фреймворк (React, Vue и т.п.) и использовать другую CSS-библиотеку (Bootstrap, Bulma и т.п.). При желании, можно воспользоваться ванильным JavaScript и чистым CSS.
Решение
Создаём компонент Sticky.svelte
, в который можно обернуть любой компонент, нуждающийся в “липкости” и скроллинге одновременно, например:
<script>
import Sticky from "./Sticky.svelte";
import Sidebar from "./Sidebar.svelte";
</script>
<Sticky>
<Sidebar />
</Sticky>
Скрипт компонента 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>