Polaroid Gallery + Lightbox

A photo gallery built from two ideas glued together: a polaroid stack with alternating tilts and overlapping offsets, and a fullscreen blurred-backdrop lightbox with keyboard nav, prev/next, and scroll lock. Vue 3 + Tailwind, no external deps.

Vue
gallerylightboxpolaroidmodalteleportvuenuxttailwindphotos

Preview

Live demo of the component.

Usage

How to drop this into your project.

01Minimal
Drop it in with no props. Ships with 3 hand-placed sample photos.
example.vue
<template>  <PolaroidGallery /></template>
02Custom photos
Pass your own array. Each photo needs src + alt. caption is optional.
example.vue
<script setup>const photos = [  { src: '/tokyo.jpg',  alt: 'Tokyo',  caption: 'TYO · 35.68°N' },  { src: '/lisbon.jpg', alt: 'Lisbon', caption: 'LIS · 38.72°N' },  { src: '/oslo.jpg',   alt: 'Oslo',   caption: 'OSL · 59.91°N' },]</script>
<template>  <PolaroidGallery :photos="photos" /></template>
03Auto-scatter vs manual
Omit rotate/offsetX/offsetY and the photo is auto-scattered from its src hash (stable across reorders). Pass them explicitly to hand-place.
example.vue
const photos = [  // Hand-placed — exact tilt and offset  { src: '/a.jpg', alt: 'A', rotate: -2, offsetX: -140, offsetY:  8 },  { src: '/b.jpg', alt: 'B', rotate:  2, offsetX:    0, offsetY: -8 },
  // Auto-scattered — falls back to deterministic random  { src: '/c.jpg', alt: 'C' },  { src: '/d.jpg', alt: 'D' },]
04Overflow with +N more
Pass a long list and set maxVisible to cap the stack. The last tile becomes a "+N more" chip that opens the lightbox into the overflow.
example.vue
<PolaroidGallery :photos="twentyPhotos" :max-visible="5" />

Props

Everything you can pass in.

NameTypeDefaultDescription
photosPhoto[]3 sample photosArray of photos to render. Each Photo: { src, alt, caption?, rotate?, offsetX?, offsetY? }.
maxVisiblenumber5Cap on how many polaroids sit in the stack. Extras collapse into a "+N more" chip.

Full source

Copy-paste the whole component.

Polaroid Gallery + Lightbox.vue
<script setup lang="ts">import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
interface Photo {  src: string  alt: string  caption?: string  /** Manual rotation in degrees. Falls back to auto scatter. */  rotate?: number  /** Manual X offset in px from the center of the container. */  offsetX?: number  /** Manual Y offset in px from the center of the container. */  offsetY?: number}
const props = withDefaults(  defineProps<{    photos?: Photo[]    maxVisible?: number  }>(),  {    photos: () => [      { src: '/img1.jpeg', alt: 'Paris',       caption: 'PAR · 48.85°N', rotate: -2, offsetX: -140, offsetY:  8 },      { src: '/img2.jpeg', alt: 'Den Haag',    caption: 'HAG · 52.07°N', rotate:  2, offsetX:    0, offsetY: -8 },      { src: '/img3.jpeg', alt: 'Cinque Terre',caption: 'CNQ · 44.12°N', rotate: -2, offsetX:  140, offsetY: 16 },    ],    maxVisible: 5,  },)
const visibleCount  = computed(() => Math.min(props.photos.length, props.maxVisible))const overflowCount = computed(() => Math.max(0, props.photos.length - props.maxVisible))
// Hash a string into a stable 0–1 pseudo-random number// (so auto-scattered photos look "random" but never reshuffle).function hashStr(str: string) {  let h = 0  for (let i = 0; i < str.length; i++) h = (h * 31 + str.charCodeAt(i)) | 0  const s = Math.sin(h * 0.017) * 43758.5453  return s - Math.floor(s)}
// Per-photo layout: manual values win, otherwise auto-scatter.function layoutFor(i: number, total: number, photo: Photo) {  const center = (total - 1) / 2  const offset = i - center
  const autoX   = offset * 140  const autoY   = (hashStr(photo.src + 'y') - 0.5) * 40  const autoRot = (hashStr(photo.src + 'r') - 0.5) * 18
  const translateX = photo.offsetX ?? autoX  const translateY = photo.offsetY ?? autoY  const rotate     = photo.rotate  ?? autoRot
  return {    transform: `translate(-50%, -50%) translate(${translateX}px, ${translateY}px) rotate(${rotate}deg)`,    zIndex: total - Math.abs(offset),  }}
const activeIdx   = ref<number | null>(null)const activePhoto = computed(() =>  activeIdx.value !== null ? props.photos[activeIdx.value] : null,)
const open  = (i: number) => (activeIdx.value = i)const close = ()          => (activeIdx.value = null)const next  = () =>  activeIdx.value !== null &&  (activeIdx.value = (activeIdx.value + 1) % props.photos.length)const prev  = () =>  activeIdx.value !== null &&  (activeIdx.value = (activeIdx.value - 1 + props.photos.length) % props.photos.length)
function onKey(e: KeyboardEvent) {  if (activeIdx.value === null) return  if (e.key === 'Escape')     close()  if (e.key === 'ArrowRight') next()  if (e.key === 'ArrowLeft')  prev()}
onMounted(()       => window.addEventListener('keydown', onKey))onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
watch(activeIdx, (v) => {  if (typeof document === 'undefined') return  document.body.style.overflow = v !== null ? 'hidden' : ''})</script>
<template>  <div class="flex w-full items-center justify-center">    <!-- Polaroid stack -->    <div class="relative h-56 w-full max-w-md">      <button        v-for="(photo, idx) in photos.slice(0, visibleCount)"        :key="photo.src + idx"        type="button"        @click="open(idx)"        :style="{ ...layoutFor(idx, visibleCount, photo), left: '50%', top: '50%' }"        :class="[          'absolute h-44 w-32 overflow-hidden rounded-md border-4 border-white bg-white',          'shadow-[0_10px_30px_rgba(0,0,0,0.18)] cursor-zoom-in',          'transition-transform duration-300 hover:scale-[1.05]',          'focus:outline-none focus-visible:ring-2 focus-visible:ring-white/60',        ]"      >        <img :src="photo.src" :alt="photo.alt" loading="lazy" class="block h-full w-full object-cover" />        <span          v-if="photo.caption"          class="absolute bottom-1.5 left-2 font-mono text-[9px] font-medium tracking-wider text-white/90 mix-blend-difference"        >          {{ photo.caption }}        </span>      </button>
      <!-- "+N more" chip when photos.length > maxVisible -->      <button        v-if="overflowCount > 0"        type="button"        @click="open(visibleCount - 1)"        :style="{          ...layoutFor(visibleCount, visibleCount + 1, { src: '+more', alt: '' }),          left: '50%',          top: '50%',        }"        :class="[          'absolute flex h-44 w-32 flex-col items-center justify-center gap-1 overflow-hidden rounded-md border-4 border-white bg-neutral-100',          'shadow-[0_10px_30px_rgba(0,0,0,0.18)] cursor-pointer',          'transition-transform duration-300 hover:scale-[1.05]',        ]"        :aria-label="`Show ${overflowCount} more photos`"      >        <span class="text-[22px] font-semibold tracking-tight text-neutral-800">+{{ overflowCount }}</span>        <span class="text-[10px] font-medium uppercase tracking-wider text-neutral-400">more</span>      </button>    </div>
    <!-- Lightbox -->    <Teleport to="body">      <Transition        enter-active-class="transition duration-200 ease-out"        enter-from-class="opacity-0"        enter-to-class="opacity-100"        leave-active-class="transition duration-150 ease-in"        leave-from-class="opacity-100"        leave-to-class="opacity-0"      >        <div          v-if="activeIdx !== null"          class="fixed inset-0 z-100 flex items-center justify-center bg-black/85 backdrop-blur-md p-6"          role="dialog"          aria-modal="true"          @click.self="close"        >          <button type="button" @click="close" aria-label="Close"            class="absolute top-5 right-5 w-9 h-9 rounded-full flex items-center justify-center bg-white/10 hover:bg-white/20 ring-1 ring-inset ring-white/10 text-white/80 hover:text-white transition">            <svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">              <path d="M18 6L6 18M6 6l12 12" />            </svg>          </button>
          <button v-if="photos.length > 1" type="button" @click.stop="prev" aria-label="Previous"            class="absolute left-5 top-1/2 -translate-y-1/2 w-10 h-10 rounded-full flex items-center justify-center bg-white/10 hover:bg-white/20 ring-1 ring-inset ring-white/10 text-white/80 hover:text-white transition">            <svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">              <path d="M15 18l-6-6 6-6" />            </svg>          </button>
          <button v-if="photos.length > 1" type="button" @click.stop="next" aria-label="Next"            class="absolute right-5 top-1/2 -translate-y-1/2 w-10 h-10 rounded-full flex items-center justify-center bg-white/10 hover:bg-white/20 ring-1 ring-inset ring-white/10 text-white/80 hover:text-white transition">            <svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">              <path d="M9 6l6 6-6 6" />            </svg>          </button>
          <Transition            mode="out-in"            enter-active-class="transition duration-200 ease-out"            enter-from-class="opacity-0 scale-[0.98]"            enter-to-class="opacity-100 scale-100"            leave-active-class="transition duration-150 ease-in"            leave-from-class="opacity-100 scale-100"            leave-to-class="opacity-0 scale-[0.98]"          >            <div v-if="activePhoto" :key="activeIdx" class="relative flex flex-col items-center gap-3 max-w-[90vw] max-h-[88vh]">              <img                :src="activePhoto.src"                :alt="activePhoto.alt"                class="block max-w-[90vw] max-h-[78vh] object-contain rounded-md shadow-[0_30px_80px_rgba(0,0,0,0.5)]"              />              <div class="flex items-center gap-3 text-white/70">                <span class="text-[13px] tracking-[-0.01em] font-medium">{{ activePhoto.alt }}</span>                <template v-if="activePhoto.caption">                  <span class="w-px h-3 bg-white/20"></span>                  <span class="font-mono text-[11px] tracking-wider">{{ activePhoto.caption }}</span>                </template>                <span class="w-px h-3 bg-white/20"></span>                <span class="font-mono text-[11px] text-white/40">{{ (activeIdx ?? 0) + 1 }} / {{ photos.length }}</span>              </div>            </div>          </Transition>        </div>      </Transition>    </Teleport>  </div></template>

More ui components

Purple gradient background

Stop recreating designs you've already seen

Join now and get 30% off your first year as an early supporter. One email on launch day. That's it.