Лайтбокс с зумом изображения на JavaScript
Лайтбокс с зумом изображения на JavaScript позволяет открыть фотографию поверх страницы, увеличить её колёсиком мыши или кнопками и закрыть без перезагрузки. Для портфолио, интернет-магазина и фотоблога такой формат экономит место: посетитель видит миниатюру, а крупный файл загружается только после открытия окна.
Ниже приведена реализация без внешней библиотеки. Она работает с обычными HTML-тегами, управляется с клавиатуры и подходит для галерей, которые выводятся в WordPress. Код можно разделить на разметку, стили и один JavaScript-файл.
Принцип работы лайтбокса
Изображение открывается в полноэкранном контейнере, расположенном поверх остальных элементов. Исходная страница остаётся на месте, но прокрутка документа блокируется. Закрытие выполняется кнопкой, нажатием клавиши Escape или щелчком по свободной области.
Для увеличения используется CSS-трансформация scale. Она меняет визуальный размер картинки, не затрагивая ширину и высоту элемента в потоке документа. Поэтому страница не прыгает, а полноразмерное изображение можно центрировать и перемещать.
Архитектура решения включает кнопку-ссылку, полноэкранное окно, область просмотра, крупное изображение и элементы управления. JavaScript хранит текущий масштаб и смещение в объекте состояния, а функция обновления применяет их через свойство transform.
Разметка и базовые стили
Для миниатюры лучше использовать ссылку: при отключённом JavaScript посетитель сможет открыть полноразмерный файл обычным способом. Атрибут data-zoom-src хранит адрес изображения для лайтбокса.
<div class="zoom-gallery">
<a class="zoom-trigger"
href="/wp-content/uploads/photo-large.jpg"
data-zoom-src="/wp-content/uploads/photo-large.jpg">
<img src="/wp-content/uploads/photo-small.jpg"
alt="Вид на горное озеро"
loading="lazy">
<span>Открыть крупно</span>
</a>
</div>
<div class="zoom-lightbox" data-zoom-overlay hidden
aria-hidden="true" role="dialog" aria-modal="true"
aria-label="Просмотр изображения">
<button type="button" data-zoom-close>Закрыть</button>
<div class="zoom-lightbox__stage" data-zoom-stage>
<img data-zoom-preview alt="">
</div>
<div class="zoom-lightbox__controls">
<button type="button" data-zoom-out>Уменьшить</button>
<button type="button" data-zoom-in>Увеличить</button>
</div>
</div>
Стили задают полноэкранный фон, ограничивают размер фотографии и сохраняют прокрутку страницы под окном. Кнопки управления располагаются поверх изображения.
.zoom-trigger {
display: inline-block;
position: relative;
color: inherit;
}
.zoom-trigger img {
display: block;
max-width: 100%;
height: auto;
}
.zoom-lightbox {
position: fixed;
inset: 0;
z-index: 10000;
display: grid;
place-items: center;
background: rgba(12, 14, 18, 0.94);
}
.zoom-lightbox[hidden] {
display: none;
}
.zoom-lightbox__stage {
width: 100%;
height: 100%;
overflow: hidden;
cursor: grab;
touch-action: none;
}
.zoom-lightbox__image {
max-width: 92vw;
max-height: 86vh;
object-fit: contain;
transform-origin: center;
user-select: none;
will-change: transform;
}
.zoom-lightbox__close,
.zoom-lightbox__controls {
position: absolute;
z-index: 2;
}
.zoom-lightbox__close {
top: 16px;
right: 16px;
}
.zoom-lightbox__controls {
bottom: 20px;
left: 50%;
display: flex;
gap: 8px;
transform: translateX(-50%);
}
body.zoom-open {
overflow: hidden;
}
Для галереи из нескольких изображений повторяется только блок со ссылкой. Окно и его элементы управления размещаются в шаблоне один раз.
JavaScript для открытия и закрытия
Обработчик использует делегирование событий. Он находит ссылку с атрибутом data-zoom-src даже тогда, когда фотографии загрузились через Ajax или появились после загрузки страницы.
(() => {
const overlay = document.querySelector('[data-zoom-overlay]');
const stage = overlay.querySelector('[data-zoom-stage]');
const preview = overlay.querySelector('[data-zoom-preview]');
const closeButton = overlay.querySelector('[data-zoom-close]');
const focusable = [...overlay.querySelectorAll('button:not([disabled])')];
let activeTrigger = null;
function openZoom(trigger) {
activeTrigger = trigger;
preview.src = trigger.dataset.zoomSrc;
preview.alt = trigger.querySelector('img')?.alt ||
'Увеличенное изображение';
preview.style.transform = 'translate3d(0, 0, 0) scale(1)';
overlay.hidden = false;
overlay.setAttribute('aria-hidden', 'false');
document.body.classList.add('zoom-open');
closeButton.focus();
}
function closeZoom() {
overlay.hidden = true;
overlay.setAttribute('aria-hidden', 'true');
document.body.classList.remove('zoom-open');
preview.removeAttribute('src');
activeTrigger?.focus();
}
document.addEventListener('click', event => {
const trigger = event.target.closest('[data-zoom-src]');
if (trigger) {
if (event.ctrlKey || event.metaKey || event.shiftKey) return;
event.preventDefault();
openZoom(trigger);
return;
}
if (event.target.closest('[data-zoom-close]')) {
closeZoom();
}
});
document.addEventListener('keydown', event => {
if (overlay.hidden) return;
if (event.key === 'Escape') {
closeZoom();
}
if (event.key === 'Tab') {
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
});
})();
При открытии фокус переходит на кнопку закрытия, а после закрытия возвращается к исходной миниатюре. Цикл клавиши Tab удерживает пользователя внутри окна и не позволяет перейти к элементам скрытой страницы.
Масштабирование и перемещение
Для точного увеличения вычисляется коэффициент между прежним и новым масштабом. Положение изображения корректируется относительно указателя, поэтому выбранная точка фотографии остаётся под курсором.
Для расширения набора жестов пригодятся практические примеры JavaScript, особенно обработка нескольких указателей и ограничение координат. В базовой версии достаточно колеса мыши, двух кнопок и перетаскивания одним пальцем.
Следующий фрагмент нужно вставить внутрь той же функции IIFE перед строкой })();.
const zoomState = { scale: 1, x: 0, y: 0 };
let drag = null;
let dragged = false;
function applyZoom() {
preview.style.transform =
`translate3d(${zoomState.x}px, ${zoomState.y}px, 0) ` +
`scale(${zoomState.scale})`;
}
function limitPosition() {
const maxX = Math.max(
0,
(preview.offsetWidth * zoomState.scale - stage.clientWidth) / 2
);
const maxY = Math.max(
0,
(preview.offsetHeight * zoomState.scale - stage.clientHeight) / 2
);
zoomState.x = Math.min(maxX, Math.max(-maxX, zoomState.x));
zoomState.y = Math.min(maxY, Math.max(-maxY, zoomState.y));
}
function changeScale(nextScale, originX = 0, originY = 0) {
const bounded = Math.min(5, Math.max(1, nextScale));
const ratio = bounded / zoomState.scale;
zoomState.x = originX - ratio * (originX - zoomState.x);
zoomState.y = originY - ratio * (originY - zoomState.y);
zoomState.scale = bounded;
if (bounded === 1) {
zoomState.x = 0;
zoomState.y = 0;
}
limitPosition();
applyZoom();
}
stage.addEventListener('wheel', event => {
event.preventDefault();
const rect = stage.getBoundingClientRect();
const originX = event.clientX - rect.left - rect.width / 2;
const originY = event.clientY - rect.top - rect.height / 2;
const factor = event.deltaY < 0 ? 1.15 : 1 / 1.15;
changeScale(zoomState.scale * factor, originX, originY);
}, { passive: false });
stage.addEventListener('pointerdown', event => {
dragged = false;
if (zoomState.scale <= 1) return;
drag = {
id: event.pointerId,
pointerX: event.clientX,
pointerY: event.clientY,
stateX: zoomState.x,
stateY: zoomState.y
};
stage.setPointerCapture(event.pointerId);
});
stage.addEventListener('pointermove', event => {
if (!drag || event.pointerId !== drag.id) return;
const deltaX = event.clientX - drag.pointerX;
const deltaY = event.clientY - drag.pointerY;
if (Math.abs(deltaX) + Math.abs(deltaY) > 4) dragged = true;
zoomState.x = drag.stateX + deltaX;
zoomState.y = drag.stateY + deltaY;
limitPosition();
applyZoom();
event.preventDefault();
});
['pointerup', 'pointercancel'].forEach(type => {
stage.addEventListener(type, event => {
if (drag?.id === event.pointerId) drag = null;
});
});
document.addEventListener('click', event => {
const control = event.target.closest(
'[data-zoom-in], [data-zoom-out]'
);
if (!control) return;
const factor = control.hasAttribute('data-zoom-in') ? 1.2 : 1 / 1.2;
changeScale(zoomState.scale * factor);
});
stage.addEventListener('click', event => {
if (event.target === stage && !dragged) closeZoom();
});
Ограничение масштаба диапазоном от 1 до 5 не позволяет потерять изображение. Границы смещения рассчитываются по размеру картинки, поэтому перетаскивание работает предсказуемо на разных экранах.
WordPress и производительность сайта
В WordPress код лучше подключать через дочернюю тему. Тогда обновление основной темы не удалит настройки, а браузер сможет кешировать отдельные файлы стилей и сценариев.
add_action( 'wp_enqueue_scripts', function () {
wp_enqueue_style(
'photo-zoom',
get_stylesheet_directory_uri() . '/assets/css/photo-zoom.css',
[],
'1.0.0'
);
wp_enqueue_script(
'photo-zoom',
get_stylesheet_directory_uri() . '/assets/js/photo-zoom.js',
[],
'1.0.0',
true
);
} );
Адрес полноразмерного файла можно получить через wp_get_attachment_image_url($attachment_id, 'full'). Миниатюру следует выбирать с меньшим размером и добавлять атрибут loading="lazy".
Для тяжёлых страниц полезно изучить сравнение плагинов кеширования: лайтбокс не должен задерживать показ страницы сильнее обычной загрузки большого изображения.
Собственный код подходит для простых галерей, но требует проверки доступности и жестов. Готовые библиотеки экономят время разработки, если нужны масштабирование несколькими пальцами, подписи, показ миниатюр и другие сложные сценарии.
| Решение | Преимущества | Ограничения | Подходящий случай |
|---|---|---|---|
| Собственный JavaScript | Полный контроль, нет внешних зависимостей | Нужно самостоятельно реализовать жесты и доступность | Простые галереи и компактные сайты |
| CSS-трансформация | Плавное масштабирование без изменения макета | Сложнее точно управлять перемещением | Изображения с простым центрированием |
| Готовая библиотека | Готовые жесты, галереи и адаптивность | Дополнительный вес и чужие настройки | Сложные фотогалереи и витрины |
| Нативный диалоговый элемент | Встроенное управление фокусом и закрытие клавишей | Оформление зависит от возможностей браузера | Современные проекты без тяжёлых библиотек |
Проверка перед публикацией
Проверьте открытие и закрытие фотографии, возврат фокуса, клавишу Escape, прокрутку увеличенного изображения и кнопки на сенсорном экране. Полезно протестировать портретную и альбомную ориентацию, экран шириной 320 пикселей и изображения с большим разрешением.
Отдельно проверьте случай отключённого JavaScript: ссылка должна вести на рабочий файл. Убедитесь, что у миниатюры и полноразмерной версии понятные альтернативные тексты, а полноэкранное окно не появляется поверх открытых полей формы.
Соберите страницу с несколькими снимками, подключите файлы через дочернюю тему и опубликуйте рабочую галерею: готовый лайтбокс покажет качество изображений прямо в контексте сайта.