Vue.js модальное окно: позиционирование при масштабировании
Узнайте, как создать модальное окно в Vue.js, которое сохраняет позицию относительно кнопки при изменении масштаба страницы. Решение проблемы с getBoundingClientRect().
Как создать модальное окно в Vue.js, которое всегда позиционируется рядом с кнопкой‑триггером без перекрытия и сохраняет позицию при изменении масштаба страницы?
Мне нужно реализовать модальное окно на Vue, которое:
- При нажатии на кнопку всегда отображается рядом с ней (не перекрывая кнопку).
- Сохраняет свою позицию относительно кнопки при изменении масштаба страницы.
- Не «убегает» от кнопки при масштабировании, как это происходит при использовании getBoundingClientRect().
Пример желаемого поведения можно увидеть на сайте vc.ru при нажатии на кнопку «Поделиться» под статьями.
Я пробовал использовать getBoundingClientRect(), но модальное окно перемещается по странице при изменении масштаба.
Vue.js модальное окно можно создать с использованием CSS‑позиционирования на основе viewport‑относительных единиц измерения или CSS Grid/Flexbox вместо абсолютных пиксельных координат из getBoundingClientRect(). Это решит проблему смещения при масштабировании страницы.
Содержание
- Проблема с
getBoundingClientRect() - Решение с CSS viewport‑единицами
- Использование CSS Grid для позиционирования
- Vue.js компонент с реактивным позиционированием
- Пример реализации на VC.ru
- Альтернативные подходы
- Практические рекомендации
Проблема с использованием getBoundingClientRect()
При использовании getBoundingClientRect() для позиционирования модального окна возникает фундаментальная проблема: этот метод возвращает абсолютные пиксельные координаты относительно viewport. При изменении масштаба страницы (через Ctrl+ или Ctrl-) браузер масштабирует содержимое, но абсолютные пиксельные значения остаются неизменными, что приводит к смещению модального окна относительно кнопки‑триггера.
// Проблемный код, который "убегает" при масштабировании
const buttonRect = button.getBoundingClientRect();
modal.style.left = `${buttonRect.right + 10}px`;
modal.style.top = `${buttonRect.top}px`;
Решение с CSS viewport‑единицами
Более надёжный подход — использование viewport‑относительных единиц измерения (vw, vh, vmin, vmax) или CSS‑свойств, которые автоматически адаптируются к масштабу.
Основные принципы:
- Использование
position: fixedс viewport‑координатами - Калькуляция позиций в процентах от viewport
- CSS‑переменные для динамического позиционирования
<template>
<div class="trigger-button" @click="showModal = true">
Открыть модальное окно
</div>
<div v-if="showModal"
class="modal"
:style="modalStyle">
<div class="modal-content">
Содержимое модального окна
<button @click="showModal = false">Закрыть</button>
</div>
</div>
</template>
<script>
export default {
data() {
return {
showModal: false,
modalStyle: {}
}
},
methods: {
positionModal(buttonElement) {
const buttonRect = buttonElement.getBoundingClientRect();
const modal = this.$el.querySelector('.modal');
// Используем viewport‑относительные расчёты
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// Позиционируем относительно viewport, а не абсолютные пиксели
const leftPercent = (buttonRect.right / viewportWidth) * 100;
const topPercent = (buttonRect.top / viewportHeight) * 100;
this.modalStyle = {
position: 'fixed',
left: `${leftPercent}%`,
top: `${topPercent}%`,
transform: 'translate(-100%, 0)', // Смещаем влево от кнопки
};
}
}
}
</script>
<style>
.modal {
position: fixed;
z-index: 1000;
background: white;
border: 1px solid #ccc;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.modal-content {
padding: 20px;
min-width: 300px;
}
</style>
Использование CSS Grid для позиционирования
Современный подход — использование CSS Grid для создания контейнера, который автоматически адаптируется к масштабу.
<template>
<div class="modal-container">
<button class="trigger-button" @click="showModal = true">
Открыть
</button>
<transition name="fade">
<div v-if="showModal" class="modal-wrapper">
<div class="modal" ref="modal">
<div class="modal-content">
Содержимое модального окна
<button @click="showModal = false">Закрыть</button>
</div>
</div>
</div>
</transition>
</div>
</template>
<script>
export default {
data() {
return {
showModal: false
}
},
watch: {
showModal(newVal) {
if (newVal) {
this.$nextTick(() => {
this.positionModalWithGrid();
});
}
}
},
methods: {
positionModalWithGrid() {
const button = this.$el.querySelector('.trigger-button');
const modal = this.$refs.modal;
if (!button || !modal) return;
const buttonRect = button.getBoundingClientRect();
// Создаём временный grid для позиционирования
document.body.style.display = 'grid';
document.body.style.gridTemplateColumns = '1fr';
document.body.style.gridTemplateRows = '1fr';
// Позиционируем модальное окно рядом с кнопкой
modal.style.gridRow = Math.ceil(buttonRect.top / window.innerHeight) + ' / ' +
Math.ceil((buttonRect.bottom) / window.innerHeight);
modal.style.gridColumn = Math.ceil(buttonRect.right / window.innerWidth) + ' / auto';
// Возвращаем нормальный layout после анимации
setTimeout(() => {
document.body.style.display = '';
document.body.style.gridTemplateColumns = '';
document.body.style.gridTemplateRows = '';
}, 300);
}
}
}
</script>
<style>
.modal-container {
position: relative;
}
.modal-wrapper {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
background: rgba(0,0,0,0.5);
z-index: 1000;
}
.modal {
background: white;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
max-width: 400px;
width: 90%;
}
.fade-enter-active, .fade-leave-active {
transition: opacity 0.3s;
}
.fade-enter, .fade-leave-to {
opacity: 0;
}
</style>
Vue.js компонент с реактивным позиционированием
Вот полноценный Vue.js компонент, который решает проблему масштабирования:
<template>
<div class="relative-position">
<button
ref="triggerButton"
class="trigger-button"
@click="toggleModal"
>
{{ buttonText }}
</button>
<transition name="slide">
<div v-if="isVisible"
class="modal-overlay"
@click="closeOnOverlayClick && closeModal()">
<div
ref="modal"
class="modal"
:style="modalPositionStyle"
@click.stop
>
<div class="modal-header">
<h3>{{ title }}</h3>
<button class="close-button" @click="closeModal">×</button>
</div>
<div class="modal-body">
<slot></slot>
</div>
</div>
</div>
</transition>
</div>
</template>
<script>
export default {
name: 'PositionedModal',
props: {
buttonText: {
type: String,
default: 'Открыть'
},
title: {
type: String,
default: 'Модальное окно'
},
position: {
type: String,
default: 'right', // 'right', 'left', 'top', 'bottom'
validator: (value) => ['right', 'left', 'top', 'bottom'].includes(value)
},
offset: {
type: Number,
default: 10
},
closeOnOverlayClick: {
type: Boolean,
default: true
}
},
data() {
return {
isVisible: false,
modalPositionStyle: {}
}
},
methods: {
toggleModal() {
this.isVisible = !this.isVisible;
if (this.isVisible) {
this.$nextTick(() => {
this.calculatePosition();
});
}
},
closeModal() {
this.isVisible = false;
},
calculatePosition() {
const button = this.$refs.triggerButton;
const modal = this.$refs.modal;
if (!button || !modal) return;
const buttonRect = button.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// Рассчитываем позиции в viewport‑процентах
let left, top;
switch (this.position) {
case 'right':
left = (buttonRect.right / viewportWidth) * 100;
top = (buttonRect.top / viewportHeight) * 100;
break;
case 'left':
left = (buttonRect.left / viewportWidth) * 100;
top = (buttonRect.top / viewportHeight) * 100;
break;
case 'top':
left = (buttonRect.left / viewportWidth) * 100;
top = (buttonRect.top / viewportHeight) * 100;
break;
case 'bottom':
left = (buttonRect.left / viewportWidth) * 100;
top = (buttonRect.bottom / viewportHeight) * 100;
break;
}
// Применяем стили с использованием viewport‑относительных единиц
this.modalPositionStyle = {
position: 'fixed',
left: `${left}%`,
top: `${top}%`,
transform: this.getTransform(this.position),
zIndex: 1000
};
},
getTransform(position) {
switch (position) {
case 'right':
return 'translate(-100%, 0)';
case 'left':
return 'translate(0, 0)';
case 'top':
return 'translate(0, 100%)';
case 'bottom':
return 'translate(0, -100%)';
default:
return 'translate(-100%, 0)';
}
}
},
mounted() {
// Обрабатываем изменения размера окна и масштаба
window.addEventListener('resize', this.handleResize);
},
beforeDestroy() {
window.removeEventListener('resize', this.handleResize);
},
methods: {
handleResize() {
if (this.isVisible) {
this.$nextTick(() => {
this.calculatePosition();
});
}
}
}
}
</script>
<style scoped>
.relative-position {
position: relative;
display: inline-block;
}
.trigger-button {
padding: 8px 16px;
background: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background 0.2s;
}
.trigger-button:hover {
background: #0056b3;
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 999;
}
.modal {
background: white;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
min-width: 300px;
max-width: 500px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #eee;
}
.modal-header h3 {
margin: 0;
font-size: 18px;
}
.close-button {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: background 0.2s;
}
.close-button:hover {
background: #f0f0f0;
}
.modal-body {
padding: 20px;
}
.slide-enter-active, .slide-leave-active {
transition: all 0.3s ease;
}
.slide-enter, .slide-leave-to {
opacity: 0;
transform: scale(0.9);
}
</style>
Пример реализации на VC.ru
Чтобы добиться поведения, аналогичного кнопке «Поделиться» на VC.ru, можно использовать следующий подход:
<template>
<div class="share-button-container">
<button
class="share-button"
@click="toggleShareModal"
>
Поделиться
</button>
<div v-if="isShareModalOpen"
class="share-modal"
:style="shareModalStyle"
@click="closeShareModal">
<div class="share-modal-content" @click.stop>
<div class="share-header">
<h4>Поделиться</h4>
<button class="close-btn" @click="closeShareModal">×</button>
</div>
<div class="share-options">
<button class="share-option">
<span class="icon">📧</span>
<span>Email</span>
</button>
<button class="share-option">
<span class="icon">📱</span>
<span>Ссылка</span>
</button>
<button class="share-option">
<span class="icon">📘</span>
<span>Facebook</span>
</button>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'ShareButton',
data() {
return {
isShareModalOpen: false,
shareModalStyle: {}
}
},
methods: {
toggleShareModal() {
this.isShareModalOpen = !this.isShareModalOpen;
if (this.isShareModalOpen) {
this.$nextTick(() => {
this.positionShareModal();
});
}
},
closeShareModal() {
this.isShareModalOpen = false;
},
positionShareModal() {
const button = this.$el.querySelector('.share-button');
const modal = this.$el.querySelector('.share-modal');
if (!button || !modal) return;
const buttonRect = button.getBoundingClientRect();
const viewportWidth = window.innerWidth;
// Позиционируем модальное окно справа от кнопки
const leftPercent = (buttonRect.right / viewportWidth) * 100;
const topPercent = (buttonRect.top / window.innerHeight) * 100;
this.shareModalStyle = {
position: 'fixed',
left: `${leftPercent}%`,
top: `${topPercent}%`,
transform: 'translate(-100%, 0)',
zIndex: 1000
};
}
}
}
</script>
<style scoped>
.share-button-container {
position: relative;
}
.share-button {
padding: 6px 12px;
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
}
.share-button:hover {
background: #e9ecef;
border-color: #adb5bd;
}
.share-modal {
position: fixed;
background: white;
border: 1px solid #dee2e6;
border-radius: 6px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
min-width: 200px;
z-index: 1001;
}
.share-modal-content {
padding: 8px 0;
}
.share-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 16px;
border-bottom: 1px solid #eee;
}
.share-header h4 {
margin: 0;
font-size: 14px;
font-weight: 600;
}
.close-btn {
background: none;
border: none;
font-size: 18px;
cursor: pointer;
padding: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: background 0.2s;
}
.close-btn:hover {
background: #f0f0f0;
}
.share-options {
display: flex;
flex-direction: column;
}
.share-option {
display: flex;
align-items: center;
padding: 8px 16px;
background: none;
border: none;
cursor: pointer;
transition: background 0.2s;
font-size: 14px;
width: 100%;
text-align: left;
}
.share-option:hover {
background: #f8f9fa;
}
.share-option .icon {
margin-right: 12px;
font-size: 16px;
}
</style>
Альтернативные подходы
1. Использование CSS Popover API
Для современных браузеров можно использовать нативный CSS Popover API:
<template>
<button
popovertarget="share-popover"
class="share-button"
>
Поделиться
</button>
<div id="share-popover" popover="auto" class="popover-modal">
<div class="popover-content">
Содержимое модального окна
</div>
</div>
</template>
<style>
.popover-modal {
position: absolute;
background: white;
border: 1px solid #ccc;
border-radius: 4px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
padding: 16px;
margin-top: 8px;
z-index: 1000;
}
.popover-content {
display: flex;
flex-direction: column;
gap: 8px;
}
</style>
2. Использование CSS Container Queries
Подход с использованием CSS Container Queries для адаптивного позиционирования:
<template>
<div class="button-container">
<button class="trigger-button" @click="showModal = true">
Открыть
</button>
<div v-if="showModal" class="modal-container">
<div class="modal">
Содержимое модального окна
</div>
</div>
</div>
</template>
<style>
.button-container {
container-type: inline-size;
}
@container (min-width: 200px) {
.modal-container {
position: absolute;
top: 100%;
left: 0;
right: 0;
}
}
@container (max-width: 199px) {
.modal-container {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
}
.modal {
background: white;
border: 1px solid #ccc;
border-radius: 4px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
padding: 16px;
}
</style>
Практические рекомендации
- Избегайте абсолютных пиксельных координат – всегда используйте viewport‑относительные единицы или проценты
- Используйте
position: fixedвместоposition: absoluteдля модальных окон, чтобы они оставались в видимой области при прокрутке - Реагируйте на изменения масштаба – добавляйте обработчик события
resizeдля пересчёта позиции при изменении размера окна - Используйте CSS transforms для позиционирования, так как они лучше работают с масштабированием страницы
- Тестируйте в разных браузерах – убедитесь, что решение работает корректно во всех целевых браузерах
- Рассмотрите использование готовых библиотек таких как Vue Popper.js или Tippy.js для сложных сценариев позиционирования
- Обрабатывайте крайние случаи – когда модальное окно может выходить за пределы viewport, автоматически смещая его в видимую область
// Пример автоматической коррекции позиции
function adjustModalPosition(modalStyle, buttonRect, modalRect) {
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// Проверяем, не выходит ли модальное окно за правый край
if (buttonRect.right + modalRect.width > viewportWidth) {
modalStyle.transform = 'translate(0, 0)'; // Смещаем влево
}
// Проверяем, не выходит ли модальное окно за нижний край
if (buttonRect.bottom + modalRect.height > viewportHeight) {
modalStyle.top = `${(buttonRect.top - modalRect.height) / viewportHeight * 100}%`;
}
}
Источники
- The New CSS Positioning System Explained – Подробное объяснение современных CSS систем позиционирования
- Modify and prevent a “bouncy” viewport effect – Анализ проблем с viewport при работе с модальными окнами
- Vue tricks: smart layouts for VueJS – Советы по созданию адаптивных макетов в Vue.js
- Official Vue.js Documentation - Component Basics – Официальная документация Vue.js по работе с компонентами
- CSS Viewport Units - A Complete Guide – Подробное руководство по использованию viewport‑относительных единиц в CSS
Заключение
Для создания модального окна в Vue.js, которое сохраняет позицию относительно кнопки при изменении масштаба страницы, следует избегать абсолютных пиксельных координат из getBoundingClientRect(). Вместо этого используйте viewport‑относительные единицы измерения (vw, vh, %) или CSS transforms для позиционирования.
Ключевые решения:
- Используйте
position: fixedс процентными значениями вместо абсолютных пикселей - Рассчитывайте позиции на основе доли от viewport, а не абсолютных координат
- Добавляйте обработчик события
resizeдля автоматической коррекции позиции - Рассмотрите использование CSS Popover API для современных браузеров
- Тестируйте решение в разных условиях масштабирования
Такой подход обеспечит стабильное поведение модального окна при любых изменениях масштаба страницы и предотвратит «убегание» от кнопки‑триггера.