Лайтбокс с зумом изображения на 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: ссылка должна вести на рабочий файл. Убедитесь, что у миниатюры и полноразмерной версии понятные альтернативные тексты, а полноэкранное окно не появляется поверх открытых полей формы.

Соберите страницу с несколькими снимками, подключите файлы через дочернюю тему и опубликуйте рабочую галерею: готовый лайтбокс покажет качество изображений прямо в контексте сайта.

Иконка пользователя в серых тонах

Настройка Windows

Доступ к скрытым параметрам конфигурации, отключение телеметрии, работа с диспетчером задач и тонкая настройка меню «Пуск» в стиле Windows XP для Windows 7.

Иконка браузера в серо-синих тонах

Веб-браузеры

Скрытые страницы конфигурации в Firefox, Chrome и Opera, быстрый текстовый редактор прямо в браузере, эффект Lightbox JS для изображений и настройка внешнего вида Firefox.

Иконка папки в нейтральных серых тонах

WordPress и блоги

Выполнение PHP-кода в текстовых виджетах, диагностика времени загрузки разделов страницы, правильная очистка блога и условная загрузка виджетов социальных сетей.

Изометрический 3D-рендер: светлый веб-сайт с серо-голубым заголовком, парящий над чистой белой поверхностью Изометрический 3D-рендер: тёмно-серая иконка социального букмаркинга на светлом фоне

Обратная связь

Есть вопрос или предложение по темам блога? Напишите — автор на связи.