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

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.