Умный sticky в Vue: как сделать прилипающий блок, который не ломается от длинного контента

от автора

в
Время чтения: 2 мин.

position: sticky — одна из тех вещей, которые кажутся идеальными…
пока не начинаешь использовать их в реальном интерфейсе.

Особенно если у тебя:

  • длинный сайдбар
  • фильтры
  • или просто блок, который выше экрана

В какой-то момент всё начинает ломаться.

В этой статье разберём решение:
👉 sticky-блок с “внутренней прокруткой” без overflow: auto


❌ Проблема обычного sticky

Классический вариант:

position: sticky;
top: 0;

Работает отлично, пока:

  • контент меньше высоты viewport

Но если больше — начинаются проблемы:

  • часть контента становится недоступной
  • появляется вложенный скролл
  • UX превращается в боль

💡 Идея решения

Вместо того чтобы:

добавлять overflow: auto и плодить скроллы

мы делаем иначе:

двигаем сам sticky-блок внутри себя

То есть:

  • sticky остаётся sticky
  • но его содержимое “прокручивается” за счёт смещения

🧩 Полный код компонента

<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { useWindowScroll, useElementBounding } from '@vueuse/core'

const props = defineProps<{
  offsetTop?: number
  offsetBottom?: number
}>()

const containerRef = ref<HTMLElement | null>(null)
const stickyRef = ref<HTMLElement | null>(null)

const { y: scrollY } = useWindowScroll()

const lastScrollY = ref(0)
const offsetY = ref(0)

const { height: stickyHeight } = useElementBounding(stickyRef)
const { top: containerTop } = useElementBounding(containerRef)

const topOffset = computed(() => props.offsetTop ?? 0)
const bottomOffset = computed(() => props.offsetBottom ?? 0)

const clamp = (val: number, min: number, max: number) => Math.max(min, Math.min(max, val))

watch(scrollY, (newY) => {
  const delta = newY - lastScrollY.value
  lastScrollY.value = newY

  const stickyH = stickyHeight.value
  const containerT = containerTop.value
  const available = window.innerHeight - topOffset.value - bottomOffset.value
  const overflow = stickyH - available

  if (overflow <= 0 || containerT > topOffset.value) {
    offsetY.value = 0
    return
  }

  offsetY.value = clamp(offsetY.value + delta, 0, overflow)
})
</script>

<template>
  <div ref="containerRef" class="relative">
    <div
      ref="stickyRef"
      class="sticky"
      :style="{
        top: `-${offsetY}px`,
      }"
    >
      <slot />
    </div>
  </div>
</template>

🔍 Как это работает

Разберём ключевые части.


1. Отслеживание скролла

Компонент слушает позицию страницы:

// scrollY

И вычисляет разницу:

// delta = текущий скролл - предыдущий

👉 Это даёт понимание:

  • пользователь скроллит вниз или вверх

2. Определение доступного пространства

Ключевая формула:

// available = высота окна - отступы
// overflow = высота sticky - доступное пространство

👉 Здесь происходит магия:

  • если overflow <= 0 → всё помещается
  • если overflow > 0 → нужно “скроллить внутри”

3. Когда ничего делать не нужно

// если элемент помещается
// или ещё не дошёл до sticky-зоны

👉 тогда:

  • offset сбрасывается
  • поведение как у обычного sticky

4. Основная логика движения

// offset += delta
// ограничиваем через clamp

👉 мы:

  • накапливаем смещение
  • но не даём выйти за пределы

5. Ограничение (clamp)

// clamp(offset, 0, overflow)

Это важно:

  • не даёт улететь вверх
  • не даёт выйти за нижнюю границу

6. Сам трюк

В шаблоне:

// top: -offsetY

👉 вместо обычного:

top: 0;

мы делаем:

  • top отрицательным
  • и двигаем контент вверх

🎯 Что получается в итоге

Поведение выглядит так:

  1. Скроллим страницу
  2. Блок “прилипает”
  3. Когда контент заканчивает помещаться:
    • он начинает “прокручиваться внутри”
  4. Дошёл до конца → зафиксировался

И всё это:
👉 без второго скролла


🧪 Где это реально полезно

  • сайдбар фильтров (e-commerce)
  • оглавление статьи
  • админ-панели
  • панели инструментов
  • любые “длинные” sticky-блоки

⚠️ Подводные камни

Частые обновления при скролле

Каждое движение вызывает пересчёт:

// watch(scrollY)

👉 решение:

  • throttle / debounce

Зависимость от окна

// window.innerHeight

👉 может быть проблемой при SSR


Динамический контент

Если внутри:

  • раскрываются блоки
  • подгружается контент

👉 высота меняется → расчёты могут поехать


🚀 Как можно улучшить

Если хочется довести до продакшн-идеала:

  • добавить useThrottleFn
  • реагировать на resize
  • использовать transform вместо top (плавнее)
  • вынести в composable
  • добавить поддержку анимации

🧠 Итог

Этот компонент — хороший пример того, как:

не бороться с браузером, а использовать его механику

Ты не создаёшь новый скролл.
Ты просто управляешь поведением sticky.

В итоге получаешь:

  • чистый UX
  • один скролл
  • предсказуемое поведение

Если делаешь админки, маркетплейсы или просто сложные интерфейсы —
это один из тех паттернов, который реально стоит иметь в арсенале.


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Сколько будет 6 + 4?