Image lightbox
Image lightbox
A lightbox that:
- Closes on backdrop click and Esc (default behaviour — no extra options needed).
- Navigates between images with ← / → arrow keys, gated by
isTopmostGlobalso keyboard shortcuts don't fire when another modal (in any group) is stacked on top. - Uses
replaceModal+instantEnter: trueto swap images without the open/close transition.
Group registration
import type { DefineGroups } from '@kolirt/vue-modal'
declare module '@kolirt/vue-modal' {
interface ModalGroupRegistry extends DefineGroups<['lightbox']> {}
}
<script setup lang="ts">
import { ModalOverlay, ModalTarget } from '@kolirt/vue-modal'
</script>
<template>
<RouterView />
<!-- Close on overlay click (default), close on Esc (default) -->
<ModalTarget group="lightbox">
<ModalOverlay class="bg-black/80" />
</ModalTarget>
</template>
The lightbox component
<script setup lang="ts">
import { onMounted, onBeforeUnmount } from 'vue'
import {
ModalRoot,
ModalContent,
replaceModal,
useModalContext
} from '@kolirt/vue-modal'
defineOptions({ modalGroup: 'lightbox' })
const props = defineProps<{
images: string[]
index: number
}>()
const { close, isTopmostGlobal } = useModalContext()
const hasPrev = props.index > 0
const hasNext = props.index < props.images.length - 1
function go(newIndex: number) {
replaceModal(ImageLightbox, {
props: { images: props.images, index: newIndex },
instantEnter: true // skip enter animation — instant swap
})
}
function onKeydown(e: KeyboardEvent) {
// Only react when this is the topmost modal across all groups
if (!isTopmostGlobal.value) return
if (e.key === 'ArrowLeft' && hasPrev) go(props.index - 1)
if (e.key === 'ArrowRight' && hasNext) go(props.index + 1)
}
onMounted(() => window.addEventListener('keydown', onKeydown))
onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
</script>
<template>
<ModalRoot class="flex items-center justify-center">
<ModalContent class="relative w-full">
<div class="relative z-10 flex items-center gap-4 px-4 max-w-5xl w-full">
<!-- Previous -->
<button
v-if="hasPrev"
class="shrink-0 rounded-full bg-white/10 hover:bg-white/25 p-3 text-white transition"
aria-label="Previous image"
@click="go(props.index - 1)"
>
←
</button>
<!-- Image -->
<div class="flex-1 flex items-center justify-center">
<img
:src="props.images[props.index]"
:alt="`Image ${props.index + 1} of ${props.images.length}`"
class="max-h-[80vh] max-w-full rounded-xl object-contain shadow-2xl"
/>
</div>
<!-- Next -->
<button
v-if="hasNext"
class="shrink-0 rounded-full bg-white/10 hover:bg-white/25 p-3 text-white transition"
aria-label="Next image"
@click="go(props.index + 1)"
>
→
</button>
<!-- Close -->
<button
class="absolute -top-10 right-0 text-white/70 hover:text-white text-2xl"
aria-label="Close"
@click="close()"
>
✕
</button>
</div>
<!-- Counter -->
<div class="absolute bottom-6 left-1/2 -translate-x-1/2 text-white/60 text-sm">
{{ props.index + 1 }} / {{ props.images.length }}
</div>
</ModalContent>
</ModalRoot>
</template>
<script setup lang="ts">
import { openModal } from '@kolirt/vue-modal'
import ImageLightbox from './ImageLightbox.vue'
const images = [
'https://example.com/photo-1.jpg',
'https://example.com/photo-2.jpg',
'https://example.com/photo-3.jpg'
]
function openLightbox(index: number) {
openModal(ImageLightbox, {
props: { images, index }
}).catch(() => {}) // closed by user — ignore
}
</script>
<template>
<div class="grid grid-cols-3 gap-2">
<img
v-for="(src, i) in images"
:key="src"
:src="src"
class="cursor-pointer rounded-lg object-cover aspect-square hover:opacity-90 transition"
@click="openLightbox(i)"
/>
</div>
</template>
Why isTopmostGlobal for keyboard navigation
isTopmostGlobal is true only when this specific modal instance is the highest open modal across every group. If something opens on top of the lightbox (a confirm dialog, a share sheet — even from a different group), isTopmostGlobal.value becomes false and arrow key navigation stops. It resumes automatically once the upper modal closes.
if (!isTopmostGlobal.value) return // another modal is above us — ignore keys
Use isTopmost instead if you only care about the topmost within this modal's own group.
Why replaceModal + instantEnter
replaceModal closes the current lightbox step (no exit animation, ignoring guards) and immediately opens a new one. instantEnter: true suppresses the enter animation on the incoming slide. The result is a seamless, instant image swap:
replaceModal(ImageLightbox, {
props: { images: props.images, index: newIndex },
instantEnter: true
})
Without instantEnter, each navigation would play the full enter animation, which looks jarring in a lightbox context.
Backdrop click behaviour
The default <ModalTarget> configuration closes on overlay interaction (closeOnInteractOverlay: true). No extra props are needed. The package detects pointer-down events outside <ModalContent> (delivered by Reka UI's pointer-down-outside hook) and calls close(). <ModalOverlay> itself is purely visual — its pointer-events: none default means it never receives clicks.
