Skip to main content

Build a lightbox from primitives

Compose a custom lightbox structure while keeping Nuxt Photo state, gestures, and transitions.

Use primitives when the built-in <Lightbox> structure cannot match your design. You own the markup and final accessibility review. Nuxt Photo keeps the photo state, navigation, gestures, zoom, focus trap, and opening transition.

If you only need different captions or action buttons, use the built-in lightbox slots instead.

Compose the provider and triggers

LightboxProvider owns one photo collection. Each PhotoTrigger connects a thumbnail to its matching photo.

app/components/CustomPhotoViewer.vue
<script setup lang="ts">
import {
  LightboxCaption,
  LightboxControls,
  LightboxOverlay,
  LightboxProvider,
  LightboxRoot,
  LightboxSlide,
  LightboxViewport,
  PhotoImage,
  PhotoTrigger,
  type PhotoItem,
} from '@lupinum/nuxt-photo/app'

defineProps<{ photos: readonly PhotoItem[] }>()
</script>

<template>
  <LightboxProvider :photos="photos">
    <div class="grid grid-cols-2 gap-2 md:grid-cols-3">
      <PhotoTrigger
        v-for="(photo, index) in photos"
        :key="photo.id"
        v-slot="{ hidden }"
        :photo="photo"
        :index="index"
      >
        <PhotoImage :photo="photo" context="thumb" :style="{ opacity: hidden ? 0 : 1 }" />
      </PhotoTrigger>
    </div>

    <LightboxRoot
      class="fixed inset-0 z-50 bg-black/90"
      role="dialog"
      aria-modal="true"
      aria-label="Photo viewer"
    >
      <LightboxOverlay class="absolute inset-0" />

      <LightboxViewport v-slot="{ photos: slides, viewportRef }" class="relative h-full">
        <div :ref="viewportRef" class="h-full overflow-hidden">
          <div class="flex h-full">
            <LightboxSlide
              v-for="(photo, index) in slides"
              :key="photo.id"
              :photo="photo"
              :index="index"
              class="min-w-0 flex-[0_0_100%]"
            />
          </div>
        </div>
      </LightboxViewport>

      <LightboxControls
        v-slot="{ activeIndex, count, close, next, prev }"
        class="absolute inset-x-0 top-0 flex items-center justify-between p-4 text-white"
      >
        <button type="button" aria-label="Close" @click="close">Close</button>
        <span>{{ activeIndex + 1 }} / {{ count }}</span>
        <div class="flex gap-2">
          <button type="button" aria-label="Previous" @click="prev">Previous</button>
          <button type="button" aria-label="Next" @click="next">Next</button>
        </div>
      </LightboxControls>

      <LightboxCaption v-slot="{ photo }" class="absolute inset-x-0 bottom-0 p-6 text-white">
        <h2 v-if="photo?.caption">{{ photo.caption }}</h2>
        <p v-if="photo?.description">{{ photo.description }}</p>
      </LightboxCaption>
    </LightboxRoot>
  </LightboxProvider>
</template>

Verify the custom structure

Check the behavior that custom markup can affect:

  1. Open every thumbnail with click, Enter, and Space.
  2. Move through photos with the arrow, Home, and End keys.
  3. Close with Escape and confirm focus returns to the trigger.
  4. Confirm the dialog has an accessible name.
  5. Test touch swipe, zoom, and reduced motion.
  6. Test the layout in left-to-right and right-to-left documents.

Use the primitives reference for exact props and slot contracts.