Перейти до вмісту
tatetUI

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Текстова альтернатива фото чи назва відео. Обов'язкове: для скрінрідера це і є слайд
typeimage, 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`).
indexnumber0Поточний слайд. Через `v-model:index`, але працює й без прив'язки: гортання тоді живе всередині компонента.
maxZoomnumber4Найбільше збільшення відносно вписаного у вікно розміру. До натурального розміру великого фото можна дійти завжди.
toolbar("zoom" | "thumbnails" | "zoomIn" | "zoomOut" | "rotate" | "flip" | "slideshow" | "fullscreen" | "download")[]["zoom", "slideshow", "fullscreen", "thumbnails"]Кнопки верхньої панелі — у цьому порядку. Лічильник і хрестик є завжди; кнопки, яким нема чого робити, не показуються: повний екран на iPhone, слайдшоу й мініатюри для одного слайда.
transitionslidefadenone"slide"Перехід між слайдами. `prefers-reduced-motion` вимикає будь-який.
wheelzoomnonenavigate"zoom"Що робить колесо миші. Щипок на тачпаді (Ctrl + колесо) збільшує завжди — це жест масштабу, а не прокрутки.
intervalnumber3000Пауза між кадрами слайдшоу, мс.
idlenumber | falsefalseЧерез скільки мілісекунд без руху миші ховати панелі й стрілки. `false` — не ховати. На дотику панелі ховає й повертає дотик до фото.
origin(index: number) => ElementundefinedМініатюра на сторінці, з якої слайд «виростає» при відкритті й куди повертається при закритті. Функція від індексу: `(i) => thumbs[i]`.
labelsPartial<LightboxLabels>undefinedПерекриття підписів. Задавайте лише ключі, що змінюються; у проєкті з i18n — об'єкт із `t()`, і зміна мови оновить підписи на льоту.
ariaLabelstring"Перегляд зображень"Доступна назва діалогу.
modelValuebooleanfalseВідкрито. Використовуйте через `v-model`.
loopbooleanfalseЗ останнього слайда переходити на перший.
thumbnailsbooleantrueПоказувати стрічку мініатюр при відкритті. Сама ховається, коли слайд один.
loadingMorebooleanfalseСписок слайдів ще дотягується — наприклад, чат відкрив одне фото і підвантажує сусідні. Поки так, стрічка мініатюр і кнопки слайдшоу та мініатюр займають місце навіть для одного слайда: інакше, коли решта з'явиться, стрічка відніме висоту в сцени, і фото стрибне. Вимкніть прапорець, щойно список дотягнувся (або не дотягнувся): одиночний слайд знову ховає стрічку.
zoomablebooleantrueЗбільшення зображень: клік, подвійний дотик, щипок, колесо, `+`/`−`. Збільшене фото рухається перетягуванням — з інерцією, як на телефоні.
dragToClosebooleantrueЗмах угору чи вниз закриває галерею — як у галереї телефона.
closeOnBackdropbooleantrueКлік мишею в порожнє поле навколо слайда закриває галерею.
autoplaybooleanfalseЗапустити слайдшоу одразу при відкритті. Кнопка паузи тоді є завжди, а при `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 перемальовував би весь компонент разом зі стрічкою мініатюр. Реактивні лише похідні прапорці, що змінюються рідко: «збільшено», «на межі масштабу».