Sticky-сайдбар без sticky: когда нужен больший контроль

от автора

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

В предыдущей статье мы разобрали “умный sticky” через смещение (top: -offset).
Этот подход позволяет сохранить один скролл страницы и аккуратно обрабатывать длинный контент.

Но это не единственный способ.

Иногда хочется:

  • больше контроля над поведением
  • чётко управлять границами
  • избежать накопительной логики через delta

В таких случаях можно пойти другим путём и реализовать sticky-поведение вручную — через position: fixed.


💡 Идея подхода

Вместо того чтобы “улучшать” position: sticky, мы:

👉 полностью берём управление на себя

И описываем поведение сайдбара как набор состояний.


🧩 Состояния сайдбара

Фактически, у нас есть три режима:

1. static

Сайдбар ведёт себя как обычный блок в потоке.

Это происходит, пока пользователь не доскроллил до него.


2. fixed

Сайдбар закрепляется в viewport:

position: fixed;
top: offsetTop;

Теперь он “прилип” к экрану.


3. absolute

Когда мы доходим до конца контейнера:

position: absolute;
bottom: 0;

Сайдбар “останавливается” внутри родителя и больше не двигается.


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

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

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

const rootRef = ref<HTMLElement | null>(null)
const sidebarRef = ref<HTMLElement | null>(null)

const { y: scrollY } = useWindowScroll()
const { height: windowHeight } = useWindowSize()

const {
  top: rootTop,
  bottom: rootBottom,
  left: rootLeft,
  width: rootWidth,
  height: rootHeight,
} = useElementBounding(rootRef)

const { height: sidebarHeight } = useElementBounding(sidebarRef)

const viewportAvailable = computed(() => {
  return windowHeight.value - props.offsetTop - props.offsetBottom
})

const overflow = computed(() => {
  return Math.max(0, sidebarHeight.value - viewportAvailable.value)
})

const maxTranslateInside = computed(() => {
  return overflow.value
})

const maxAbsoluteTop = computed(() => {
  return Math.max(0, rootHeight.value - sidebarHeight.value)
})

const state = computed<'static' | 'fixed-top' | 'fixed-bottom' | 'absolute-bottom'>(() => {
  if (!rootRef.value || !sidebarRef.value) {
    return 'static'
  }

  // пока контейнер не дошёл до зоны прилипания
  if (rootTop.value > props.offsetTop) {
    return 'static'
  }

  // если сайдбар помещается во viewport
  if (overflow.value <= 0) {
    // если низ контейнера уже дошёл до низа сайдбара в fixed-состоянии
    const fixedBottomEdge = props.offsetTop + sidebarHeight.value
    if (rootBottom.value <= fixedBottomEdge) {
      return 'absolute-bottom'
    }

    return 'fixed-top'
  }

  // если сайдбар длиннее viewport
  const fixedVisibleBottom = windowHeight.value - props.offsetBottom
  const shouldStickBottom = rootBottom.value > fixedVisibleBottom

  if (shouldStickBottom) {
    return 'fixed-bottom'
  }

  return 'absolute-bottom'
})

const translateY = computed(() => {
  if (overflow.value <= 0) {
    return 0
  }

  // как далеко верх контейнера ушёл выше точки фиксации
  const passed = Math.max(0, props.offsetTop - rootTop.value)

  return Math.min(passed, maxTranslateInside.value)
})

const sidebarStyle = computed<Record<string, string>>(() => {
  if (!rootRef.value || !sidebarRef.value) {
    return {}
  }

  if (state.value === 'static') {
    return {
      position: 'static',
      width: '100%',
      transform: 'translateY(0)',
    }
  }

  if (state.value === 'fixed-top') {
    return {
      position: 'fixed',
      top: `${props.offsetTop}px`,
      left: `${rootLeft.value}px`,
      width: `${rootWidth.value}px`,
      transform: 'translateY(0)',
    }
  }

  if (state.value === 'fixed-bottom') {
    return {
      position: 'fixed',
      top: `${props.offsetTop}px`,
      left: `${rootLeft.value}px`,
      width: `${rootWidth.value}px`,
      transform: `translateY(-${translateY.value}px)`,
    }
  }

  // absolute-bottom
  return {
    position: 'absolute',
    top: `${maxAbsoluteTop.value}px`,
    left: '0',
    width: '100%',
    transform: 'translateY(0)',
    }
})
</script>

<template>
  <aside ref="rootRef" class="relative">
    <div ref="sidebarRef" :style="sidebarStyle">
      <slot />
    </div>
  </aside>
</template>

🔍 Что меняется по сравнению с первым подходом

В первой статье логика строилась вокруг:

  • отслеживания delta
  • накопления смещения

Здесь подход другой:

👉 позиция сайдбара вычисляется из текущего состояния страницы

То есть:

  • без хранения истории скролла
  • без накопления ошибок
  • поведение легче предсказать

📐 Как определяется позиция

Основные факторы:

  • положение контейнера (top, bottom)
  • высота сайдбара
  • высота viewport
  • текущий scroll

На основе этого мы:

  1. определяем состояние (static / fixed / absolute)
  2. вычисляем смещение (если нужно)

🧠 Что делать с длинным сайдбаром

Если сайдбар выше экрана, простого fixed недостаточно.

В этом случае добавляется ещё один слой логики:

👉 содержимое сайдбара смещается внутри фиксированного блока

transform: translateY(-offset)

Это даёт эффект:

  • один скролл страницы
  • доступ ко всему контенту
  • без overflow: auto

🔄 Поведение в итоге

При скролле пользователь видит:

  1. Сайдбар в потоке
  2. Сайдбар прилипает
  3. Контент внутри начинает “двигаться”
  4. Сайдбар доходит до конца
  5. Останавливается внизу контейнера

⚖️ Когда этот подход оправдан

Этот вариант имеет смысл, если:

  • сайдбар длинный
  • важен один скролл страницы
  • нужно контролировать поведение вплоть до пикселя
  • sticky даёт недостаточно гибкости

⚠️ Когда лучше остаться на первом решении

Если:

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

👉 усложнять не обязательно


🧪 Альтернатива, о которой стоит помнить

Есть ещё более простой вариант:

position: sticky;
max-height: calc(100vh - offset);
overflow: auto;

Он:

  • надёжнее
  • проще
  • но даёт второй скролл

И иногда это абсолютно нормальный компромисс, особенно в админках.


🧠 Итог

В реальности нет одного “правильного” sticky.

Есть несколько подходов:

  • sticky — простой и нативный
  • sticky + overflow — практичный
  • “умный sticky” (первая статья) — UX без второго скролла
  • fixed + absolute (этот подход) — максимум контроля

И выбор зависит не от техники, а от задачи.


📌 Главное

Хороший интерфейс — это не про “использовать правильный CSS”.

Это про:

выбрать поведение, которое лучше всего работает для пользователя

А уже потом — реализовать его любым подходящим способом.


Комментарии

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

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

Сколько будет 8 + 1?