This document presents a production-ready low-level design for an image gallery component that handles large media collections efficiently while maintaining excellent user experience and accessibility standards.
1. Problem Statement & Requirements
Clarifying questions
- What's the target browser support matrix? (e.g., IE11, mobile Safari, Chrome 80+)
- Do we need real-time image updates or is this a static gallery?
- Should the gallery support offline viewing of previously loaded images?
- What accessibility level is required? (e.g., WCAG 2.1 AA, full keyboard navigation)
- Do we need internationalization support for image captions or metadata?
- Are there any design system constraints (e.g., Material Design, Tailwind, custom tokens)?
- Should the gallery support drag-and-drop reordering or multi-select?
Functional requirements
- Display a grid of images with responsive layout
- Support image loading with placeholders and error states
- Enable user to view full-size image in modal
- Allow filtering by category or tags (if applicable)
- Support keyboard navigation between images
- Provide image metadata display (title, caption, upload date)
- Enable image download functionality
- Support infinite scroll or pagination for large collections
Non-functional requirements
- Performance: LCP < 2.5s, CLS < 0.1, INP < 200ms
- Accessibility: Full keyboard navigation, ARIA roles, screen reader support
- Scalability: Support up to 10k images with virtualized rendering
- Responsiveness: Grid layout adapts from 2 to 6 columns based on viewport width
- Offline capability: Cache images for offline viewing (if supported)
- Internationalization: Support RTL layouts and localized metadata
Constraints and assumptions
- Images are served from a CDN with consistent URLs
- Image metadata is provided via API (title, caption, tags)
- No client-side image editing or upload capabilities
- Images are assumed to be in common formats (JPEG, PNG, WebP)
- User session is managed via JWT tokens for protected routes
- Design system uses Tailwind CSS with custom theme
Out of scope
- Image upload or editing functionality
- Image compression or format conversion in browser
- Advanced filtering (e.g., search by metadata content)
- Social sharing or embedding features
- Image annotation or tagging by users
- Server-side image processing or transformations
- Multi-language content beyond metadata display
2. Component Architecture
graph TD
A[ImageGallery] --> B[ImageGrid]
A --> C[ImageModal]
A --> D[ImageToolbar]
B --> E[ImageCard]
E --> F[ImageThumbnail]
E --> G[ImageTitle]
E --> H[ImageDescription]
D --> I[SearchInput]
D --> J[FilterDropdown]
D --> K[SortSelect]
C --> L[ModalImage]
C --> M[ModalNavigation]
C --> N[ModalCloseButton]
Component Tree Breakdown
Smart Components (Container Components):
ImageGallery- Main container that orchestrates state, API calls, and renders the galleryImageModal- Handles modal view state and navigation between images
Dumb Components (Presentational Components):
ImageGrid- Renders the grid of image cardsImageCard- Individual image card with thumbnail, title, and descriptionImageThumbnail- Image preview with loading statesImageToolbar- Search, filter, and sort controlsModalImage- Full-size image in modal viewModalNavigation- Navigation controls within modalSearchInput,FilterDropdown,SortSelect- Form controls
TypeScript Props Contracts
// ImageGallery.tsx
interface Image {
id: string;
title: string;
description: string;
url: string;
thumbnailUrl: string;
uploadDate: string;
tags: string[];
}
interface ImageGalleryProps {
images?: Image[];
loading?: boolean;
error?: string | null;
onImageSelect: (image: Image) => void;
onSearch?: (query: string) => void;
onFilter?: (filter: string) => void;
onSort?: (sort: string) => void;
}
// ImageCard.tsx
interface ImageCardProps {
image: Image;
onClick: () => void;
isSelected?: boolean;
}
// ImageModal.tsx
interface ImageModalProps {
image: Image;
isOpen: boolean;
onClose: () => void;
onNext: () => void;
onPrevious: () => void;
}
UI State Machine
stateDiagram-v2
[*] --> Idle
Idle --> Loading : fetchImages()
Loading --> Success : imagesLoaded()
Loading --> Error : fetchError()
Success --> ModalOpen : openModal()
ModalOpen --> ModalClosed : closeModal()
ModalOpen --> NextImage : nextImage()
ModalOpen --> PrevImage : prevImage()
Error --> Idle : retryFetch()
Success --> Filtering : applyFilter()
Success --> Searching : applySearch()
Success --> Sorting : applySort()
Component Responsibilities
ImageGallery:
- Main orchestrator component
- Manages global state (images, loading, error)
- Handles search/filter/sort operations
- Renders image grid and modal
ImageGrid:
- Renders collection of ImageCard components in a responsive grid
- Handles layout and spacing
ImageCard:
- Individual image presentation with thumbnail, title, description
- Handles selection state and click events
ImageModal:
- Full-screen modal view for individual image
- Navigation between images in gallery
- Close functionality
Reusability Decisions
The component architecture prioritizes:
- Highly reusable:
ImageThumbnail,SearchInput,FilterDropdown- these can be used across different features - Feature-specific:
ImageCard,ImageGrid- designed specifically for gallery use case - State management:
ImageGallerycontains all complex state logic to keep components pure
Component Lifecycle Considerations
ImageGallerymounts and fetches initial image dataImageCardrenders thumbnails with proper loading statesImageModalhandles focus trapping when opening/closing- All components implement proper cleanup for event listeners and timers
ImageThumbnailimplements lazy loading with IntersectionObserver
// Example of ImageCard lifecycle management
const ImageCard = ({ image, onClick }: ImageCardProps) => {
const [isLoaded, setIsLoaded] = useState(false);
const [hasError, setHasError] = useState(false);
useEffect(() => {
// Cleanup for any side effects
return () => {
// Remove any event listeners or timers if needed
};
}, []);
const handleImageLoad = () => {
setIsLoaded(true);
};
const handleImageError = () => {
setHasError(true);
};
return (
<div className="image-card" onClick={onClick}>
<ImageThumbnail
src={image.thumbnailUrl}
alt={image.title}
onLoad={handleImageLoad}
onError={handleImageError}
loaded={isLoaded}
error={hasError}
/>
<div className="image-info">
<h3>{image.title}</h3>
<p>{image.description}</p>
</div>
</div>
);
};
3. State Management Design
Local state vs. global state decisions
For an Image Gallery component, local state management is appropriate for:
- Individual image loading states (pending, loaded, error)
- Current view mode (grid vs. list)
- Selected image state for modal viewing
- Pagination and infinite scroll tracking
Global state is appropriate for:
- User preferences (view mode, image size)
- Authenticated user's favorite images
- Cross-feature image selection state (for sharing/copying)
- Application-level loading indicators
State management approach
Zustand is selected as the primary state management solution because:
- Simplicity: Minimal boilerplate for complex state operations
- TypeScript native: Excellent type inference and support
- Flexible: Can easily integrate with React Query for server state
- Bundle size: ~1KB minified, negligible impact on bundle size
- Developer experience: Clear separation of concerns with easy debugging
State shape and normalization strategy
// Normalized state structure for image gallery
interface Image {
id: string;
url: string;
thumbnailUrl: string;
title: string;
description: string;
author: string;
uploadDate: Date;
width: number;
height: number;
tags: string[];
likes: number;
isFavorite: boolean;
}
interface GalleryState {
images: Record<string, Image>;
imageIds: string[];
viewMode: 'grid' | 'list';
selectedImageId: string | null;
currentPage: number;
totalPages: number;
isLoading: boolean;
error: string | null;
favorites: Set<string>;
searchTerm: string;
filters: {
tags: string[];
dateRange: { start?: Date; end?: Date };
};
}
Server state vs. client state separation
Server State:
- Image metadata (fetched from API)
- User favorites (fetched from backend)
- Pagination information
Client State:
- UI state (view mode, search term)
- Selected image for modal
- Local cache of loaded images
Cache invalidation strategy
- Time-based expiration: Images cached for 5 minutes
- User action invalidation: Favorites update immediately, trigger re-fetch of user's favorite list
- Search term invalidation: Clear cache when search changes
- Manual refresh: User-triggered refresh invalidates all local caches
Optimistic updates design
For favorite toggling:
- Immediately update UI state (toggle local
isFavorite) - Send API request to backend
- If successful, update server state and sync with local cache
- If failed, revert UI to previous state and show error
Critical-path hook implementation
// src/features/imageGallery/hooks/useImageGallery.ts
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
import { Image, GalleryState } from '../types';
interface ImageGalleryStore extends GalleryState {
actions: {
// Image loading and caching
loadImage: (imageId: string) => Promise<void>;
invalidateCache: () => void;
// UI state management
setViewMode: (mode: 'grid' | 'list') => void;
setSelectedImageId: (id: string | null) => void;
// Pagination and fetching
fetchImages: (page?: number, search?: string) => Promise<void>;
loadMoreImages: () => Promise<void>;
// Favorites
toggleFavorite: (imageId: string) => Promise<void>;
// Filters and search
setSearchTerm: (term: string) => void;
applyFilters: (filters: GalleryState['filters']) => void;
};
}
const useImageGalleryStore = create<ImageGalleryStore>()(
immer((set, get) => ({
// Initial state
images: {},
imageIds: [],
viewMode: 'grid',
selectedImageId: null,
currentPage: 1,
totalPages: 1,
isLoading: false,
error: null,
favorites: new Set(),
searchTerm: '',
filters: {
tags: [],
dateRange: {}
},
actions: {
// Image loading with caching
loadImage: async (imageId) => {
const state = get();
if (state.images[imageId]) return;
set((draft) => {
draft.isLoading = true;
draft.error = null;
});
try {
// Simulate API call with caching
const image = await fetchImageFromAPI(imageId);
set((draft) => {
draft.images[imageId] = image;
if (!draft.imageIds.includes(imageId)) {
draft.imageIds.push(imageId);
}
});
} catch (error) {
set((draft) => {
draft.error = `Failed to load image ${imageId}`;
});
} finally {
set((draft) => {
draft.isLoading = false;
});
}
},
// View mode management
setViewMode: (mode) => {
set((draft) => {
draft.viewMode = mode;
});
},
// Modal selection
setSelectedImageId: (id) => {
set((draft) => {
draft.selectedImageId = id;
});
},
// Image fetching with pagination
fetchImages: async (page = 1, search = '') => {
set((draft) => {
draft.isLoading = true;
draft.error = null;
});
try {
const response = await fetchImagesFromAPI({
page,
search,
filters: get().filters
});
set((draft) => {
draft.imageIds = response.imageIds;
draft.images = { ...draft.images, ...response.images };
draft.currentPage = page;
draft.totalPages = response.totalPages;
});
} catch (error) {
set((draft) => {
draft.error = 'Failed to fetch images';
});
} finally {
set((draft) => {
draft.isLoading = false;
});
}
},
// Infinite scroll loading
loadMoreImages: async () => {
const state = get();
if (state.currentPage >= state.totalPages) return;
set((draft) => {
draft.isLoading = true;
});
try {
const response = await fetchImagesFromAPI({
page: state.currentPage + 1,
search: state.searchTerm,
filters: state.filters
});
set((draft) => {
draft.imageIds = [...draft.imageIds, ...response.imageIds];
draft.images = { ...draft.images, ...response.images };
draft.currentPage = response.page;
});
} catch (error) {
set((draft) => {
draft.error = 'Failed to load more images';
});
} finally {
set((draft) => {
draft.isLoading = false;
});
}
},
// Favorite toggling with optimistic updates
toggleFavorite: async (imageId) => {
const state = get();
const isCurrentlyFavorite = state.favorites.has(imageId);
// Optimistic update
set((draft) => {
if (isCurrentlyFavorite) {
draft.favorites.delete(imageId);
} else {
draft.favorites.add(imageId);
}
// Update image favorite status if already loaded
if (draft.images[imageId]) {
draft.images[imageId].isFavorite = !isCurrentlyFavorite;
}
});
try {
// API call to sync with backend
await toggleFavoriteInAPI(imageId);
// If successful, update server state (already handled by optimistic update)
} catch (error) {
// Revert optimistic update on failure
set((draft) => {
if (isCurrentlyFavorite) {
draft.favorites.add(imageId);
} else {
draft.favorites.delete(imageId);
}
if (draft.images[imageId]) {
draft.images[imageId].isFavorite = isCurrentlyFavorite;
}
});
set((draft) => {
draft.error = 'Failed to update favorite status';
});
}
},
// Search term management
setSearchTerm: (term) => {
set((draft) => {
draft.searchTerm = term;
// Clear current results and fetch new ones
draft.imageIds = [];
draft.images = {};
});
// Debounced fetch
setTimeout(() => {
get().actions.fetchImages(1, term);
}, 300);
},
// Filter application
applyFilters: (filters) => {
set((draft) => {
draft.filters = filters;
// Reset and refetch with new filters
draft.imageIds = [];
draft.images = {};
});
get().actions.fetchImages(1);
},
// Cache invalidation
invalidateCache: () => {
set((draft) => {
draft.images = {};
draft.imageIds = [];
draft.currentPage = 1;
draft.totalPages = 1;
});
}
}
}))
);
// Custom hook for component usage
export const useImageGallery = () => {
const store = useImageGalleryStore();
return {
// State
images: store.images,
imageIds: store.imageIds,
viewMode: store.viewMode,
selectedImageId: store.selectedImageId,
currentPage: store.currentPage,
totalPages: store.totalPages,
isLoading: store.isLoading,
error: store.error,
favorites: store.favorites,
searchTerm: store.searchTerm,
// Actions
actions: store.actions
};
};
// Helper function for API interaction (simplified)
const fetchImageFromAPI = async (id: string): Promise<Image> => {
// Simulated API call
return new Promise((resolve) => {
setTimeout(() => {
resolve({
id,
url: `https://example.com/image/${id}.jpg`,
thumbnailUrl: `https://example.com/image/${id}-thumb.jpg`,
title: `Image ${id}`,
description: `Description for image ${id}`,
author: 'Author Name',
uploadDate: new Date(),
width: 800,
height: 600,
tags: ['nature', 'landscape'],
likes: Math.floor(Math.random() * 100),
isFavorite: false
});
}, 100);
});
};
const fetchImagesFromAPI = async (params: {
page: number;
search?: string;
filters: GalleryState['filters'];
}): Promise<{
imageIds: string[];
images: Record<string, Image>;
page: number;
totalPages: number;
}> => {
// Simulated API response
return new Promise((resolve) => {
setTimeout(() => {
const imageIds = Array.from({ length: 20 }, (_, i) => `img-${i + (params.page - 1) * 20}`);
const images = imageIds.reduce((acc, id) => {
acc[id] = fetchImageFromAPI(id);
return acc;
}, {} as Record<string, Image>);
resolve({
imageIds,
images,
page: params.page,
totalPages: Math.ceil(1000 / 20)
});
}, 200);
});
};
const toggleFavoriteInAPI = async (imageId: string): Promise<void> => {
// Simulated API call
return new Promise((resolve) => {
setTimeout(() => resolve(), 150);
});
};
4. Routing & Navigation
Route Structure and Hierarchy
The image gallery feature will be structured as a nested route under the /gallery path with the following hierarchy:
/gallery
├── /albums
│ ├── /:albumId
│ │ ├── /:imageId
│ │ └── /view
│ └── /all
├── /search
└── /upload
Protected routes include:
/gallery/albums/:albumId- Album view with image details/gallery/albums/:albumId/:imageId- Individual image modal view/gallery/albums/:albumId/view- Fullscreen image viewer/gallery/search- Search results view/gallery/upload- Image upload form
Public routes:
/gallery/albums/all- All images view (read-only)
Code Splitting Strategy
Route-based code splitting with React.lazy and Suspense:
// routes/GalleryRoutes.tsx
const GalleryLayout = React.lazy(() => import('components/gallery/Layout'));
const AlbumsView = React.lazy(() => import('components/gallery/AlbumsView'));
const ImageViewer = React.lazy(() => import('components/gallery/ImageViewer'));
const SearchView = React.lazy(() => import('components/gallery/SearchView'));
const UploadForm = React.lazy(() => import('components/gallery/UploadForm'));
// Route configuration with lazy loading
const galleryRoutes = [
{
path: '/gallery',
element: <GalleryLayout />,
children: [
{
index: true,
element: <Navigate to="/gallery/albums/all" replace />
},
{
path: 'albums',
element: <AlbumsView />,
children: [
{
path: 'all',
element: <AlbumsView />
},
{
path: ':albumId',
element: <AlbumsView />
},
{
path: ':albumId/:imageId',
element: <ImageViewer />
},
{
path: ':albumId/view',
element: <ImageViewer />
}
]
},
{
path: 'search',
element: <SearchView />
},
{
path: 'upload',
element: <UploadForm />,
handle: { requiresAuth: true }
}
]
}
];
Component-based code splitting for large subcomponents:
// components/gallery/ImageGrid.tsx
const ImageCard = React.lazy(() => import('./ImageCard'));
const InfiniteScrollLoader = React.lazy(() => import('./InfiniteScrollLoader'));
// components/gallery/ImageViewer.tsx
const ImageModal = React.lazy(() => import('./ImageModal'));
const ImageToolbar = React.lazy(() => import('./ImageToolbar'));
Navigation Guards and Authentication Flows
Authentication guard implementation:
// guards/AuthGuard.tsx
const AuthGuard: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { isAuthenticated, loading } = useAuth();
if (loading) return <LoadingSpinner />;
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return children;
};
// Route configuration with guard
const ProtectedRoute = ({
element,
requiresAuth = true
}: {
element: React.ReactNode;
requiresAuth?: boolean;
}) => (
<AuthGuard>
{element}
</AuthGuard>
);
// Usage in route config
{
path: 'upload',
element: <ProtectedRoute element={<UploadForm />} />,
handle: { requiresAuth: true }
}
Deep Linking Support and URL Parameter Design
URL structure designed for SEO and deep linking:
// URL patterns:
// /gallery/albums/all?sort=recent&filter=tag:landscape
// /gallery/albums/123?view=grid&limit=20&page=2
// /gallery/albums/123/456?mode=fullscreen&autoplay=true
// /gallery/search?q=mountain&category=nature&page=1
// URL parameter parsing utility
const useGalleryParams = () => {
const location = useLocation();
const searchParams = new URLSearchParams(location.search);
return {
albumId: searchParams.get('album') || null,
imageId: searchParams.get('image') || null,
viewMode: searchParams.get('view') || 'grid',
sort: searchParams.get('sort') || 'recent',
filter: searchParams.get('filter') || null,
page: parseInt(searchParams.get('page') || '1', 10),
limit: parseInt(searchParams.get('limit') || '20', 10)
};
};
Browser History Management and Scroll Restoration
// components/gallery/ScrollRestoration.tsx
const GalleryScrollRestoration: React.FC = () => {
const location = useLocation();
useEffect(() => {
// Restore scroll position when navigating back
const restoreScroll = () => {
const scrollPosition = sessionStorage.getItem(`scrollPosition_${location.key}`);
if (scrollPosition) {
window.scrollTo(0, parseInt(scrollPosition, 10));
}
};
// Save scroll position before navigation
const handleBeforeUnload = () => {
sessionStorage.setItem(`scrollPosition_${location.key}`, window.scrollY.toString());
};
window.addEventListener('beforeunload', handleBeforeUnload);
restoreScroll();
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
};
}, [location.key]);
return null;
};
// In main App component
const App: React.FC = () => {
return (
<BrowserRouter>
<GalleryScrollRestoration />
<Routes>
{galleryRoutes}
</Routes>
</BrowserRouter>
);
};
Navigation Strategy Implementation
// hooks/useGalleryNavigation.ts
export const useGalleryNavigation = () => {
const navigate = useNavigate();
const location = useLocation();
const goToAlbum = (albumId: string) => {
navigate(`/gallery/albums/${albumId}`);
};
const goToImage = (albumId: string, imageId: string) => {
navigate(`/gallery/albums/${albumId}/${imageId}`);
};
const goToSearch = (query: string) => {
navigate(`/gallery/search?q=${encodeURIComponent(query)}`);
};
const goToUpload = () => {
navigate('/gallery/upload');
};
const goBack = () => {
// Preserve scroll position when going back
navigate(-1);
};
const goToImageFullscreen = (albumId: string, imageId: string) => {
navigate(`/gallery/albums/${albumId}/view?image=${imageId}`);
};
return {
goToAlbum,
goToImage,
goToSearch,
goToUpload,
goBack,
goToImageFullscreen
};
};
Navigation UX Considerations
- Keyboard navigation support for all gallery views
- Back/forward button preservation of view state and scroll position
- Deep link validation to prevent invalid navigation states
- Progressive enhancement for users without JavaScript
- URL parameter normalization to prevent malformed URLs
The routing strategy prioritizes:
- User experience: Smooth transitions and preserved state
- Performance: Code splitting to minimize initial bundle size
- Accessibility: Keyboard navigation and proper semantic structure
- Maintainability: Clear component hierarchy and consistent URL patterns
5. API Integration Layer
API Client Design Pattern
We'll implement a centralized API client using Axios with request/response interceptors for consistent handling across the application. The client will be built as a singleton to ensure consistent configuration and error handling.
// api/client.ts
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
class ApiClient {
private client: AxiosInstance;
constructor() {
this.client = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_BASE_URL || 'https://api.example.com',
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
});
this.setupInterceptors();
}
private setupInterceptors() {
// Request interceptor for auth token injection
this.client.interceptors.request.use(
(config) => {
const token = localStorage.getItem('authToken');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// Response interceptor for error handling and normalization
this.client.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// Handle auth errors
localStorage.removeItem('authToken');
window.location.href = '/login';
}
return Promise.reject(error);
}
);
}
public get<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
return this.client.get<T>(url, config);
}
public post<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
return this.client.post<T>(url, data, config);
}
public put<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
return this.client.put<T>(url, data, config);
}
public delete<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
return this.client.delete<T>(url, config);
}
}
export const apiClient = new ApiClient();
Request/Response Interceptors
The interceptors handle:
- Authentication: Automatically injects auth tokens from localStorage
- Error Handling: Centralized error processing with specific handling for auth failures
- Response Normalization: Ensures consistent response structure across all API calls
Error Handling and Retry Strategy
We implement a retry mechanism with exponential backoff for transient network errors:
// api/utils.ts
export const retryWithBackoff = async <T>(
fn: () => Promise<T>,
retries = 3,
delay = 1000
): Promise<T> => {
for (let i = 0; i <= retries; i++) {
try {
return await fn();
} catch (error) {
if (i === retries) throw error;
// Exponential backoff with jitter
const waitTime = delay * Math.pow(2, i) + Math.random() * 1000;
await new Promise(resolve => setTimeout(resolve, waitTime));
}
}
throw new Error('Retry limit exceeded');
};
Loading, Error, and Empty States Design
We implement a consistent state management approach using custom hooks that provide loading, error, and data states:
// api/hooks/useImageGallery.ts
import { useState, useEffect } from 'react';
import { apiClient } from '../client';
interface Image {
id: string;
title: string;
url: string;
thumbnailUrl: string;
width: number;
height: number;
uploadedAt: string;
}
interface GalleryState {
images: Image[];
loading: boolean;
error: string | null;
hasMore: boolean;
page: number;
}
export const useImageGallery = (page = 1, limit = 20) => {
const [state, setState] = useState<GalleryState>({
images: [],
loading: false,
error: null,
hasMore: true,
page,
});
const fetchImages = async () => {
if (state.loading) return;
setState(prev => ({ ...prev, loading: true, error: null }));
try {
const response = await apiClient.get<{ images: Image[]; hasMore: boolean }>(
`/gallery?page=${page}&limit=${limit}`
);
setState(prev => ({
...prev,
images: prev.page === 1 ? response.images : [...prev.images, ...response.images],
hasMore: response.hasMore,
page: page,
loading: false,
}));
} catch (error) {
setState(prev => ({
...prev,
loading: false,
error: 'Failed to load images. Please try again.',
}));
}
};
useEffect(() => {
fetchImages();
}, [page]);
return {
...state,
refetch: fetchImages,
};
};
Data Fetching Strategy
For image gallery, we use Client-Side Rendering (CSR) with:
- Initial Load: Fetch first page of images on component mount
- Infinite Scroll: Load additional pages as user scrolls
- Caching: Use React Query for intelligent caching and invalidation
API Response Transformation Layer
We implement a DTO-to-domain model mapping layer:
// api/models/Image.ts
export interface ImageDto {
id: string;
title: string;
url: string;
thumbnail_url: string;
width: number;
height: number;
uploaded_at: string;
}
export interface Image {
id: string;
title: string;
url: string;
thumbnailUrl: string;
width: number;
height: number;
uploadedAt: Date;
}
export const transformImageDto = (dto: ImageDto): Image => ({
id: dto.id,
title: dto.title,
url: dto.url,
thumbnailUrl: dto.thumbnail_url,
width: dto.width,
height: dto.height,
uploadedAt: new Date(dto.uploaded_at),
});
Sequence Diagram for Primary Data Flow
sequenceDiagram
participant User
participant GalleryComponent
participant ImageService
participant ApiClient
participant Server
User->>GalleryComponent: Component mounts
GalleryComponent->>ImageService: fetchImages(page=1)
ImageService->>ApiClient: GET /gallery?page=1&limit=20
ApiClient->>Server: Request
Server-->>ApiClient: Response with images
ApiClient-->>ImageService: Transformed image data
ImageService-->>GalleryComponent: Updated state with images
GalleryComponent->>User: Render gallery with images
The API integration layer provides a robust foundation for image gallery functionality with proper error handling, retry logic, and consistent data transformation patterns that support the overall component architecture and performance requirements.
6. Performance Design
Core Web Vitals Targets
- Largest Contentful Paint (LCP): < 2.5s for all image gallery routes
- Interaction to Next Paint (INP): < 200ms for user interactions (clicks, keyboard navigation)
- Cumulative Layout Shift (CLS): < 0.1 for all gallery layouts and transitions
Bundle Size Budget per Route
- Image Gallery Main Route: < 150KB (excluding image assets)
- Image Detail Modal Route: < 80KB additional bundle size
- Lazy-loaded Image Components: < 25KB total for all image loading utilities
Critical Rendering Path Optimization
- Critical CSS: Inline minimal gallery layout styles (max 5KB)
- CSS Loading Strategy:
- Critical layout styles inlined in HTML head
- Non-critical gallery styling loaded asynchronously via CSS-in-JS or dynamic imports
- JavaScript Loading:
- Core gallery logic (image loading, keyboard navigation) loaded eagerly
- Non-critical features (e.g., image editing tools) loaded on demand
Image Optimization Strategy
// Image optimization configuration
const imageConfig = {
formats: ['webp', 'jpeg'],
sizes: [320, 480, 640, 768, 1024, 1280, 1600],
quality: 75,
blurUp: true,
placeholder: 'blur'
};
- Responsive Images:
- Use
srcSetwith multiple image sizes - Implement
sizesattribute for proper viewport targeting
- Use
- Lazy Loading:
- Default to
loading="lazy"attribute on all image elements - Custom intersection observer for pre-loading images near viewport
- Default to
- Image Format Optimization:
- Prefer WebP format where supported (90% smaller than JPEG)
- Fallback to JPEG for older browsers
Font Loading Strategy
- Critical Fonts:
- Load minimal gallery font subset (e.g., 400, 500 weights) via
font-display: swap
- Load minimal gallery font subset (e.g., 400, 500 weights) via
- Non-Critical Fonts:
- Load additional font weights asynchronously after initial render
- Font Preloading:
- Use
preloadfor critical font files in HTML head
- Use
Memoization Strategy
// Example memoization patterns for performance
const ImageCard = React.memo(({ image, onClick }: ImageCardProps) => {
const handleClick = useCallback(() => {
onClick(image);
}, [image, onClick]);
return (
<div
className="image-card"
onClick={handleClick}
role="button"
tabIndex={0}
>
<img
src={image.thumbnailUrl}
alt={image.altText}
loading="lazy"
width={image.width}
height={image.height}
/>
</div>
);
});
const GalleryGrid = React.memo(({ images, onImageClick }: GalleryProps) => {
const gridItems = useMemo(() =>
images.map(image => (
<ImageCard
key={image.id}
image={image}
onClick={onImageClick}
/>
)), [images, onImageClick]
);
return (
<div className="gallery-grid">
{gridItems}
</div>
);
});
- React.memo: Apply to image cards and grid components with stable props
- useCallback: Memoize event handlers that depend on image data
- useMemo: Cache computed values like filtered images or layout calculations
- Avoid over-memoization: Don't memoize props that change frequently
Virtualization for Large Lists
// Virtualized image gallery implementation
import { FixedSizeList as List } from 'react-window';
const VirtualizedImageGallery = ({ images, onImageClick }: VirtualizedGalleryProps) => {
const Row = ({ index, style }: { index: number; style: React.CSSProperties }) => (
<div style={style} className="virtualized-image-row">
<ImageCard
image={images[index]}
onClick={onImageClick}
/>
</div>
);
return (
<List
height={600}
itemCount={images.length}
itemSize={200}
width="100%"
>
{Row}
</List>
);
};
- react-window: Use for large image collections (>100 items)
- Virtualized rendering: Only render visible items + buffer
- Buffer size: 50% of viewport height to prevent flickering during scroll
Prefetching and Preloading Strategy
// Prefetching implementation for gallery navigation
const ImageGallery = ({ images, currentImageIndex }: GalleryProps) => {
const [prefetchImages, setPrefetchImages] = useState<Image[]>([]);
useEffect(() => {
// Prefetch next 3 images
const nextImages = images.slice(
currentImageIndex + 1,
currentImageIndex + 4
);
setPrefetchImages(nextImages);
// Preload next image for smoother transitions
const preloadNext = new Image();
preloadNext.src = nextImages[0]?.fullImageUrl || '';
}, [currentImageIndex, images]);
return (
<div className="image-gallery">
{prefetchImages.map(image => (
<img
key={image.id}
src={image.thumbnailUrl}
style={{ display: 'none' }}
alt=""
/>
))}
</div>
);
};
- Prefetch Next Images: Load 3-5 images ahead of current view
- Preload Full-Size Images: When user clicks thumbnail, preload full-size image
- Preconnect to Image Domains: Use
<link rel="preconnect">for image CDN domains - Priority Preloading: Use
preloadfor images in viewport or likely to enter viewport soon
7. Error Handling & Resilience
Error Boundary Placement Strategy
Error boundaries should be strategically placed to prevent cascading failures while maintaining graceful degradation. For an image gallery, we'll implement a hierarchical error boundary structure:
graph TD
A[GalleryContainer] --> B[ImageGrid]
B --> C[ImageCard]
C --> D[ImageLoader]
A --> E[GalleryErrorBoundary]
B --> F[GridErrorBoundary]
C --> G[CardErrorBoundary]
The GalleryErrorBoundary will catch any unhandled errors in the entire gallery, while GridErrorBoundary handles grid-level issues and CardErrorBoundary manages individual image card failures.
Global Error Handler Design
A centralized error handler will capture unhandled errors and provide appropriate user feedback:
// ErrorHandlerContext.tsx
import React, { createContext, useContext, useState, useCallback } from 'react';
interface ErrorHandlerContextType {
handleError: (error: Error, errorInfo?: string) => void;
clearError: () => void;
hasError: boolean;
}
const ErrorHandlerContext = createContext<ErrorHandlerContextType | undefined>(undefined);
export const useErrorHandler = () => {
const context = useContext(ErrorHandlerContext);
if (!context) {
throw new Error('useErrorHandler must be used within ErrorHandlerProvider');
}
return context;
};
export const ErrorHandlerProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [error, setError] = useState<Error | null>(null);
const [errorInfo, setErrorInfo] = useState<string | null>(null);
const handleError = useCallback((error: Error, errorInfo?: string) => {
console.error('Gallery Error:', error);
setError(error);
setErrorInfo(errorInfo || null);
// Report to monitoring service
if (window.Sentry) {
window.Sentry.captureException(error, { contexts: { errorInfo } });
}
}, []);
const clearError = useCallback(() => {
setError(null);
setErrorInfo(null);
}, []);
return (
<ErrorHandlerContext.Provider value={{ handleError, clearError, hasError: !!error }}>
{children}
</ErrorHandlerContext.Provider>
);
};
Offline / Network Failure Handling
The gallery must gracefully handle network failures:
// useNetworkStatus.ts
import { useState, useEffect } from 'react';
export const useNetworkStatus = () => {
const [isOnline, setIsOnline] = useState<boolean>(navigator.onLine);
useEffect(() => {
const handleOnline = () => setIsOnline(true);
const handleOffline = () => setIsOnline(false);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
return isOnline;
};
// ImageCard.tsx
const ImageCard: React.FC<ImageCardProps> = ({ image, onImageLoad }) => {
const [isLoaded, setIsLoaded] = useState(false);
const [hasError, setHasError] = useState(false);
const networkStatus = useNetworkStatus();
const handleImageLoad = () => {
setIsLoaded(true);
onImageLoad?.();
};
const handleImageError = () => {
setHasError(true);
};
if (hasError) {
return (
<div className="image-card-error">
<span className="error-icon">⚠️</span>
<p>Failed to load image</p>
</div>
);
}
if (!isLoaded && !networkStatus) {
return (
<div className="image-card-offline">
<span className="offline-icon">📶</span>
<p>Offline - retry when online</p>
</div>
);
}
return (
<div className="image-card">
<img
src={image.url}
alt={image.alt}
onLoad={handleImageLoad}
onError={handleImageError}
className={isLoaded ? "loaded" : "loading"}
/>
</div>
);
};
Graceful Degradation Patterns
When images fail to load or network is unavailable, the gallery should degrade gracefully:
- Image Loading Failures: Show placeholder with error message
- Network Failures: Display offline indicator and retry mechanism
- Memory Issues: Implement image virtualization to prevent OOM errors
- Browser Compatibility: Fallback to simpler layouts for older browsers
User Feedback on Errors
// ToastManager.tsx
import React from 'react';
import { ToastContainer, toast } from 'react-toastify';
export const ToastManager: React.FC = () => {
return (
<ToastContainer
position="bottom-right"
autoClose={5000}
hideProgressBar={false}
newestOnTop={false}
closeOnClick
rtl={false}
pauseOnFocusLoss
draggable
pauseOnHover
/>
);
};
// ErrorToast.tsx
export const showErrorToast = (message: string, error?: Error) => {
toast.error(message, {
toastId: 'gallery-error',
autoClose: 8000,
onClose: () => {
// Optionally log error to monitoring service
if (error && window.Sentry) {
window.Sentry.captureException(error);
}
}
});
};
// ImageCard.tsx - Error handling integration
const ImageCard: React.FC<ImageCardProps> = ({ image, onImageLoad }) => {
const [isLoaded, setIsLoaded] = useState(false);
const [hasError, setHasError] = useState(false);
const { handleError } = useErrorHandler();
const handleImageLoad = () => {
setIsLoaded(true);
onImageLoad?.();
};
const handleImageError = () => {
setHasError(true);
handleError(new Error(`Failed to load image: ${image.url}`), `Image URL: ${image.url}`);
showErrorToast(`Failed to load image: ${image.alt || 'Untitled'}`);
};
// ... rest of component logic
};
Error Boundary Implementation
// GalleryErrorBoundary.tsx
class GalleryErrorBoundary extends React.Component<
{ children: React.ReactNode },
{ hasError: boolean; error?: Error }
> {
constructor(props: { children: React.ReactNode }) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error) {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('Gallery Error Boundary caught:', error, errorInfo);
// Report to monitoring service
if (window.Sentry) {
window.Sentry.captureException(error, { contexts: { errorInfo } });
}
}
render() {
if (this.state.hasError) {
return (
<div className="gallery-error-container">
<h2>Something went wrong</h2>
<p>We're sorry, but we couldn't display the gallery at this time.</p>
<button onClick={() => window.location.reload()}>
Reload Page
</button>
</div>
);
}
return this.props.children;
}
}
Retry Mechanism Strategy
Implement exponential backoff retry for failed image loads:
// useImageRetry.ts
import { useState, useEffect } from 'react';
export const useImageRetry = (src: string, maxRetries = 3) => {
const [retryCount, setRetryCount] = useState(0);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
if (retryCount > maxRetries) {
setError(new Error(`Failed to load image after ${maxRetries} attempts`));
setIsLoading(false);
return;
}
const img = new Image();
const handleLoad = () => {
setIsLoading(false);
};
const handleError = () => {
if (retryCount < maxRetries) {
setTimeout(() => {
setRetryCount(prev => prev + 1);
}, Math.pow(2, retryCount) * 1000); // Exponential backoff
} else {
setError(new Error(`Failed to load image: ${src}`));
setIsLoading(false);
}
};
img.addEventListener('load', handleLoad);
img.addEventListener('error', handleError);
img.src = src;
return () => {
img.removeEventListener('load', handleLoad);
img.removeEventListener('error', handleError);
};
}, [src, retryCount, maxRetries]);
return { isLoading, error, retryCount };
};
8. Testing Strategy
Unit Testing Approach
Component Tests (70% of test coverage):
- Test all visual states: loading, loaded, error, empty
- Test prop combinations and edge cases (empty array, single image, 100 images)
- Test keyboard navigation and focus behavior
- Test responsive breakpoints and layout changes
- Test image loading states with IntersectionObserver mock
Utility Tests (20% of test coverage):
- Test image URL generation helpers
- Test responsive image size calculation logic
- Test accessibility attribute generation
- Test error boundary error message formatting
Test Coverage Targets:
- Components: 85% line coverage, 90% branch coverage
- Utilities: 95% line coverage
- Critical paths: 100% test coverage
Integration Testing Strategy
Image Gallery Integration Tests:
- Test image loading sequence with mock API responses
- Test infinite scroll behavior and loading states
- Test responsive layout changes during image load
- Test error recovery flow from network failure to retry
State Management Integration:
- Test state transitions in image loading lifecycle
- Test optimistic updates with mock server responses
- Test cache invalidation and refetch behavior
E2E Testing Scope
Cypress Test Coverage:
- User flows: image selection, modal view, download
- Responsive behavior across device sizes (mobile, tablet, desktop)
- Keyboard navigation accessibility testing
- Performance regression tests (LCP, FCP targets)
Critical User Journeys:
- Gallery load → image click → modal view → close
- Infinite scroll to end → loading states → new images
- Network error recovery → retry functionality
- Keyboard navigation through gallery items
Visual Regression Testing
Tool: Percy (or Chromatic)
- Test all major breakpoints (mobile, tablet, desktop)
- Test loading states and error states
- Test modal overlay behavior
- Test responsive grid layouts
Accessibility Testing Automation
Axe-core Integration:
- Run axe-core on all gallery states (loaded, loading, error)
- Test keyboard navigation flow
- Validate ARIA attributes and roles
- Check color contrast compliance
Test Data Strategy
Fixtures:
// test/fixtures/imageGallery.ts
export const mockImages = [
{
id: '1',
url: 'https://example.com/image1.jpg',
alt: 'Beautiful landscape',
width: 800,
height: 600,
thumbnailUrl: 'https://example.com/thumb1.jpg'
},
{
id: '2',
url: 'https://example.com/image2.jpg',
alt: 'Mountain view',
width: 1024,
height: 768,
thumbnailUrl: 'https://example.com/thumb2.jpg'
}
];
export const mockErrorState = {
error: 'Failed to load images',
retryable: true
};
Mock API Setup:
- Use MSW (Mock Service Worker) for realistic network mocking
- Mock different response scenarios: success, timeout, 404, 500
- Test loading states with realistic delay simulation
Test Environment Configuration:
// jest.config.ts
export default {
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/src/test/setup.ts'],
moduleNameMapper: {
'\\.svg$': '<rootDir>/src/test/__mocks__/svgMock.ts'
},
coverageThreshold: {
'src/components/ImageGallery': {
lines: 85,
branches: 80
}
}
};
9. Accessibility (A11y)
WCAG 2.1 AA Compliance Checklist
The Image Gallery component must meet all WCAG 2.1 AA success criteria:
- 1.1.1 Non-text Content (Level A): All images must have meaningful
alttext or be marked as decorative - 1.3.1 Info and Relationships (Level A): Semantic HTML structure must be preserved for image grid layout
- 1.4.1 Use of Color (Level A): Color contrast ratios must meet 4.5:1 for text and 3:1 for interactive elements
- 1.4.3 Contrast (Minimum) (Level AA): Text and images must have sufficient contrast ratios
- 2.1.1 Keyboard (Level A): All functionality must be operable via keyboard navigation
- 2.4.1 Bypass Blocks (Level A): Skip links must be available for keyboard users
- 2.4.3 Focus Order (Level A): Visual focus order must match logical reading order
- 2.4.6 Headings and Labels (Level AA): All interactive elements must have descriptive labels
- 3.1.1 Language of Page (Level A): Language attribute must be set on the root element
- 4.1.2 Name, Role, Value (Level AA): ARIA attributes must be valid and properly implemented
Keyboard Navigation Support
The gallery supports full keyboard navigation:
- Tab Navigation: Moves through image thumbnails and interactive elements in logical order
- Arrow Keys: Navigate between images in the gallery view (left/right arrows)
- Enter/Space: Activate image preview modal or selection
- Escape: Close modal view and return to gallery
- Home/End: Jump to first/last image in the gallery
Focus Management Strategy
Focus Trapping: When modal is open, focus is trapped within the modal boundaries:
const handleModalFocus = (event: KeyboardEvent) => {
if (event.key === 'Tab') {
const focusableElements = modalRef.current?.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
) as NodeListOf<HTMLElement>;
if (focusableElements.length === 0) return;
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (event.shiftKey && document.activeElement === firstElement) {
lastElement.focus();
event.preventDefault();
} else if (!event.shiftKey && document.activeElement === lastElement) {
firstElement.focus();
event.preventDefault();
}
}
};
Focus Restoration: When modal closes, focus returns to the previously focused element:
const restoreFocus = () => {
if (previousActiveElement) {
previousActiveElement.focus();
}
};
ARIA Roles and Attributes Plan
// Gallery container
<div
role="region"
aria-label="Image gallery"
tabIndex={0}
>
// Image thumbnail
<li
role="listitem"
aria-label={`Image ${index + 1} of ${images.length}`}
>
<img
src={image.url}
alt={image.altText || `Gallery image ${index + 1}`}
role="button"
tabIndex={0}
aria-describedby={`image-description-${index}`}
onClick={() => openModal(index)}
/>
</li>
// Modal
<div
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
aria-describedby="modal-description"
>
<h2 id="modal-title">Image Preview</h2>
<img
src={currentImage.url}
alt={currentImage.altText}
role="img"
aria-label={`Full size view of ${currentImage.altText}`}
/>
</div>
Screen Reader Compatibility
- Image Descriptions: Each image includes descriptive alt text or
aria-describedbyreferences - Progress Indicators: Screen readers announce current position in gallery (e.g., "Image 3 of 12")
- Keyboard Instructions: Clear announcements when keyboard navigation is active
- Dynamic Content Updates: When images load or modal opens, screen readers are notified of content changes
Additional ARIA Considerations
- Live Regions: For loading states and progress updates
<span
role="status"
aria-live="polite"
className="sr-only"
>
Loading image {currentIndex + 1} of {imageCount}
</span>
- Landmark Roles: Proper semantic structure with
main,sectionandnavelements - Skip Links: Hidden navigation links to bypass gallery content for keyboard users
Keyboard Navigation Flow
- User tabs through gallery thumbnails
- Press Enter or Space to open image preview modal
- Use arrow keys to navigate between images in modal
- Press Escape to close modal and return focus to gallery
- Use Home/End keys to jump to first/last image in gallery
Focus Order Validation
The component ensures that focus order follows logical reading flow:
- Gallery navigation controls (if present)
- Image thumbnails in sequence
- Modal close button
- Navigation buttons within modal (previous/next)
- Image description elements
Dynamic Content Updates
When images are loaded or modal transitions occur, the component uses ARIA live regions to announce changes:
const updateScreenReaderStatus = (message: string) => {
setLiveRegionMessage(message);
// Clear after short delay to prevent repeated announcements
setTimeout(() => setLiveRegionMessage(''), 1000);
};
This ensures screen reader users are always informed of current state changes without disrupting their navigation flow.
10. Security Considerations
XSS Prevention Strategy
The image gallery must prevent cross-site scripting attacks through comprehensive input sanitization and output encoding:
// Secure image URL validation and sanitization
const validateImageUrl = (url: string): boolean => {
try {
const parsedUrl = new URL(url);
// Allow only HTTPS images from trusted domains
const allowedDomains = ['images.unsplash.com', 'picsum.photos', 'source.unsplash.com'];
return parsedUrl.protocol === 'https:' && allowedDomains.includes(parsedUrl.hostname);
} catch (e) {
return false;
}
};
// Sanitize image metadata to prevent script injection
const sanitizeImageMetadata = (metadata: ImageMetadata): ImageMetadata => {
return {
...metadata,
title: sanitizeHtml(metadata.title),
description: sanitizeHtml(metadata.description),
altText: sanitizeHtml(metadata.altText)
};
};
CSRF Protection
For any image upload or metadata modification functionality, implement CSRF token validation:
// CSRF token management in gallery context
const useCSRFToken = () => {
const [csrfToken, setCsrfToken] = useState<string | null>(null);
useEffect(() => {
// Fetch CSRF token from server on component mount
fetch('/api/csrf-token')
.then(res => res.json())
.then(data => setCsrfToken(data.token));
}, []);
return csrfToken;
};
Content Security Policy (CSP) Headers
Implement CSP headers to restrict image sources and prevent inline script execution:
Content-Security-Policy:
default-src 'self';
img-src 'self' https://images.unsplash.com https://picsum.photos data:;
script-src 'self' 'unsafe-inline';
style-src 'self' 'unsafe-inline';
Sensitive Data Handling in Frontend State
Image metadata and user data should never be stored in insecure client-side storage:
// Secure state management for sensitive image data
interface SecureImageState {
// Never store sensitive metadata in localStorage/sessionStorage
images: ImageItem[]; // Only non-sensitive data
currentUser: User | null;
}
// Secure image data handling
const handleImageUpload = async (file: File) => {
// Validate file type and size server-side
if (!file.type.startsWith('image/')) {
throw new Error('Invalid file type');
}
// Upload to secure server endpoint with proper validation
const formData = new FormData();
formData.append('image', file);
const response = await fetch('/api/upload-image', {
method: 'POST',
body: formData,
headers: {
// Include CSRF token if needed
'X-CSRF-Token': getCSRFToken()
}
});
return response.json();
};
Input Sanitization Approach
All user-provided image data must undergo strict sanitization:
// Comprehensive input sanitization for image gallery
const sanitizeInput = (input: string): string => {
// Remove potentially dangerous characters
return input
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
};
// Validate image metadata fields
const validateImageMetadata = (metadata: Partial<ImageMetadata>): boolean => {
const { title, altText, description } = metadata;
// Check for malicious content in text fields
if (title && sanitizeInput(title) !== title) return false;
if (altText && sanitizeInput(altText) !== altText) return false;
if (description && sanitizeInput(description) !== description) return false;
// Validate URL format
if (metadata.url && !validateImageUrl(metadata.url)) return false;
return true;
};
Security Headers Implementation
Implement security headers at the application level:
// Security headers configuration
const securityHeaders = {
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
'X-XSS-Protection': '1; mode=block',
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
'Content-Security-Policy': "img-src 'self' https: data: blob:; default-src 'none'"
};
// Apply security headers in middleware or server config
Image Source Validation
Implement strict source validation to prevent loading images from untrusted sources:
// Image source validation middleware
const validateImageSource = (src: string): boolean => {
// Reject any URLs that don't match expected patterns
const trustedPatterns = [
/^https:\/\/images\.unsplash\.com\/.*$/,
/^https:\/\/picsum\.photos\/.*$/,
/^data:image\/.*;base64,/
];
return trustedPatterns.some(pattern => pattern.test(src));
};
// Secure image rendering component
const SecureImageRenderer = ({ src, alt }: { src: string; alt: string }) => {
if (!validateImageSource(src)) {
console.warn('Blocked potentially unsafe image source:', src);
return <div className="error">Image source not allowed</div>;
}
return <img src={src} alt={alt} loading="lazy" />;
};
Authentication & Authorization
Ensure only authorized users can modify image metadata:
// Authenticated image operations
const updateImageMetadata = async (imageId: string, metadata: ImageMetadata) => {
// Verify user has permission to modify this image
const hasPermission = await checkUserPermission(imageId);
if (!hasPermission) {
throw new Error('Unauthorized to modify image');
}
// Validate and sanitize metadata before update
const sanitizedMetadata = sanitizeImageMetadata(metadata);
return fetch(`/api/images/${imageId}/metadata`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${getAuthToken()}`
},
body: JSON.stringify(sanitizedMetadata)
});
};
Secure File Upload Handling
Implement secure image upload with proper validation and sanitization:
// Secure file upload handler
const handleSecureImageUpload = async (file: File) => {
// Validate file size (max 10MB)
if (file.size > 10 * 1024 * 1024) {
throw new Error('File too large');
}
// Validate file type
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if (!allowedTypes.includes(file.type)) {
throw new Error('Unsupported file type');
}
// Sanitize filename
const sanitizedFilename = file.name.replace(/[^a-zA-Z0-9._-]/g, '');
// Upload to secure storage with proper validation
const formData = new FormData();
formData.append('file', file);
formData.append('filename', sanitizedFilename);
const response = await fetch('/api/secure-upload', {
method: 'POST',
body: formData,
headers: {
// Include security tokens and validation headers
'X-Client-Version': getClientVersion(),
'X-Request-ID': generateRequestId()
}
});
return response.json();
};
11. Internationalization (i18n)
Translation Key Structure and Naming Convention
Following BEM-style naming with feature-scoped keys:
// Translation keys for image gallery feature
{
"imageGallery": {
"title": "Image Gallery",
"loading": "Loading images...",
"noImages": "No images found",
"filterBy": "Filter by",
"sortBy": "Sort by",
"viewAll": "View All",
"viewGrid": "Grid View",
"viewList": "List View",
"imageCount": "{{count}} images",
"imageDetails": "Image details",
"close": "Close",
"download": "Download",
"share": "Share"
}
}
RTL Support Considerations
RTL support requires:
- Layout direction adjustments (flexbox, grid, text alignment)
- Icon rotation for directional indicators (chevrons, arrows)
- Navigation flow reversal in multi-step interactions
- Component alignment and spacing adjustments
Date, Number, Currency Formatting Strategy
Date formatting uses Intl.DateTimeFormat with locale-aware fallbacks:
// Example date formatting in i18n context
const formatDate = (date: Date, locale: string) => {
return new Intl.DateTimeFormat(locale, {
year: 'numeric',
month: 'long',
day: 'numeric'
}).format(date);
};
Locale Detection and Switching Strategy
Automatic locale detection via:
- Browser language preference (
navigator.language) - User preference in settings (persisted in localStorage)
- URL parameter override (
?lang=fr-FR)
Locale switching implementation:
// Simple locale context for gallery
interface LocaleContext {
currentLocale: string;
supportedLocales: string[];
switchLocale: (newLocale: string) => void;
}
const useLocale = () => {
const [currentLocale, setCurrentLocale] = useState<string>(
detectUserLocale() || 'en-US'
);
const switchLocale = (newLocale: string) => {
setCurrentLocale(newLocale);
localStorage.setItem('preferredLocale', newLocale);
};
return { currentLocale, switchLocale };
};
Translation File Organization
Translation files organized by locale:
/src/locales/
├── en-US.json
├── fr-FR.json
├── ar-SA.json
└── es-ES.json
i18n Integration with Component
Using react-i18next for seamless integration:
// Gallery component using i18n
const ImageGallery = () => {
const { t, i18n } = useTranslation('imageGallery');
return (
<div className="gallery" dir={i18n.dir()}>
<h2>{t('title')}</h2>
<p>{t('imageCount', { count: imageCount })}</p>
</div>
);
};
Dynamic Text Direction Handling
Component-level direction handling:
// Direction-aware gallery container
const GalleryContainer = ({ children }: { children: ReactNode }) => {
const { i18n } = useTranslation();
return (
<div
className="gallery-container"
dir={i18n.dir()}
style={{
direction: i18n.dir(),
textAlign: i18n.dir() === 'rtl' ? 'right' : 'left'
}}
>
{children}
</div>
);
};
Testing i18n Implementation
Test coverage includes:
- Translation key existence validation
- RTL layout verification in component tests
- Locale switching behavior testing
- Date/time formatting consistency across locales
Performance Considerations
Translation bundles are loaded on-demand per locale:
- Default bundle includes English (en-US)
- Additional bundles fetched via dynamic imports
- Fallback mechanism for missing translations
Accessibility and i18n Synergy
ARIA labels and screen reader support:
// Accessible translation keys with ARIA integration
const ImageCard = ({ image, locale }: { image: Image, locale: string }) => {
const { t } = useTranslation('imageGallery');
return (
<div
role="article"
aria-label={t('imageDetails')}
lang={locale}
>
<img
src={image.url}
alt={image.altText}
aria-describedby={`desc-${image.id}`}
/>
<p id={`desc-${image.id}`}>
{t('imageDetails')}
</p>
</div>
);
};
12. Monitoring & Observability
Real User Monitoring (RUM) Metrics to Track
The image gallery component should track key performance indicators to understand user experience:
- Largest Contentful Paint (LCP): Target <2.5s for image loading completion
- First Input Delay (FID): Target <100ms for user interactions with gallery controls
- Cumulative Layout Shift (CLS): Target <0.1 for layout stability during image loading
- Time to First Byte (TTFB): Track average response times from image API calls
- Image Load Success Rate: Percentage of images that load successfully vs. failed
- Gallery Render Time: Time from component mount to full gallery display
Error Tracking Integration
Integrate with Sentry or similar error monitoring service:
// Error tracking integration example
import * as Sentry from '@sentry/react';
const handleError = (error: Error, context: string) => {
Sentry.captureException(error, {
contexts: {
gallery: { context, timestamp: Date.now() }
}
});
};
Custom Events and User Journey Tracking
Implement custom event tracking for key user interactions:
// Event tracking implementation
const trackGalleryEvent = (event: string, properties?: Record<string, any>) => {
if (typeof window !== 'undefined' && window.gtag) {
window.gtag('event', event, {
...properties,
timestamp: Date.now(),
galleryId: 'image-gallery-123'
});
}
};
Events to track:
gallery_image_loaded: When individual image successfully loadsgallery_image_failed: When image fails to load with retry attemptsgallery_scroll_to_end: When user reaches end of gallery (for infinite scroll)gallery_image_clicked: When user clicks on an image for detail viewgallery_image_zoomed: When user zooms into an image (if feature exists)
Performance Monitoring and Regression Detection
Set up performance baselines for key metrics:
// Performance monitoring setup
const PERFORMANCE_THRESHOLDS = {
lcp: 2500, // 2.5 seconds
fid: 100, // 100ms
cls: 0.1 // 0.1 layout shift
};
const monitorPerformance = () => {
if (typeof window !== 'undefined') {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.name === 'largest-contentful-paint') {
const lcpValue = entry.startTime;
if (lcpValue > PERFORMANCE_THRESHOLDS.lcp) {
// Log performance regression
console.warn('LCP performance degradation detected', { lcpValue });
}
}
}
});
observer.observe({ entryTypes: ['largest-contentful-paint'] });
}
};
Health Checks and Alerting
Implement health checks for the gallery's core functionality:
- Image API Response Time: Alert if average response time exceeds 3 seconds
- Image Load Success Rate: Alert if <95% of images load successfully
- Gallery Initialization Time: Alert if gallery takes >5 seconds to render
- Memory Usage: Monitor for memory leaks in image loading components
Data Collection Strategy
All monitoring data should be collected with:
- User session IDs for correlation
- Gallery ID and configuration parameters
- Device characteristics (screen size, browser type)
- Network conditions (if available via browser APIs)
Dashboard and Alerting Configuration
Create dashboards in monitoring tools with:
- Real-time performance metrics
- Error rate trends over time
- User engagement patterns
- Image loading success/failure rates by region or device type
Privacy Considerations
Ensure all monitoring respects user privacy:
- Anonymize user session IDs
- Exclude sensitive data from error reports
- Comply with GDPR and CCPA requirements for tracking
- Provide opt-out mechanisms where required
Integration with Existing Monitoring Stack
The gallery monitoring should integrate with existing observability infrastructure:
- Use existing logging frameworks (Winston, Bunyan)
- Follow established error reporting patterns
- Align with existing performance monitoring tools (New Relic, Datadog)
- Support existing alerting systems and Slack integrations
Metrics Export Strategy
Implement metrics export for external monitoring:
// Sample metrics export function
const exportGalleryMetrics = () => {
const metrics = {
timestamp: Date.now(),
imageCount: imageState.images.length,
loadSuccessRate: imageState.successCount / imageState.totalCount,
avgLoadTime: imageState.avgLoadTime,
userInteractions: interactionTracker.getEvents()
};
// Export to monitoring service or console
console.log('GalleryMetrics:', JSON.stringify(metrics));
};
13. File & Folder Structure
Proposed Directory Structure
src/
├── components/
│ ├── ImageGallery/
│ │ ├── index.ts
│ │ ├── ImageGallery.tsx
│ │ ├── ImageItem.tsx
│ │ ├── ImageGrid.tsx
│ │ ├── ImageModal.tsx
│ │ └── hooks/
│ │ └── useImageGallery.ts
├── features/
│ └── image-gallery/
│ ├── index.ts
│ ├── ImageGalleryPage.tsx
│ └── routes/
│ └── ImageGalleryRoute.tsx
├── hooks/
│ └── useImagePreload.ts
├── types/
│ └── image-gallery.ts
└── utils/
└── image-utils.ts
Module Boundaries and Import Rules
- Feature isolation: ImageGallery is self-contained within
features/image-gallerywith its own route and page component - Shared components: Reusable UI elements like
ImageItemandImageModalare incomponents/ImageGallery - No cross-feature imports: Components in
ImageGallerydon't import from other feature directories directly - Barrel exports: All components in
components/ImageGalleryexport through a singleindex.tsfor clean imports
Shared vs Feature-Specific Code Separation
Shared code:
ImageItemandImageModalare generic enough to potentially be reused in other image-based features- Utility functions like
image-utils.tsfor common transformations - Custom hooks like
useImagePreload.tsthat can be reused across features
Feature-specific code:
ImageGalleryPage.tsxand route configuration are specific to this feature's user journeyuseImageGallery.tshook encapsulates gallery-specific business logic and state management
This structure follows a feature-based organization that scales well for larger teams while maintaining clear boundaries between features and shared components. The approach balances code reuse with feature encapsulation, making it easy to extract shared components later if needed.
14. Dependencies & Third-Party Libraries
Key Libraries Selected with Justification
1. React (v18+)
- Justification: Core framework for building UI components with hooks, context, and concurrent rendering capabilities. Essential for modern React development patterns.
- Bundle size impact: ~150KB (minified + gzip)
- Alternatives considered: Preact - smaller bundle but less ecosystem support; rejected due to reduced developer experience and tooling compatibility.
2. React Query (v5+)
- Justification: Production-ready data fetching and state management solution that handles caching, background updates, and optimistic UI patterns. Ideal for image gallery's data loading and pagination needs.
- Bundle size impact: ~20KB (minified + gzip)
- Alternatives considered: Redux Toolkit - more boilerplate for simple data fetching; Context API + custom hooks - requires significant implementation effort for caching and retry logic.
3. React Window (v1.8+)
- Justification: Efficient virtualization library for rendering large image collections without performance degradation. Critical for handling thousands of images.
- Bundle size impact: ~15KB (minified + gzip)
- Alternatives considered: react-virtual - similar feature set but less mature; custom virtualization - high maintenance cost.
4. Axios (v1.6+)
- Justification: Robust HTTP client with request/response interceptors, cancelation support, and better error handling than native fetch. Essential for API integration.
- Bundle size impact: ~20KB (minified + gzip)
- Alternatives considered: Native fetch - requires more boilerplate for interceptors and error handling; ky - better performance but less established.
5. Framer Motion (v10+)
- Justification: Production-ready animation library that enables smooth image transitions, loading states, and modal interactions with minimal performance overhead.
- Bundle size impact: ~25KB (minified + gzip)
- Alternatives considered: CSS animations - less flexible and maintainable; react-spring - more complex for simple transitions.
6. React Icons (v4+)
- Justification: Consistent, accessible icon system with tree-shaking support that reduces bundle size while maintaining design consistency.
- Bundle size impact: ~5KB (minified + gzip)
- Alternatives considered: Custom SVGs - more maintenance overhead; iconfont solutions - less accessibility and performance.
Alternative Libraries Considered and Rejected
1. Redux Toolkit
- Rejected because: Overkill for image gallery's data fetching needs; requires significant boilerplate for simple caching and pagination patterns.
2. Zustand
- Rejected because: While lightweight, it lacks built-in data fetching capabilities and would require additional libraries for cache invalidation.
3. Emotion / Styled Components
- Rejected because: Not essential for core image gallery functionality; design system already provides styling approach.
4. React Virtualized
- Rejected because: Outdated and less actively maintained; react-window provides same functionality with better performance characteristics.
Licensing and Bundle Size Impact Assessment
Bundle size breakdown (estimated):
- Core React + React Query + React Window: ~200KB
- Additional libraries (Axios, Framer Motion, React Icons): ~60KB
- Total estimated bundle size: ~260KB for image gallery feature (excluding base React)
License considerations:
- All selected libraries are MIT licensed with no commercial restrictions
- React Query and Framer Motion have permissive licenses suitable for enterprise use
- Bundle size is within acceptable limits for modern web applications (target <300KB for feature bundles)
Tree-shaking optimization:
- All libraries support tree-shaking to minimize final bundle size
- React Query and Framer Motion have granular imports that reduce overhead
- React Icons provide component-specific imports to avoid loading entire icon sets
15. Open Questions, Risks & Trade-offs
Decisions that need product/engineering alignment
- Image loading strategy: Should we prioritize lazy loading with placeholders or preloading all images for better perceived performance? This decision impacts both UX and bundle size.
- Pagination vs. Infinite scroll: The choice between these two approaches requires alignment on data volume expectations, user behavior patterns, and server-side API capabilities.
- Image optimization pipeline: Whether to use client-side image processing or rely on CDN/image service providers (Cloudinary, Imgix) requires cross-team coordination for infrastructure and costs.
Known trade-offs made in this design
- Bundle size vs. feature richness: Prioritized minimal dependencies over advanced features like drag-and-drop or image editing to keep the bundle size under 50KB for core gallery functionality.
- Client-side vs. server-side rendering: Chose CSR over SSR to enable real-time interactions and dynamic filtering but at the cost of initial page load performance for SEO.
- State management complexity vs. maintainability: Used React Context API instead of Redux Toolkit for simplicity, but this introduces potential performance issues at scale with complex state interactions.
Risks and mitigations
- Performance degradation with large image sets: Risk of UI jank when rendering hundreds of images. Mitigation includes implementing virtualization with react-window and progressive image loading.
- Image loading failures: Risk of broken images or loading spinners persisting indefinitely. Mitigation includes robust error boundaries, fallback image handling, and retry mechanisms.
- Accessibility compliance gaps: Risk of incomplete keyboard navigation or screen reader support. Mitigation includes comprehensive ARIA implementation and regular accessibility audits.
Technical debt acknowledged
- Hardcoded breakpoints: Using fixed breakpoint values (e.g., 768px) instead of responsive design tokens creates maintenance overhead as design system evolves.
- Limited image format support: Currently only supports common formats (JPEG, PNG) and doesn't handle WebP or AVIF without additional configuration.
- No image compression: Client-side image optimization is not implemented, relying on CDN or backend services for image processing.
- Basic error handling: Simple toast notifications instead of structured error logging or user-facing recovery paths for critical failures.