Creating an Immersive Art Browsing Experience with Astro Collections - Dean Keesey's Blog
6 min read

Creating an Immersive Art Browsing Experience with Astro Collections

How I built a full-viewport, reel-like scrolling experience to showcase detailed artwork while preserving the ability to zoom into intricate details.

astro
web design
art gallery
content collections
user experience
Cover image for Creating an Immersive Art Browsing Experience with Astro Collections

Creating an Immersive Art Browsing Experience with Astro Collections

When designing the gallery section of my portfolio to showcase my mother’s artwork, I faced a unique challenge. Her detailed collage work contains intricate elements that would be lost if displayed in a traditional gallery grid with multiple thumbnails per page. I needed a solution that would:

  1. Display each artwork as large as possible
  2. Allow visitors to zoom into the fine details
  3. Create a smooth, engaging browsing experience

The solution I landed on was inspired by modern content consumption platforms like Instagram Reels and TikTok – a full-viewport, vertically-scrolling experience powered by Astro’s content collections.

Why Traditional Galleries Didn’t Work

Most portfolio sites use a grid layout for gallery items, which is perfect for showcasing multiple works at a glance. However, for large, detailed artwork like my mother’s collages, this approach creates several problems:

  1. Loss of detail: The thumbnail view robs viewers of seeing the artwork’s intricacies
  2. Poor aspect ratio handling: Collages often have unique dimensions that get cropped in grids
  3. Competing visual attention: Multiple artworks visible simultaneously can diminish the impact of each piece

Thinking in Terms of Experience, Not Just Layout

Instead of focusing on fitting more items per page, I asked myself: “How would these pieces be displayed in a physical gallery?” The answer was obvious – each would have its own wall space, viewed one at a time, with the ability to step closer for details.

This realization led me to create a digital equivalent – a full-viewport, sequential browsing experience.

Implementing with Astro Collections

Astro’s content collections provided the perfect foundation for this approach. Here’s how I built it:

First, I set up a collection for the artwork in src/content/config.ts:

const artworkCollection = defineCollection({
  type: 'content',
  schema: z.object({
    title: z.string(),
    artist: z.string(),
    description: z.string(),
    image: z.string(),
    width: z.number(),
    height: z.number(),
    medium: z.string(),
    year: z.number(),
    featured: z.boolean().default(false),
    tags: z.array(z.string()).default([]),
  }),
});

export const collections = {
  artwork: artworkCollection,
  // other collections...
};

Next, I created the full-viewport gallery component:

---
// src/pages/gallery/index.astro
import Layout from '@/components/layout/Layout.astro';
import { getCollection } from 'astro:content';
import ArtworkViewer from '@/components/gallery/ArtworkViewer.tsx';

const artworks = await getCollection('artwork');
const sortedArtworks = artworks.sort((a, b) => {
  // Featured items first, then by year (newest first)
  if (a.data.featured && !b.data.featured) return -1;
  if (!a.data.featured && b.data.featured) return 1;
  return b.data.year - a.data.year;
});

const seo = {
  title: 'Art Gallery - Immersive Browsing Experience',
  description: 'Explore detailed artwork in an immersive, full-screen browsing experience that lets you appreciate every detail.',
};
---

<Layout {seo}>
  <div class="artwork-container">
    <ArtworkViewer artworks={sortedArtworks} client:load />
  </div>
</Layout>

<style>
  .artwork-container {
    height: 100vh;
    width: 100%;
    overflow: hidden;
    position: relative;
  }
</style>

The real magic happens in the ArtworkViewer React component:

// src/components/gallery/ArtworkViewer.tsx
import { useState, useRef, useEffect } from 'react';
import type { CollectionEntry } from 'astro:content';
import { motion, AnimatePresence } from 'framer-motion';
import ZoomableImage from './ZoomableImage';
import ArtworkInfo from './ArtworkInfo';

interface ArtworkViewerProps {
  artworks: CollectionEntry<'artwork'>[];
}

export default function ArtworkViewer({ artworks }: ArtworkViewerProps) {
  const [currentIndex, setCurrentIndex] = useState(0);
  const [direction, setDirection] = useState(0);
  const containerRef = useRef<HTMLDivElement>(null);

  // Wheel event handler for scrolling between artworks
  useEffect(() => {
    const handleWheel = (e: WheelEvent) => {
      // Debounce for smoother experience
      if (Math.abs(e.deltaY) < 50) return;
      
      if (e.deltaY > 0 && currentIndex < artworks.length - 1) {
        setDirection(1);
        setCurrentIndex(prev => prev + 1);
      } else if (e.deltaY < 0 && currentIndex > 0) {
        setDirection(-1);
        setCurrentIndex(prev => prev - 1);
      }
    };

    const container = containerRef.current;
    container?.addEventListener('wheel', handleWheel);
    
    return () => {
      container?.removeEventListener('wheel', handleWheel);
    };
  }, [currentIndex, artworks.length]);

  // Touch controls for mobile
  // ... touch event handlers omitted for brevity

  const currentArtwork = artworks[currentIndex];
  
  return (
    <div ref={containerRef} className="artwork-viewer">
      <AnimatePresence initial={false} custom={direction}>
        <motion.div 
          key={currentArtwork.id}
          custom={direction}
          initial={{ y: direction * 1000, opacity: 0 }}
          animate={{ y: 0, opacity: 1 }}
          exit={{ y: direction * -1000, opacity: 0 }}
          transition={{ type: 'spring', stiffness: 300, damping: 30 }}
          className="artwork-slide"
        >
          <ZoomableImage 
            src={currentArtwork.data.image} 
            alt={currentArtwork.data.title} 
            width={currentArtwork.data.width}
            height={currentArtwork.data.height}
          />
          <ArtworkInfo artwork={currentArtwork} />
        </motion.div>
      </AnimatePresence>
      
      <div className="navigation">
        <ProgressBar current={currentIndex + 1} total={artworks.length} />
        <NavigationDots 
          count={artworks.length} 
          active={currentIndex} 
          onChange={setCurrentIndex} 
        />
      </div>
    </div>
  );
}

// Helper components omitted for brevity

The most crucial component is ZoomableImage, which enables the deep-zoom functionality:

// src/components/gallery/ZoomableImage.tsx
import { useState, useRef, useEffect } from 'react';
import { motion } from 'framer-motion';

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

export default function ZoomableImage({ src, alt, width, height }: ZoomableImageProps) {
  const [scale, setScale] = useState(1);
  const [position, setPosition] = useState({ x: 0, y: 0 });
  const [dragging, setDragging] = useState(false);
  const constraintsRef = useRef<HTMLDivElement>(null);
  
  // Calculate the initial scale to fit the image properly
  useEffect(() => {
    if (!constraintsRef.current) return;
    
    const containerAspect = constraintsRef.current.clientWidth / constraintsRef.current.clientHeight;
    const imageAspect = width / height;
    
    // Determine whether to fit by width or height
    let initialScale;
    if (imageAspect > containerAspect) {
      initialScale = constraintsRef.current.clientWidth / width * 0.9;
    } else {
      initialScale = constraintsRef.current.clientHeight / height * 0.9;
    }
    
    setScale(initialScale);
  }, [width, height]);

  // Handle double tap for zoom
  const handleDoubleTap = () => {
    if (scale > 1) {
      // Reset to initial view
      setScale(1);
      setPosition({ x: 0, y: 0 });
    } else {
      // Zoom in to 2.5x
      setScale(2.5);
    }
  };

  return (
    <div 
      ref={constraintsRef}
      className="zoomable-container"
    >
      <motion.div
        className="zoomable-content"
        drag={scale > 1}
        dragConstraints={constraintsRef}
        dragElastic={0.1}
        dragMomentum={false}
        onDragStart={() => setDragging(true)}
        onDragEnd={() => setDragging(false)}
        onDoubleClick={handleDoubleTap}
        style={{
          scale,
          x: position.x,
          y: position.y,
          cursor: scale > 1 ? 'grab' : 'default',
        }}
        whileDrag={{ cursor: 'grabbing' }}
      >
        <img 
          src={src} 
          alt={alt} 
          className="artwork-image"
          draggable="false"
        />
      </motion.div>
      
      {scale > 1 && (
        <button 
          className="reset-zoom"
          onClick={() => {
            setScale(1);
            setPosition({ x: 0, y: 0 });
          }}
        >
          Reset View
        </button>
      )}
    </div>
  );
}

Key UX Considerations

Several design decisions were crucial to make this experience work:

1. Smooth Transitions

Using Framer Motion for transitions creates a cinematic feeling when moving between artworks. The spring physics gives weight and momentum to the scrolling, making it feel natural.

2. Intuitive Zoom Controls

The zoom experience needed to be intuitive across devices:

  • Double-tap/click to zoom in
  • Pinch-to-zoom on touch devices
  • Drag to pan when zoomed in
  • A reset button to quickly return to the full view

3. Progressive Loading

To ensure performance, I implemented:

  • Progressive image loading with low-resolution placeholders
  • Preloading of adjacent artworks
  • Unloading distant artworks to conserve memory

4. Context Preservation

I included minimal, non-intrusive navigation elements:

  • A subtle progress bar showing position in the collection
  • Small navigation dots for direct access to specific pieces
  • Swipe gestures on mobile

Performance Optimizations

This approach requires careful performance optimization:

  1. Image Processing: All artwork images are processed through a pipeline that:

    • Creates multiple resolution variants
    • Implements modern formats (WebP with JPEG fallback)
    • Applies appropriate compression
  2. Virtualization: Only the current artwork and its immediate neighbors remain fully in memory.

  3. Prefetching: The next few artworks in both directions are prefetched based on scroll direction.

  4. Lazy Details: Information about each artwork is loaded only when needed.

Accessibility Considerations

A beautiful experience must also be accessible:

  1. Keyboard Navigation: Arrow keys provide full navigation
  2. Screen Reader Support: Proper ARIA labels and image descriptions
  3. Reduced Motion: Alternative transitions for users with motion sensitivity
  4. Focus Management: Clear focus indicators and logical tab order

Conclusion: Prioritizing the Art

The most important lesson from this project was learning when to break from conventional patterns. By prioritizing the artwork itself – its detail, scale, and impact – I created a more meaningful experience than a traditional gallery could provide.

This approach won’t be right for every portfolio or gallery site. It works specifically because of the nature of the artwork being showcased – large, detailed pieces that benefit from dedicated attention and the ability to explore their intricacies.

The end result is an immersive digital gallery that captures some of the magic of viewing these works in person, allowing viewers to appreciate both the full composition and the minute details that make each piece special.

← Back to all posts

Written by Dean Keesey