# Immersive Gallery

> An edge to edge gallery with large visuals and smooth motion. Use it to surround visitors in your photos or brand imagery on a campaign page.

A shadcn/ui block from the [@shadcn-ui-blocks](https://www.shadcn-ui-blocks.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/shadcn-ui-blocks/marketing-gallery-immersive-gallery).

- Page, with a live preview: https://shadcn.exchange/r/shadcn-ui-blocks/marketing-gallery-immersive-gallery
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/shadcn-ui-blocks/marketing-gallery-immersive-gallery-light-b98778df080947d1.png
- Registry item: https://www.shadcn-ui-blocks.com/r/marketing-gallery-immersive-gallery.json
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://www.shadcn-ui-blocks.com/r/marketing-gallery-immersive-gallery.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `class-variance-authority`, `lucide-react`, `radix-ui`

## Files

### `components/blocks/marketing/gallery/immersive-gallery.tsx`

```tsx
import * as React from 'react';
import { Button } from '@/components/ui/button';
import {
  ArrowLeft,
  ArrowRight,
  Camera,
  Calendar,
  MapPin,
  Info,
  X,
  ChevronUp,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from '@/components/ui/tooltip';
import { Badge } from '@/components/ui/badge';

interface SplitGalleryImage {
  src: string;
  alt: string;
  width: number;
  height: number;
  title: string;
  description: string;
  photographer: string;
  date: string;
  location: string;
  tags: string[];
}

export default function SplitGallery() {
  const [currentIndex, setCurrentIndex] = React.useState(0);
  const [infoExpanded, setInfoExpanded] = React.useState(false);
  const [fullscreen, setFullscreen] = React.useState(false);

  // Static images array with detailed metadata
  const images = React.useMemo<SplitGalleryImage[]>(
    () => [
      {
        src: 'https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?q=80&w=1474&auto=format&fit=crop',
        alt: 'Mountain lake with snow-capped peaks in background',
        width: 1474,
        height: 983,
        title: 'Alpine Serenity',
        description:
          'A pristine alpine lake reflects the majestic snow-capped mountains that surround it. The stillness of the water creates a perfect mirror image of the landscape, while the early morning light bathes everything in a golden glow.',
        photographer: 'Thomas Raynor',
        date: 'August 15, 2023',
        location: 'Banff National Park, Canada',
        tags: ['Nature', 'Landscape', 'Mountains', 'Water'],
      },
      {
        src: 'https://images.unsplash.com/photo-1470770903676-69b98201ea1c?q=80&w=1470&auto=format&fit=crop',
        alt: 'Sunset over mountain range with dramatic clouds',
        width: 1470,
        height: 980,
        title: 'Twilight Mountains',
        description:
          'As the day comes to a close, the setting sun casts dramatic rays through the clouds, illuminating the mountain range with a spectrum of warm colors. The interplay of light and shadow emphasizes the rugged texture of the peaks.',
        photographer: 'Emma Walters',
        date: 'October 3, 2023',
        location: 'Dolomites, Italy',
        tags: ['Sunset', 'Mountains', 'Clouds', 'Dramatic'],
      },
      {
        src: 'https://images.unsplash.com/photo-1542224566-6e85f2e6772f?q=80&w=1528&auto=format&fit=crop',
        alt: 'Desert landscape with rock formations at sunrise',
        width: 1528,
        height: 1019,
        title: 'Desert Dawn',
        description:
          'The first light of day breaks over ancient rock formations in the desert, creating long shadows and revealing the rich textures and colors of the arid landscape. The silence and vastness create a profound sense of solitude and timelessness.',
        photographer: 'Hassan Khan',
        date: 'March 21, 2023',
        location: 'Wadi Rum, Jordan',
        tags: ['Desert', 'Sunrise', 'Rock Formations', 'Landscape'],
      },
      {
        src: 'https://images.unsplash.com/photo-1505765050516-f72dcac9c60e?q=80&w=1470&auto=format&fit=crop',
        alt: 'Foggy forest with sunlight streaming through trees',
        width: 1470,
        height: 980,
        title: 'Mystic Forest',
        description:
          'Morning fog weaves between ancient trees as rays of sunlight pierce through the canopy. The ethereal atmosphere transforms the forest into an enchanted realm where time seems to stand still and every sound is muffled by the mist.',
        photographer: 'Lucia Fernandez',
        date: 'November 12, 2023',
        location: 'Olympic National Park, USA',
        tags: ['Forest', 'Fog', 'Sunlight', 'Trees', 'Mystical'],
      },
      {
        src: 'https://images.unsplash.com/photo-1534447677768-be436bb09401?q=80&w=1494&auto=format&fit=crop',
        alt: 'Aerial view of turquoise ocean water meeting white sand beach',
        width: 1494,
        height: 996,
        title: 'Coastal Paradise',
        description:
          'A birds-eye view reveals the stunning meeting point of crystal-clear turquoise waters and pristine white sand beaches. The natural patterns formed by ocean currents create a mesmerizing tapestry of colors and textures along the shoreline.',
        photographer: 'Michael Chen',
        date: 'January 8, 2023',
        location: 'Whitsunday Islands, Australia',
        tags: ['Aerial', 'Ocean', 'Beach', 'Coastal', 'Turquoise'],
      },
    ],
    []
  );

  const nextImage = () => {
    setCurrentIndex((prev) => (prev === images.length - 1 ? 0 : prev + 1));
    if (infoExpanded) setInfoExpanded(false);
  };

  const prevImage = () => {
    setCurrentIndex((prev) => (prev === 0 ? images.length - 1 : prev - 1));
    if (infoExpanded) setInfoExpanded(false);
  };

  // Keyboard navigation
  React.useEffect(() => {
    const handleKeyDown = (e: KeyboardEvent) => {
      if (e.key === 'ArrowRight') {
        nextImage();
      } else if (e.key === 'ArrowLeft') {
        prevImage();
      } else if (e.key === 'Escape' && fullscreen) {
        setFullscreen(false);
      } else if (e.key === 'i') {
        setInfoExpanded((prev) => !prev);
      } else if (e.key === 'f') {
        setFullscreen((prev) => !prev);
      }
    };

    window.addEventListener('keydown', handleKeyDown);
    return () => window.removeEventListener('keydown', handleKeyDown);
  }, [fullscreen, infoExpanded]);

  const currentImage = images[currentIndex]!;

  return (
    <div
      className={cn(
        'relative w-full overflow-hidden bg-black text-white',
        fullscreen ? 'fixed inset-0 z-50' : 'h-screen max-h-[800px]'
      )}
    >
      {/* Main gallery container */}
      <div className="relative h-full w-full">
        {/* Image container */}
        <div className="absolute inset-0 transition-opacity duration-500">
          <img
            src={currentImage.src}
            alt={currentImage.alt}
            sizes="100vw"
            className="object-cover"
          />
          {/* Gradient overlay for better text readability */}
          <div className="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-black/30" />
        </div>

        {/* Header with title and navigation */}
        <div className="absolute top-0 right-0 left-0 z-10 flex items-center justify-between p-4 md:p-6">
          <h2 className="animate-fade-in text-xl font-bold text-white md:text-3xl">
            {currentImage.title}
          </h2>

          <div className="flex items-center gap-2">
            <TooltipProvider>
              <Tooltip>
                <TooltipTrigger asChild>
                  <Button
                    variant="outline"
                    size="icon"
                    className="h-8 w-8 rounded-full border-white/20 bg-black/30 text-white backdrop-blur-sm hover:bg-black/50 md:h-10 md:w-10"
                    onClick={() => setInfoExpanded(!infoExpanded)}
                  >
                    <Info className="h-4 w-4 md:h-5 md:w-5" />
                  </Button>
                </TooltipTrigger>
                <TooltipContent side="bottom">
                  <p>Image details</p>
                </TooltipContent>
              </Tooltip>
            </TooltipProvider>

            {fullscreen && (
              <TooltipProvider>
                <Tooltip>
                  <TooltipTrigger asChild>
                    <Button
                      variant="outline"
                      size="icon"
                      className="h-8 w-8 rounded-full border-white/20 bg-black/30 text-white backdrop-blur-sm hover:bg-black/50 md:h-10 md:w-10"
                      onClick={() => setFullscreen(false)}
                    >
                      <X className="h-4 w-4 md:h-5 md:w-5" />
                    </Button>
                  </TooltipTrigger>
                  <TooltipContent side="bottom">
                    <p>Exit fullscreen</p>
                  </TooltipContent>
                </Tooltip>
              </TooltipProvider>
            )}
          </div>
        </div>

        {/* Navigation arrows */}
        <div className="absolute top-1/2 right-0 left-0 z-10 flex -translate-y-1/2 justify-between px-4 md:px-6">
          <Button
            variant="outline"
            size="icon"
            className="h-10 w-10 rounded-full border-white/20 bg-black/30 text-white backdrop-blur-sm hover:bg-black/50 md:h-12 md:w-12"
            onClick={prevImage}
            aria-label="Previous image"
          >
            <ArrowLeft className="h-5 w-5 md:h-6 md:w-6" />
          </Button>
          <Button
            variant="outline"
            size="icon"
            className="h-10 w-10 rounded-full border-white/20 bg-black/30 text-white backdrop-blur-sm hover:bg-black/50 md:h-12 md:w-12"
            onClick={nextImage}
            aria-label="Next image"
          >
            <ArrowRight className="h-5 w-5 md:h-6 md:w-6" />
          </Button>
        </div>

        {/* Bottom info bar */}
        <div
          className={cn(
            'absolute right-0 bottom-0 left-0 z-20 transform transition-transform duration-500',
            infoExpanded ? 'translate-y-0' : 'translate-y-[calc(100%-60px)]'
          )}
        >
          {/* Info toggle button */}
          <button
            onClick={() => setInfoExpanded(!infoExpanded)}
            className="mx-auto flex h-10 w-full items-center justify-center bg-black/60 backdrop-blur-md"
          >
            <ChevronUp
              className={cn(
                'h-5 w-5 transition-transform duration-300',
                infoExpanded && 'rotate-180'
              )}
            />
          </button>

          {/* Expanded info panel */}
          <div className="bg-black/60 p-5 backdrop-blur-md md:p-6">
            <div className="mb-4 grid gap-5 md:grid-cols-2">
              <div className="space-y-4">
                <div className="animate-fade-in text-sm md:text-base">
                  {currentImage.description}
                </div>

                <div className="flex flex-wrap gap-2">
                  {currentImage.tags.map((tag) => (
                    <Badge
                      key={tag}
                      variant="secondary"
                      className="bg-white/10 hover:bg-white/20"
                    >
                      {tag}
                    </Badge>
                  ))}
                </div>
              </div>

              <div className="space-y-4">
                <div className="grid grid-cols-[repeat(auto-fit,minmax(150px,1fr))] gap-4">
                  <div className="space-y-1">
                    <div className="flex items-center gap-2">
                      <Camera className="h-4 w-4 text-gray-400" />
                      <span className="text-xs font-medium text-gray-400 uppercase">
                        Photographer
                      </span>
                    </div>
                    <p className="text-sm">{currentImage.photographer}</p>
                  </div>

                  <div className="space-y-1">
                    <div className="flex items-center gap-2">
                      <Calendar className="h-4 w-4 text-gray-400" />
                      <span className="text-xs font-medium text-gray-400 uppercase">
                        Date
                      </span>
                    </div>
                    <p className="text-sm">{currentImage.date}</p>
                  </div>

                  <div className="space-y-1">
                    <div className="flex items-center gap-2">
                      <MapPin className="h-4 w-4 text-gray-400" />
                      <span className="text-xs font-medium text-gray-400 uppercase">
                        Location
                      </span>
                    </div>
                    <p className="text-sm">{currentImage.location}</p>
                  </div>
                </div>

                <div className="pt-2">
                  <Button
                    onClick={() => setFullscreen(!fullscreen)}
                    className="w-full sm:w-auto"
                    variant={fullscreen ? 'destructive' : 'default'}
                  >
                    {fullscreen ? 'Exit Fullscreen' : 'View Fullscreen'}
                  </Button>
                </div>
              </div>
            </div>
          </div>
        </div>

        {/* Bottom indicator bar */}
        <div className="absolute bottom-[60px] left-0 z-10 flex w-full justify-center gap-1 p-4">
          {images.map((_, index) => (
            <button
              key={index}
              onClick={() => {
                setCurrentIndex(index);
                if (infoExpanded) setInfoExpanded(false);
              }}
              className={cn(
                'h-1 transition-all',
                index === currentIndex
                  ? 'w-6 bg-white'
                  : 'w-3 bg-white/40 hover:bg-white/60'
              )}
              aria-label={`Go to image ${index + 1}`}
            />
          ))}
        </div>
      </div>

      {/* Add animation classes */}
      <style jsx global>{`
        @keyframes fadeIn {
          from {
            opacity: 0;
          }
          to {
            opacity: 1;
          }
        }

        .animate-fade-in {
          animation: fadeIn 0.5s ease-in-out;
        }
      `}</style>
    </div>
  );
}
```

### `components/ui/button.tsx`

```tsx
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"

import { cn } from "@/lib/utils"

const buttonVariants = cva(
  "group/button inline-flex shrink-0 items-center justify-center rounded-md border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
  {
    variants: {
      variant: {
        default: "bg-primary text-primary-foreground hover:bg-primary/80",
        outline:
          "border-border bg-background shadow-xs hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
        secondary:
          "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
        ghost:
          "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
        destructive:
          "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
        link: "text-primary underline-offset-4 hover:underline",
      },
      size: {
        default:
          "h-9 gap-1.5 px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
        xs: "h-6 gap-1 rounded-[min(var(--radius-md),8px)] px-2 text-xs in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
        sm: "h-8 gap-1 rounded-[min(var(--radius-md),10px)] px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5",
        lg: "h-10 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
        icon: "size-9",
        "icon-xs":
          "size-6 rounded-[min(var(--radius-md),8px)] in-data-[slot=button-group]:rounded-md [&_svg:not([class*='size-'])]:size-3",
        "icon-sm":
          "size-8 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-md",
        "icon-lg": "size-10",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "default",
    },
  }
)

function Button({
  className,
  variant = "default",
  size = "default",
  asChild = false,
  ...props
}: React.ComponentProps<"button"> &
  VariantProps<typeof buttonVariants> & {
    asChild?: boolean
  }) {
  const Comp = asChild ? Slot.Root : "button"

  return (
    <Comp
      data-slot="button"
      data-variant={variant}
      data-size={size}
      className={cn(buttonVariants({ variant, size, className }))}
      {...props}
    />
  )
}

export { Button, buttonVariants }
```

### `components/ui/tooltip.tsx`

```tsx
"use client"

import * as React from "react"
import { Tooltip as TooltipPrimitive } from "radix-ui"

import { cn } from "@/lib/utils"

function TooltipProvider({
  delayDuration = 0,
  ...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
  return (
    <TooltipPrimitive.Provider
      data-slot="tooltip-provider"
      delayDuration={delayDuration}
      {...props}
    />
  )
}

function Tooltip({
  ...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
  return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}

function TooltipTrigger({
  ...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
  return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}

function TooltipContent({
  className,
  sideOffset = 0,
  arrow = true,
  children,
  ...props
}: React.ComponentProps<typeof TooltipPrimitive.Content> & {
  /** Hide the pointer arrow (custom-surfaced tooltips, e.g. the app's dark
   *  menu-styled ones, where the bg-foreground diamond clashes). */
  arrow?: boolean
}) {
  return (
    <TooltipPrimitive.Portal>
      <TooltipPrimitive.Content
        data-slot="tooltip-content"
        sideOffset={sideOffset}
        className={cn(
          "z-50 inline-flex w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
          className
        )}
        {...props}
      >
        {children}
        {arrow && (
          <TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
        )}
      </TooltipPrimitive.Content>
    </TooltipPrimitive.Portal>
  )
}

export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
```

### `components/ui/badge.tsx`

```tsx
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"

import { cn } from "@/lib/utils"

const badgeVariants = cva(
  "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
  {
    variants: {
      variant: {
        default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
        secondary:
          "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
        destructive:
          "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
        outline:
          "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
        ghost:
          "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
        link: "text-primary underline-offset-4 hover:underline",
      },
    },
    defaultVariants: {
      variant: "default",
    },
  }
)

function Badge({
  className,
  variant = "default",
  asChild = false,
  ...props
}: React.ComponentProps<"span"> &
  VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
  const Comp = asChild ? Slot.Root : "span"

  return (
    <Comp
      data-slot="badge"
      data-variant={variant}
      className={cn(badgeVariants({ variant }), className)}
      {...props}
    />
  )
}

export { Badge, badgeVariants }
```
