Lightbox
Повноекранна галерея — фото, відео, YouTube, iframe і власні слайди; щипок, збільшення з інерцією, поворот, слайдшоу, повний екран, політ із мініатюри.
Галерея стоїть на тому самому стеку шарів, що й Modal та Drawer: її можна відкрити з модалки, а Escape закриє лише верхній шар. Фон — майже чорний в обох темах: фото читається на темному, а кнопки й підписи несуть власну поверхню картки.
Орієнтир за можливостями — Fancybox: відео й сторінки поруч із фото, щипок і збільшення з інерцією, поворот і віддзеркалення, слайдшоу, повний екран, закриття змахом, політ фото з мініатюри. Усе без залежностей — компонент копіюється разом із трьома композаблами оверлеїв.
Відкрийте фото й закрийте його знову: воно виростає з мініатюри й повертається в неї, навіть якщо в галереї догорнули до іншого.
Відео, сторінки й власні слайди
Вид слайда визначається за адресою: посилання YouTube чи Vimeo стає
вбудованим плеєром, файл .mp4/.webm/.ogv/.mov — <video>, решта —
зображенням. Явний type потрібен лише для iframe (мапа, PDF, будь-яка
сторінка) і custom — власного вмісту через слот custom.
Плеєр і iframe монтуються лише для поточного слайда. Сусід із YouTube,
змонтований заздалегідь, почав би грати за кадром, а кожен зайвий iframe —
це ще й чужі cookies; сусіди показують обкладинку (poster, thumbnail, а
для YouTube — його власне прев'ю). YouTube вбудовується з
youtube-nocookie.com, Vimeo — з dnt=1: галерея не має права вирішувати
за людину про трекінг.
Відеофайл починає грати, щойно слайд стає поточним, і зупиняється, щойно
перестає: слайд обрали явно, тож жест користувача для звуку вже був. Жести
не починаються з нижньої смуги відео, де нативні контроли, — перемотування
не перетворюється на свайп. У слоті custom так само не заважають кнопки й
поля: з них жест не стартує, а прокрутка всередині вмісту лишається його
власною.
Інструменти й слайдшоу
toolbar — перелік кнопок верхньої панелі в потрібному порядку. Лічильник
і хрестик є завжди, а кнопка, якій нема чого робити, не з'являється: повний
екран на iPhone (там на весь екран розгортається лише <video>), слайдшоу й
мініатюри для одного слайда.
| Кнопка | Що робить |
|---|---|
zoom | Перемикає «вписано» ↔ збільшено: до натурального розміру великого фото, але щонайменше вдвічі |
zoomIn, zoomOut | Крок збільшення — у 1,5 раза |
rotate | Дві кнопки: поворот на чверть оберту проти і за годинниковою стрілкою; повернуте фото вписується заново |
flip | Дві кнопки: віддзеркалення по горизонталі й по вертикалі — в осях екрана, навіть після повороту |
slideshow | Слайдшоу з лінією прогресу. Чекає, поки фото завантажиться, і зупиняється на останньому кадрі без loop |
fullscreen | Повний екран через Fullscreen API; закриття галереї виходить із нього, лише якщо вмикала саме вона |
thumbnails | Сховати чи показати стрічку мініатюр |
download | Завантажити оригінал (download слайда або src) |
Слайдшоу чекає й на відео: обрізати ролик на третій секунді гірше, ніж
зачекати, тож файлове відео саме перегортає слайд, коли закінчиться, а
YouTube, Vimeo й сторінки тримають слайдшоу на паузі. autoplay запускає
слайдшоу при відкритті — і тоді кнопка паузи є завжди, навіть якщо її немає
в toolbar. При prefers-reduced-motion слайдшоу саме не стартує.
idle ховає панелі й стрілки, коли миша не рухається, — фото лишається
саме. Панелі не зникають з-під курсора, що стоїть на них, і з-під фокуса з
клавіатури, а Tab до схованої кнопки повертає їх. На дотику панелі ховає й
повертає одиночний дотик до фото.
Жести й клавіатура
| Дія | Миша | Дотик | Клавіатура |
|---|---|---|---|
| Наступний / попередній | стрілки на краях, колесо з wheel="navigate" | свайп | ← → PageUp PageDown Home End |
| Збільшити | клік по фото, колесо, щипок на тачпаді | подвійний дотик, щипок | + |
| Рухати збільшене | перетягування з інерцією | палець з інерцією | стрілки |
| Зменшити | клік по фото, колесо | подвійний дотик, щипок | −, 0, Escape |
| Сховати панелі | без руху миші (idle) | дотик до фото | — |
| Слайдшоу | кнопка | кнопка | Пробіл |
| Закрити | хрестик, клік у порожнє поле, змах угору чи вниз | змах угору чи вниз, хрестик | Escape |
Перший Escape на збільшеному фото лише зменшує: «вийти зі збільшення» і «закрити галерею» — різні наміри, і другий не має ховатися за першим.
Свайп використовує ту саму логіку, що UiCarousel:
поріг масштабується від ширини екрана, швидкий змах гортає навіть коротшим
рухом, а вісь жесту визначається до того, як щось рухати, — горизонталь
гортає, вертикаль закриває. Під час свайпу видно сусідній слайд: у DOM
завжди три слайди, поточний і два сусіди, і їхні фото вантажаться наперед.
Збільшене фото, дотягнуте до краю, віддає решту руху треку: наступний слайд
виїжджає, як у галереї телефона, без зменшення назад. Щипок тримає під
пальцями ту саму деталь, трохи пружинить за межами масштабу й повертається в
них після відпускання. Колесо збільшує в точці курсора, а щипок на тачпаді
(браузер надсилає його як Ctrl + колесо) — завжди, навіть із
wheel="navigate": це жест масштабу, а не прокрутки.
Політ із мініатюри
origin — функція, що за індексом повертає мініатюру на сторінці:
:origin="(i) => thumbs[i]". Фото виростає з неї при відкритті й повертається
в неї при закритті — з того місця, де воно зараз: збільшене чи недотягнуте
змахом. Мініатюра з object-cover обрізає фото — політ відтворює й
обрізання (clip-path у власних координатах зображення), і заокруглення кутів
обгортки.
Галерея просто згасає, коли летіти нікуди або чесно не вийде: при
prefers-reduced-motion, для повернутого чи віддзеркаленого фото і коли
мініатюра не видна — прокручена за край сторінки чи каруселі, а не лише за
межі вікна. Якщо фото ще вантажиться, політ чекає його не довше 600 мс:
анімація, що стартує через секунду після кліку, виглядає як збій.
Поля слайда
| Поле | Для чого |
|---|---|
src | Адреса фото, відеофайлу, відео YouTube/Vimeo чи сторінки. Для custom — унікальний ключ |
alt | Текстова альтернатива фото чи назва відео. Обов'язкове: для скрінрідера це і є слайд |
type | image, video, iframe, custom. Типово — за src |
caption | Підпис під слайдом. Складніший — через слот caption |
thumbnail | Мініатюра для стрічки. Поки вантажиться повне фото, вона ж стоїть на його місці заглушкою |
srcset, sizes | Відповідні розміри. Під час збільшення галерея підставляє в sizes фактичну ширину, і браузер довантажує більший файл |
width, height | Натуральний розмір: заглушка не виходить за нього, iframe бере з нього пропорції |
poster | Обкладинка відео — і в плеєрі, і в стрічці |
download | Адреса для кнопки «Завантажити», якщо вона інша, ніж src; false — не пропонувати |
Підписи й мова
Усі рядки галереї — у labels. Задавайте лише ключі, що змінюються: у
проєкті з i18n це об'єкт із t(), і зміна мови оновить підписи без
перемонтування.
<UiLightbox
v-model="open"
:images="images"
:labels="{
close: t('gallery.close'),
slide: (position, total, title) => t('gallery.slide', { position, total, title }),
}"
/>
Список, що дотягується
Месенджер відкриває галерею з одним фото, а сусідні підвантажує вже після відкриття. Два правила, щоб це не було видно:
- Підміна списку під відкритою галереєю. Якщо разом зі списком
imagesзмінився йindexі тепер вказує на ту саму адресу, що показана на екрані, галерея стає на новий номер на місці: слайд лишається тим самим вузлом, трек не їде, стрічка мініатюр не гортається. Якщоindexвказує на інше фото — це звичайний перехід. loading-more. Поки список дотягується, стрічка мініатюр і кнопки слайдшоу та мініатюр займають місце навіть для одного слайда (кнопка слайдшоу неактивна). Без цього, коли прийдуть решта фото, стрічка забере висоту в сцени, і фото стрибне. Вимкніть прапорець, щойно список дотягнувся або не дотягнувся.
<UiLightbox
v-model="open"
v-model:index="index"
:images="images"
:loading-more="loadingMore"
/>
Посилання на слайд
Хеш у стилі Fancybox (#gallery-3) галерея не пише сама: у застосунку з
роутером він сперечався б із роутером за історію. Ті самі дві речі —
посилання на конкретне фото й «Назад», що закриває галерею, — дає v-model
на query-параметр:
<script setup lang="ts">
const route = useRoute()
const router = useRouter()
const photo = computed(() => Number(route.query.photo ?? -1))
let pushed = false
const open = computed({
get: () => photo.value >= 0,
set: (value) => {
if (value) return
// Галерею відкрили ми — «Назад» просто прибирає свій запис. Прийшли
// за посиланням одразу на фото — back() вивів би з сайту.
if (pushed) router.back()
else router.replace({ query: { ...route.query, photo: undefined } })
pushed = false
},
})
const index = computed({
get: () => Math.max(photo.value, 0),
set: (value) => router.replace({ query: { ...route.query, photo: value } }),
})
function show(at: number) {
pushed = true
router.push({ query: { ...route.query, photo: at } })
}
</script>
<template>
<UiLightbox v-model="open" v-model:index="index" :images="images" />
</template>
Відкриття — push, гортання — replace, тож «Назад» закриває галерею
одним натиском, а не гортає назад кожен переглянутий кадр.
API
Props
| Назва | Тип | Типово | Опис |
|---|---|---|---|
images* | LightboxItem[] | — | Слайди галереї: зображення, відео (файл, YouTube, Vimeo), сторінки в iframe або власний вміст (`type: 'custom'` + слот `custom`). |
index | number | 0 | Поточний слайд. Через `v-model:index`, але працює й без прив'язки: гортання тоді живе всередині компонента. |
maxZoom | number | 4 | Найбільше збільшення відносно вписаного у вікно розміру. До натурального розміру великого фото можна дійти завжди. |
toolbar | ("zoom" | "thumbnails" | "zoomIn" | "zoomOut" | "rotate" | "flip" | "slideshow" | "fullscreen" | "download")[] | ["zoom", "slideshow", "fullscreen", "thumbnails"] | Кнопки верхньої панелі — у цьому порядку. Лічильник і хрестик є завжди; кнопки, яким нема чого робити, не показуються: повний екран на iPhone, слайдшоу й мініатюри для одного слайда. |
transition | slidefadenone | "slide" | Перехід між слайдами. `prefers-reduced-motion` вимикає будь-який. |
wheel | zoomnonenavigate | "zoom" | Що робить колесо миші. Щипок на тачпаді (Ctrl + колесо) збільшує завжди — це жест масштабу, а не прокрутки. |
interval | number | 3000 | Пауза між кадрами слайдшоу, мс. |
idle | number | false | false | Через скільки мілісекунд без руху миші ховати панелі й стрілки. `false` — не ховати. На дотику панелі ховає й повертає дотик до фото. |
origin | (index: number) => Element | undefined | Мініатюра на сторінці, з якої слайд «виростає» при відкритті й куди повертається при закритті. Функція від індексу: `(i) => thumbs[i]`. |
labels | Partial<LightboxLabels> | undefined | Перекриття підписів. Задавайте лише ключі, що змінюються; у проєкті з i18n — об'єкт із `t()`, і зміна мови оновить підписи на льоту. |
ariaLabel | string | "Перегляд зображень" | Доступна назва діалогу. |
modelValue | boolean | false | Відкрито. Використовуйте через `v-model`. |
loop | boolean | false | З останнього слайда переходити на перший. |
thumbnails | boolean | true | Показувати стрічку мініатюр при відкритті. Сама ховається, коли слайд один. |
loadingMore | boolean | false | Список слайдів ще дотягується — наприклад, чат відкрив одне фото і підвантажує сусідні. Поки так, стрічка мініатюр і кнопки слайдшоу та мініатюр займають місце навіть для одного слайда: інакше, коли решта з'явиться, стрічка відніме висоту в сцени, і фото стрибне. Вимкніть прапорець, щойно список дотягнувся (або не дотягнувся): одиночний слайд знову ховає стрічку. |
zoomable | boolean | true | Збільшення зображень: клік, подвійний дотик, щипок, колесо, `+`/`−`. Збільшене фото рухається перетягуванням — з інерцією, як на телефоні. |
dragToClose | boolean | true | Змах угору чи вниз закриває галерею — як у галереї телефона. |
closeOnBackdrop | boolean | true | Клік мишею в порожнє поле навколо слайда закриває галерею. |
autoplay | boolean | false | Запустити слайдшоу одразу при відкритті. Кнопка паузи тоді є завжди, а при `prefers-reduced-motion` слайдшоу саме не стартує. |
Події
| Назва | Payload | Опис |
|---|---|---|
update:modelValue | [value: boolean] | Зміна стану відкриття. Використовуйте через `v-model`. |
close | [] | Галерею закрито — хрестиком, Escape, змахом угору чи вниз або кліком у порожнє поле. |
update:index | [value: number] | Поточний слайд змінився — стрілкою, свайпом, мініатюрою чи слайдшоу. Використовуйте через `v-model:index`. |
Слоти
| Назва | Опис |
|---|---|
actions | Додаткові дії у верхній панелі: «Поділитися», «До товару». |
caption | Власний підпис замість `caption` слайда. |
custom | Вміст слайдів із `type: 'custom'`. `active` — чи слайд зараз поточний. |
Доступно через ref
| Назва | Тип | Опис |
|---|---|---|
previous | () => void | — |
next | () => void | — |
goTo | (target: number) => void | — |
zoomIn | () => void | — |
zoomOut | () => void | — |
resetZoom | () => void | — |
toggleZoom | () => void | — |
rotate | (direction?: 1 | -1) => void | — |
flip | (axis?: "x" | "y") => void | — |
toggleSlideshow | () => void | — |
toggleFullscreen | () => void | — |
Коли використовувати
- Фото товару, об'єкта, роботи в портфоліо — коли деталь важлива і мініатюри замало.
- Вкладення в чаті чи тікеті: фото, відео, PDF — в одній галереї.
- Огляд, де фото чергуються з відео чи мапою.
Коли НЕ використовувати
- Для послідовності слайдів у потоці сторінки — це
UiCarousel. - Для одного декоративного зображення без деталей: відкриття на весь екран нічого не додає.
- Для форми чи довгого тексту: слот
custom— для вмісту поряд із фото, а не замістьUiModalчиUiDrawer.
Доступність
Діалог з aria-modal, назвою з ariaLabel і пасткою фокуса. Фокус при
відкритті стає на саму панель, а не на хрестик: стрілки гортають одразу, а
перший Tab доходить до першої кнопки. Після закриття фокус повертається
туди, звідки галерею відкрили, — тому мініатюри на сторінці мають бути
кнопками. З origin — на мініатюру слайда, який переглядали, туди ж, куди
летить фото: кнопка, з якої відкрили, часто вже недосяжна (у каруселі, що
йде за індексом галереї, її слайд став inert), і фокус падав би в
<body>.
Поточний слайд оголошує live-регіон: «Зображення 2 з 5: Хол», для відео —
«Відео 2 з 5: …». Під час слайдшоу оголошення вимикаються: кадри
змінюються самі, і скрінрідер інакше говорив би без упину. alt
обов'язковий у кожного слайда. Мініатюри мають aria-current на поточній, а
сусідні слайди — inert і aria-hidden: ні Tab, ні скрінрідер у них не
потрапляють. Фокус зі слайда, що виїжджає, переходить на панель, а не
падає в <body>.
Кнопки-перемикачі (слайдшоу, повний екран, мініатюри, збільшення) міняють
назву — «Запустити слайдшоу» / «Зупинити слайдшоу», — а не тримають одну
назву з aria-pressed: разом ці два способи суперечили б один одному.
Панель інструментів на вузькому екрані й довга стрічка мініатюр гортаються вбік. Tab докручує смугу до кнопки цілою, разом із кільцем фокуса: браузер докручує лише кнопку, сховану повністю, а частково видиму лишав обрізаною. Клік і тап смугу не зсувають.
Усі рухи — перехід між слайдами, політ із мініатюри, інерція, анімація
збільшення — вимикаються при prefers-reduced-motion.
Чого немає і чому
- Хеш в адресі — див. «Посилання на слайд»: роутер робить це краще.
- Захист від збереження (
protectedу Fancybox) — заборона правого кліку нікого не зупиняє, а скрінрідерам і людям із розширеннями заважає. - Ajax-вміст — у Vue для цього є слот
customіз власним завантаженням даних.
Деталі реалізації
Уся геометрія — чисті функції в app/utils/lightbox.ts, покриті тестами
без рендеру: межі масштабу (zoomLimits, fitScale для повернутого фото),
збільшення навколо точки (zoomAround), межі зсуву (panBounds), гумові
краї, інерція (releaseVelocity, decay), поріг закриття змахом, розбір
посилань YouTube і Vimeo, перетворення для польоту з мініатюри
(originTransform, visibleFraction).
Слайд вважається готовим після decode(), а не після події load: зображення
має decoding="async", і між завантаженням файлу та першою відмальовкою
лишалися порожні кадри — заглушка-мініатюра вже зникла, а фото ще не видно.
Під пальцем збільшення й трек рухаються імперативно — записом transform
у стиль, а не реактивним :style: інакше кожен pointermove перемальовував
би весь компонент разом зі стрічкою мініатюр. Реактивні лише похідні
прапорці, що змінюються рідко: «збільшено», «на межі масштабу».