<script setup lang="ts">
import {
Gallery,
GalleryContent,
GalleryImage,
GalleryImageSource,
GalleryItem,
GalleryNext,
GalleryPrevious,
GalleryToolbar,
GalleryViewer,
GalleryViewerClose,
GalleryViewerContent,
GalleryViewerGallery,
GalleryViewerSidebar,
GalleryViewerSidebarTrigger,
GalleryViewerTrigger,
GalleryZoomInControl,
GalleryZoomOutControl,
} from "@/components/gallery";
import { Image, ImageLoading, ImageSource } from "@/components/image";
import { SidebarContent, SidebarHeader, SidebarMenu, SidebarMenuItem } from "@/components/ui/sidebar";
import { ref } from "vue";
const scale = ref(1);
const initialIndex = ref(0);
const images = [
"https://cdn.shopify.com/s/files/1/0745/1295/7658/files/IMG-18273962_m_jpeg_1.jpg?v=1760537131",
"https://www.ghacks.net/wp-content/uploads/2025/10/M5-MacBook-Pro-keyboard.jpg",
"https://istyle.mk/cdn/shop/files/IMG-18277044_m_jpeg_1.jpg?v=1760537022&width=823",
"https://www.papita.co/om/wp-content/uploads/sites/22/2025/10/MACBOOKPROM5-3.jpg",
"https://istyle.mk/cdn/shop/files/IMG-18277042_m_jpeg_1.jpg?v=1760537023&width=823",
"https://cdn.pdx.stibosystems.com/submitted/assets/23a2f7d5-e63d-3d61-95ee-745b7c0c8dd0/IMG-18277141/IMG-18277141_m_jpeg_1.jpeg?v=1760536763133",
];
</script>
<template>
<GalleryViewer default-sidebar-open>
<div class="grid grid-cols-3 gap-2 w-full">
<GalleryViewerTrigger v-for="(src, index) in images" :key="index" class="w-full bg-muted rounded-lg overflow-hidden cursor-pointer" @click="initialIndex = index">
<Image class="size-full">
<ImageSource :src="src" alt="Gallery image" class="w-full aspect-square object-cover hover:scale-110 transition-transform duration-300 animate-in fade-in" />
<ImageLoading as-child>
<div class="w-full aspect-square bg-muted animate-in fade-in" />
</ImageLoading>
</Image>
</GalleryViewerTrigger>
</div>
<GalleryViewerContent :style="{ '--sidebar-width': '20rem', '--sidebar-width-mobile': '20rem' }">
<GalleryViewerGallery>
<Gallery
v-model:zoom-scale="scale"
:zoom-follow-cursor="true"
:zoom-max-scale="2"
:initial-index="initialIndex"
zoom-on-click
class="flex flex-col gap-6 h-full"
v-slot="{ selectedIndex, isZoomed }"
>
<GalleryContent class="h-full items-center overflow-visible">
<GalleryItem v-for="(src, index) in images" :key="index" :item-index="index + 1" class="flex justify-center overflow-visible pointer-events-auto">
<GalleryImage class="basis-full md:basis-1/2 overflow-visible">
<GalleryImageSource :src="src" alt="Gallery image" class="w-full h-full object-cover" />
</GalleryImage>
</GalleryItem>
</GalleryContent>
<GalleryToolbar class="absolute top-0 right-0 flex items-center gap-2">
<GalleryZoomInControl class="text-white" />
<GalleryZoomOutControl class="text-white" />
<GalleryViewerSidebarTrigger class="text-white" />
<GalleryViewerClose class="text-white" />
</GalleryToolbar>
<div class="absolute shadow bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-2 bg-muted text-sm text-muted-foreground rounded-full p-2 overflow-hidden">
<div
v-for="(src, index) in images"
:key="index"
class="size-2 rounded-full"
:class="selectedIndex === index + 1 ? 'bg-primary' : 'bg-primary/30'"
/>
</div>
<GalleryPrevious v-if="!isZoomed" class="hidden md:flex absolute left-4 top-1/2 -translate-y-1/2" />
<GalleryNext v-if="!isZoomed" class="hidden md:flex absolute right-4 top-1/2 -translate-y-1/2" />
</Gallery>
</GalleryViewerGallery>
<GalleryViewerSidebar side="right">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem class="px-4 py-4 font-bold text-lg">
Image Gallery
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent class="px-4">
<p class="text-sm text-muted-foreground">
Use the sidebar for metadata, image details, or custom actions. The sidebar can be toggled with the panel button in the toolbar.
</p>
</SidebarContent>
</GalleryViewerSidebar>
</GalleryViewerContent>
</GalleryViewer>
</template>
Features
- Carousel — Navigate between images with prev/next controls
- Zoom — Zoom and pan on each image (pinch, click, cursor-following)
- Fullscreen dialog — Open gallery in a fullscreen overlay
- Sidebar — Optional sidebar for custom content (metadata, details, actions)
- Navigation on zoom — Prev/next buttons hide when zoomed for cleaner UX
Installation
Install from the Vuzeno registry. Requires Image Viewer and its dependencies:
bunx --bun shadcn-vue@latest add https://vuzeno.com/r/gallery.json
Examples
Regular Carousel
<script setup lang="ts">
import { Gallery, GalleryContent, GalleryImage, GalleryImageSource, GalleryItem, GalleryNext, GalleryPrevious } from "@/components/gallery";
import { ref } from "vue";
const scale = ref(1);
const images = ["https://picsum.photos/id/229/600/400", "https://picsum.photos/id/230/600/400", "https://picsum.photos/id/231/600/400"];
</script>
<template>
<Gallery v-model:zoom-scale="scale" zoom-on-click class="relative w-full max-w-2xl overflow-hidden flex flex-col gap-6" v-slot="{ selectedIndex }">
<GalleryContent class="h-64 items-center overflow-visible">
<GalleryItem v-for="(src, index) in images" :key="index" :item-index="index + 1" class="flex justify-center overflow-visible">
<GalleryImage class="basis-1/2 overflow-visible">
<GalleryImageSource :src="src" alt="Gallery image" class="w-full h-full object-cover" />
</GalleryImage>
</GalleryItem>
</GalleryContent>
<div class="mx-auto flex items-center gap-2">
<GalleryPrevious class="relative translate-0 size-6" variant="secondary" />
<div class="flex items-center gap-2 bg-muted rounded-full p-2 overflow-hidden">
<div
v-for="(src, index) in images"
:key="index"
class="size-2 rounded-full"
:class="selectedIndex === index + 1 ? 'bg-primary' : 'bg-primary/30'"
/>
</div>
<GalleryNext class="relative translate-0 size-6" variant="secondary" />
</div>
</Gallery>
</template>
Use Gallery, GalleryContent, GalleryItem, GalleryImage, and GalleryImageSource for a simple zoomable carousel:
<template>
<Gallery v-model:zoom-scale="scale" zoom-on-click>
<GalleryContent class="h-64">
<GalleryItem v-for="(src, index) in images" :key="index" :item-index="index + 1">
<GalleryImage>
<GalleryImageSource :src="src" alt="..." />
</GalleryImage>
</GalleryItem>
</GalleryContent>
<GalleryPrevious />
<GalleryNext />
</Gallery>
</template>
Within Dialog
<script setup lang="ts">
import { Gallery, GalleryContent, GalleryImage, GalleryImageSource, GalleryItem, GalleryNext, GalleryPrevious, GalleryToolbar, GalleryZoomInControl, GalleryZoomOutControl } from "@/components/gallery";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import { Maximize2Icon, Minimize2Icon } from "lucide-vue-next";
import { ref } from "vue";
const scale = ref(1);
const initialIndex = ref(0);
const isExpanded = ref(false);
const images = [
"https://cdn.shopify.com/s/files/1/0745/1295/7658/files/IMG-18273962_m_jpeg_1.jpg?v=1760537131",
"https://www.ghacks.net/wp-content/uploads/2025/10/M5-MacBook-Pro-keyboard.jpg",
"https://istyle.mk/cdn/shop/files/IMG-18277044_m_jpeg_1.jpg?v=1760537022&width=823",
"https://www.papita.co/om/wp-content/uploads/sites/22/2025/10/MACBOOKPROM5-3.jpg",
"https://istyle.mk/cdn/shop/files/IMG-18277042_m_jpeg_1.jpg?v=1760537023&width=823",
"https://cdn.pdx.stibosystems.com/submitted/assets/23a2f7d5-e63d-3d61-95ee-745b7c0c8dd0/IMG-18277141/IMG-18277141_m_jpeg_1.jpeg?v=1760536763133",
];
</script>
<template>
<Dialog>
<DialogTrigger>
<Button variant="outline">
Open Gallery
</Button>
</DialogTrigger>
<DialogContent class="w-full max-w-3xl overflow-hidden" :class="{ 'max-w-[90vw]': isExpanded }">
<Gallery
v-model:zoom-scale="scale"
:zoom-follow-cursor="true"
:zoom-max-scale="2"
:initial-index="initialIndex"
zoom-on-click
class="flex flex-col gap-4 h-full"
v-slot="{ selectedIndex, isZoomed }"
>
<GalleryContent class="h-full items-center">
<GalleryItem v-for="(src, index) in images" :key="index" :item-index="index + 1" class="flex justify-center pointer-events-auto">
<GalleryImage class="basis-full md:basis-1/2">
<GalleryImageSource :src="src" alt="Gallery image" class="w-full h-full object-cover" />
</GalleryImage>
</GalleryItem>
</GalleryContent>
<GalleryToolbar class="flex items-center gap-2 mx-auto">
<GalleryZoomInControl />
<GalleryZoomOutControl />
<Button variant="ghost" size="icon-sm" class="size-7" @click="isExpanded = !isExpanded">
<Maximize2Icon v-if="!isExpanded" />
<Minimize2Icon v-else />
</Button>
</GalleryToolbar>
<GalleryPrevious v-if="!isZoomed" class="hidden md:flex absolute left-4 top-1/2 -translate-y-1/2" />
<GalleryNext v-if="!isZoomed" class="hidden md:flex absolute right-4 top-1/2 -translate-y-1/2" />
</Gallery>
</DialogContent>
</Dialog>
</template>
Fullscreen with Sidebar
Combine GalleryViewer, GalleryViewerTrigger, GalleryViewerContent, and GalleryViewerSidebar for a fullscreen experience with custom sidebar content:
<template>
<GalleryViewer v-model:open="open">
<GalleryViewerTrigger>
<Button>Open Gallery</Button>
</GalleryViewerTrigger>
<GalleryViewerContent :style="{ '--sidebar-width': '24rem' }">
<GalleryViewerGallery>
<Gallery v-model:zoom-scale="scale" zoom-on-click>
<GalleryContent>...</GalleryContent>
<GalleryToolbar>
<GalleryZoomInControl />
<GalleryZoomOutControl />
<GalleryViewerSidebarTrigger />
<GalleryViewerClose />
</GalleryToolbar>
<GalleryPrevious />
<GalleryNext />
</Gallery>
</GalleryViewerGallery>
<GalleryViewerSidebar side="right">
<SidebarHeader>...</SidebarHeader>
<SidebarContent>Custom metadata, details, etc.</SidebarContent>
</GalleryViewerSidebar>
</GalleryViewerContent>
</GalleryViewer>
</template>