/**
* ImageWithSkeleton Component
*
* Progressive image loading with skeleton placeholder:
* - Shows skeleton while image loads
* - Smooth fade transition on load
* - Supports lazy loading (loads last in priority)
* - Error state handling
*/
import { useState, useCallback } from 'react';
import styled from 'styled-components';
import { Skeleton } from './Skeleton';
export interface ImageWithSkeletonProps {
/** Image source URL */
src: string;
/** Alt text for accessibility */
alt: string;
/** Width (default: 100%) */
width?: string | number;
/** Height (default: auto) */
height?: string | number;
/** Border radius */
borderRadius?: string | number;
/** Object fit style */
objectFit?: 'cover' | 'contain' | 'fill' | 'none' | 'scale-down';
/** Lazy loading (default: true) */
lazy?: boolean;
/** Custom className */
className?: string;
/** Fallback image on error */
fallbackSrc?: string;
/** Aspect ratio (e.g., "16/9", "1/1") */
aspectRatio?: string;
/** Loading priority (for fetch priority API) */
priority?: 'high' | 'low' | 'auto';
/** Callback when image loads */
onLoad?: () => void;
/** Callback when image fails to load */
onError?: () => void;
}
const ImageContainer = styled.div<{
$width?: string | number;
$height?: string | number;
$aspectRatio?: string;
$borderRadius?: string | number;
}>`
position: relative;
width: ${({ $width }) =>
$width ? (typeof $width === 'number' ? `${$width}px` : $width) : '100%'};
height: ${({ $height }) =>
$height ? (typeof $height === 'number' ? `${$height}px` : $height) : 'auto'};
aspect-ratio: ${({ $aspectRatio }) => $aspectRatio || 'auto'};
border-radius: ${({ $borderRadius }) =>
$borderRadius
? typeof $borderRadius === 'number'
? `${$borderRadius}px`
: $borderRadius
: '0'};
overflow: hidden;
`;
const StyledImage = styled.img<{
$loaded: boolean;
$objectFit?: ImageWithSkeletonProps['objectFit'];
}>`
width: 100%;
height: 100%;
object-fit: ${({ $objectFit }) => $objectFit || 'cover'};
opacity: ${({ $loaded }) => ($loaded ? 1 : 0)};
transition: opacity 0.3s ease-in-out;
`;
const SkeletonOverlay = styled.div<{ $visible: boolean }>`
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
opacity: ${({ $visible }) => ($visible ? 1 : 0)};
transition: opacity 0.3s ease-in-out;
pointer-events: none;
`;
const ErrorPlaceholder = styled.div`
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
min-height: 100px;
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.5);
font-size: 14px;
`;
export function ImageWithSkeleton({
src,
alt,
width,
height,
borderRadius,
objectFit = 'cover',
lazy = true,
className,
fallbackSrc,
aspectRatio,
priority = 'low',
onLoad,
onError,
}: ImageWithSkeletonProps) {
const [loaded, setLoaded] = useState(false);
const [error, setError] = useState(false);
const [currentSrc, setCurrentSrc] = useState(src);
const handleLoad = useCallback(() => {
setLoaded(true);
onLoad?.();
}, [onLoad]);
const handleError = useCallback(() => {
if (fallbackSrc && currentSrc !== fallbackSrc) {
setCurrentSrc(fallbackSrc);
} else {
setError(true);
onError?.();
}
}, [fallbackSrc, currentSrc, onError]);
if (error) {
return (
Failed to load image
);
}
return (
);
}
/**
* Avatar image with skeleton
*/
export function AvatarWithSkeleton({
src,
alt,
size = 40,
className,
fallbackSrc,
}: {
src: string;
alt: string;
size?: number;
className?: string;
fallbackSrc?: string;
}) {
return (
);
}
/**
* Hero image with skeleton (higher priority)
*/
export function HeroImageWithSkeleton({
src,
alt,
aspectRatio = '16/9',
className,
}: {
src: string;
alt: string;
aspectRatio?: string;
className?: string;
}) {
return (
);
}