# Fullscreen Gallery

> A fullscreen gallery that fills the viewport with one image at a time. Good for immersive photo stories, lookbooks, or a striking visual showcase.

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-fullscreen-gallery).

- Page, with a live preview: https://shadcn.exchange/r/shadcn-ui-blocks/marketing-gallery-fullscreen-gallery
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/shadcn-ui-blocks/marketing-gallery-fullscreen-gallery-light-784d1be6eaa88051.png
- Registry item: https://www.shadcn-ui-blocks.com/r/marketing-gallery-fullscreen-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-fullscreen-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/fullscreen-gallery.tsx`

```tsx
import * as React from 'react';
import { cn } from '@/lib/utils';
import {
  ChevronLeftIcon,
  ChevronRightIcon,
  ExpandIcon,
  MinimizeIcon,
} from 'lucide-react';
import { Button } from '@/components/ui/button';

interface GalleryImage {
  src: string;
  alt: string;
  width: number;
  height: number;
}

export default function FullscreenGallery() {
  const [currentIndex, setCurrentIndex] = React.useState(0);
  const [isFullscreen, setIsFullscreen] = React.useState(false);
  const galleryRef = React.useRef<HTMLDivElement>(null);

  // Static images array
  const images: GalleryImage[] = [
    {
      src: 'https://images.unsplash.com/photo-1682687982501-1e58ab814714?q=80&w=1470&auto=format&fit=crop',
      alt: 'Emerald lake surrounded by forest and mountains',
      width: 1470,
      height: 980,
    },
    {
      src: 'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?q=80&w=1470&auto=format&fit=crop',
      alt: 'Majestic mountain peaks with snow and clouds',
      width: 1470,
      height: 980,
    },
    {
      src: 'https://images.unsplash.com/photo-1433086966358-54859d0ed716?q=80&w=1374&auto=format&fit=crop',
      alt: 'Powerful waterfall flowing through lush greenery',
      width: 1374,
      height: 916,
    },
    {
      src: 'https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?q=80&w=1474&auto=format&fit=crop',
      alt: 'Foggy forest with sunlight streaming through trees',
      width: 1474,
      height: 982,
    },
    {
      src: 'https://images.unsplash.com/photo-1426604966848-d7adac402bff?q=80&w=1470&auto=format&fit=crop',
      alt: 'Serene mountain lake reflecting snow-capped peaks',
      width: 1470,
      height: 980,
    },
  ];

  const prevSlide = () => {
    setCurrentIndex((prev) => (prev === 0 ? images.length - 1 : prev - 1));
  };

  const nextSlide = () => {
    setCurrentIndex((prev) => (prev === images.length - 1 ? 0 : prev + 1));
  };

  // Handle fullscreen toggling
  const toggleFullscreen = () => {
    if (!isFullscreen) {
      if (galleryRef.current?.requestFullscreen) {
        galleryRef.current.requestFullscreen();
      }
      setIsFullscreen(true);
    } else {
      if (document.exitFullscreen) {
        document.exitFullscreen();
      }
      setIsFullscreen(false);
    }
  };

  React.useEffect(() => {
    const handleFullscreenChange = () => {
      setIsFullscreen(!!document.fullscreenElement);
    };

    document.addEventListener('fullscreenchange', handleFullscreenChange);
    return () => {
      document.removeEventListener('fullscreenchange', handleFullscreenChange);
    };
  }, []);

  // Keyboard navigation
  React.useEffect(() => {
    const handleKeyDown = (e: KeyboardEvent) => {
      if (e.key === 'ArrowRight') {
        nextSlide();
      } else if (e.key === 'ArrowLeft') {
        prevSlide();
      } else if (e.key === 'Escape' && isFullscreen) {
        toggleFullscreen();
      } else if (e.key === 'f') {
        toggleFullscreen();
      }
    };

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

  return (
    <div
      ref={galleryRef}
      className={cn(
        'relative w-full overflow-hidden bg-black',
        isFullscreen ? 'h-screen' : 'aspect-video rounded-lg'
      )}
    >
      {/* Progress bar */}
      <div className="absolute top-0 right-0 left-0 z-10 h-1 bg-black/20">
        <div
          className="h-full bg-white"
          style={{ width: `${((currentIndex + 1) / images.length) * 100}%` }}
        />
      </div>

      {/* Images */}
      <div className="relative h-full w-full">
        {images.map((image, index) => (
          <div
            key={`slide-${index}`}
            className={cn(
              'absolute inset-0 transition-all duration-500',
              index === currentIndex ? 'opacity-100' : 'opacity-0'
            )}
          >
            <img
              src={image.src}
              alt={image.alt}
              sizes="100vw"
              className="object-contain"
            />
          </div>
        ))}
      </div>

      {/* Controls - centered at the bottom */}
      <div className="absolute inset-x-0 bottom-0 z-10 flex items-center justify-between p-4 text-white">
        <div className="text-xs text-white/70">
          {currentIndex + 1} / {images.length}
        </div>

        <div className="mb-2 flex items-center gap-2">
          <Button size="icon" onClick={prevSlide}>
            <ChevronLeftIcon className="h-5 w-5" />
          </Button>

          <Button size="icon" onClick={toggleFullscreen}>
            {isFullscreen ? (
              <MinimizeIcon className="h-5 w-5" />
            ) : (
              <ExpandIcon className="h-5 w-5" />
            )}
          </Button>

          <Button size="icon" onClick={nextSlide}>
            <ChevronRightIcon className="h-5 w-5" />
          </Button>
        </div>

        <div className="mb-3 max-w-md text-center">
          <p className="sr-only">{images[currentIndex]!.alt}</p>
        </div>
      </div>
    </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 }
```
