В предыдущей статье мы разобрали “умный 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
На основе этого мы:
- определяем состояние (
static / fixed / absolute) - вычисляем смещение (если нужно)
🧠 Что делать с длинным сайдбаром
Если сайдбар выше экрана, простого fixed недостаточно.
В этом случае добавляется ещё один слой логики:
👉 содержимое сайдбара смещается внутри фиксированного блока
transform: translateY(-offset)
Это даёт эффект:
- один скролл страницы
- доступ ко всему контенту
- без
overflow: auto
🔄 Поведение в итоге
При скролле пользователь видит:
- Сайдбар в потоке
- Сайдбар прилипает
- Контент внутри начинает “двигаться”
- Сайдбар доходит до конца
- Останавливается внизу контейнера
⚖️ Когда этот подход оправдан
Этот вариант имеет смысл, если:
- сайдбар длинный
- важен один скролл страницы
- нужно контролировать поведение вплоть до пикселя
stickyдаёт недостаточно гибкости
⚠️ Когда лучше остаться на первом решении
Если:
- логика из первой статьи уже решает задачу
- нет сложных ограничений по контейнеру
- хочется проще и быстрее
👉 усложнять не обязательно
🧪 Альтернатива, о которой стоит помнить
Есть ещё более простой вариант:
position: sticky;
max-height: calc(100vh - offset);
overflow: auto;
Он:
- надёжнее
- проще
- но даёт второй скролл
И иногда это абсолютно нормальный компромисс, особенно в админках.
🧠 Итог
В реальности нет одного “правильного” sticky.
Есть несколько подходов:
sticky— простой и нативныйsticky + overflow— практичный- “умный sticky” (первая статья) — UX без второго скролла
fixed + absolute(этот подход) — максимум контроля
И выбор зависит не от техники, а от задачи.
📌 Главное
Хороший интерфейс — это не про “использовать правильный CSS”.
Это про:
выбрать поведение, которое лучше всего работает для пользователя
А уже потом — реализовать его любым подходящим способом.


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