Реалізація галереї зображень (Lightbox) на вебсайті
Галерея з lightbox — один з найчастіших UI-компонентів. Задача здається простою, поки не зіткнешся з touch-свайпами, lazy-завантаженням, zoom, передзавантаженням сусідніх зображень та доступністю.
Бібліотеки
PhotoSwipe 5 — найкращий варіант для більшості задач. Чистий vanilla JS, touch-first, zoom, підтримка srcset. Активно підтримується, ~20 KB gzip.
GLightbox — легкавесна (~12 KB), підтримує відео та iframe в лайтбоксі, без залежностей.
Fancybox 5 — багатий функціонал, але платна для комерційних проектів.
Для простих випадків без zoom і відео — GLightbox. Для повнофункціональної фотогалереї — PhotoSwipe.
PhotoSwipe: інтеграція
npm install photoswipe
import PhotoSwipeLightbox from 'photoswipe/lightbox'
import 'photoswipe/style.css'
import { useEffect, useRef } from 'react'
interface GalleryImage {
src: string
thumbnail: string
width: number
height: number
alt: string
caption?: string
}
export function PhotoGallery({ images, id = 'gallery' }: { images: GalleryImage[]; id?: string }) {
const galleryRef = useRef<HTMLElement>(null)
useEffect(() => {
if (!galleryRef.current) return
const lightbox = new PhotoSwipeLightbox({
gallery: `#${id}`,
children: 'a',
pswpModule: () => import('photoswipe'),
// Передзавантаження сусідніх
preload: [1, 2],
// Анімація
showHideAnimationType: 'zoom',
// Закриття за клікам на фон
closeOnVerticalDrag: true,
// Зум
maxZoomLevel: 4,
initialZoomLevel: 'fit',
secondaryZoomLevel: 1.5,
})
// Кастомний заголовок
lightbox.on('uiRegister', () => {
lightbox.pswp?.ui?.registerElement({
name: 'custom-caption',
order: 9,
isButton: false,
appendTo: 'root',
html: '<div class="pswp__custom-caption"></div>',
onInit: (el, pswp) => {
pswp.on('change', () => {
const currSlideElement = pswp.currSlide?.data.element
const caption = currSlideElement?.querySelector('figcaption')?.textContent ?? ''
el.querySelector('.pswp__custom-caption')!.textContent = caption
})
},
})
})
lightbox.init()
return () => lightbox.destroy()
}, [id, images])
return (
<section
id={id}
ref={galleryRef as any}
className="photo-gallery"
aria-label="Галерея фотографій"
>
{images.map((img, i) => (
<figure key={i} className="photo-gallery__item">
<a
href={img.src}
data-pswp-width={img.width}
data-pswp-height={img.height}
>
<img
src={img.thumbnail}
alt={img.alt}
loading="lazy"
decoding="async"
width={img.width}
height={img.height}
/>
</a>
{img.caption && <figcaption>{img.caption}</figcaption>}
</figure>
))}
</section>
)
}
Адаптивні зображення через srcset
// Генеруємо srcset для лайтбокса — різні розміри для різних екранів
function buildSrcSet(baseUrl: string, sizes: number[]): string {
return sizes.map(w => `${baseUrl}?w=${w} ${w}w`).join(', ')
}
// У PhotoSwipe 5 — через dataSource
const dataSource = images.map(img => ({
src: img.src,
srcset: buildSrcSet(img.src, [800, 1200, 1920, 2560]),
width: img.width,
height: img.height,
alt: img.alt,
// Для мобільних — не грузити 2560px
msrc: img.thumbnail, // placeholder до завантаження повного
}))
Masonry-лейаут сітки
/* CSS Columns — найпростіше masonry без JS */
.photo-gallery {
columns: 3 200px;
column-gap: 8px;
}
.photo-gallery__item {
break-inside: avoid;
margin-bottom: 8px;
}
.photo-gallery__item img {
width: 100%;
height: auto;
display: block;
}
@media (max-width: 768px) {
.photo-gallery { columns: 2 150px; }
}
@media (max-width: 480px) {
.photo-gallery { columns: 1; }
}
Для підтримки CSS Masonry (поки тільки Firefox за флагом) або точного вирівнювання рядків — Masonry.js або нативний CSS grid з grid-template-rows: masonry.
Рівномірна сітка з відомими пропорціями
/* Якщо співвідношення сторін відомо заздалегідь */
.gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 8px;
}
.gallery-grid__item {
aspect-ratio: 4/3;
overflow: hidden;
}
.gallery-grid__item img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
.gallery-grid__item:hover img {
transform: scale(1.05);
}
Lazy-завантаження з плейсхолдером
import { useState } from 'react'
function LazyGalleryImage({ src, thumbnail, alt, width, height }: GalleryImage) {
const [loaded, setLoaded] = useState(false)
const [error, setError] = useState(false)
return (
<div className={`gallery-image ${loaded ? 'gallery-image--loaded' : ''}`}>
{/* LQIP (Low Quality Image Placeholder) */}
{!loaded && !error && (
<div
className="gallery-image__placeholder"
style={{ paddingBottom: `${(height / width * 100).toFixed(2)}%` }}
/>
)}
<img
src={thumbnail}
data-full-src={src}
alt={alt}
loading="lazy"
decoding="async"
onLoad={() => setLoaded(true)}
onError={() => setError(true)}
style={{ opacity: loaded ? 1 : 0, transition: 'opacity 0.3s' }}
/>
</div>
)
}
GLightbox для відео-галереї
npm install glightbox
import GLightbox from 'glightbox'
import 'glightbox/dist/css/glightbox.css'
const lightbox = GLightbox({
elements: [
{
href: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
type: 'video',
source: 'youtube',
width: 900,
},
{
href: '/video/promo.mp4',
type: 'video',
description: 'Промо-ролик',
},
{
href: '/images/photo1.jpg',
type: 'image',
description: 'Опис фото',
},
],
autoplayVideos: false,
loop: false,
draggable: true,
dragToleranceX: 40,
dragToleranceY: 65,
swipeToClose: true,
})
Навігація клавіатурою та доступність
// PhotoSwipe підтримує клавіатуру нативно
// Додатково: повернення фокусу при закриття
lightbox.on('close', () => {
// Повертаємо фокус на елемент, з якого ми його відкривали
const opener = document.querySelector('[data-gallery-opener]') as HTMLElement
opener?.focus()
})
// ARIA для сітки
// role="list" + role="listitem" або просто нативний <ul><li>
<ul class="photo-gallery" role="list" aria-label="Галерея проектів">
<li role="listitem">
<a href="/large/1.jpg"
aria-label="Відкрити зображення: Проект офісу, 2024"
data-pswp-width="2400"
data-pswp-height="1600">
<img src="/thumb/1.jpg" alt="Проект офісу, 2024" loading="lazy">
</a>
</li>
</ul>
Терміни
GLightbox з базовою сіткою та CSS columns — половина дня. PhotoSwipe з masonry, srcset, caption та кастомним UI — 1,5–2 дні. Повнофункціональна галерея з завантаженням з API, пагінацією, фільтрами по категоріях та підтримкою відео — 4–5 днів.







