Recipes

Image lightbox

A full-screen lightbox that navigates a list of images with arrow keys, closes on backdrop or Esc, and transitions instantly between images using replaceModal.

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 isTopmostGlobal so keyboard shortcuts don't fire when another modal (in any group) is stacked on top.
  • Uses replaceModal + instantEnter: true to swap images without the open/close transition.

Group registration

main.ts
import type { DefineGroups } from '@kolirt/vue-modal'

declare module '@kolirt/vue-modal' {
  interface ModalGroupRegistry extends DefineGroups<['lightbox']> {}
}
App.vue
<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)"
        >
          &#8592;
        </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)"
        >
          &#8594;
        </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>

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.

Copyright © 2026