An image component.
<Flex orientation="vertical" gap="10"> <Img src="https://app.requestly.io/delay/5000/https://images.unsplash.com/photo-1558728112-33eea323814f" alt="Neytiri" width="800" height="533" /> <Img src="/img/neytiri.webp" alt="Neytiri" width="800" height="533" /> <Img src="/img/doesntexist.webp" alt="Neytiri" width="800" height="533" /> <AspectRatio ratio={10 / 2}> <Img src="/img/neytiri.webp" alt="Neytiri" width="800" height="533" className="h-full w-full object-cover" /> </AspectRatio></Flex>
Here's the <Img />
component in action.
import { forwardRef, type ImgHTMLAttributes, useRef, useEffect, useState, useImperativeHandle } from 'react'import { cn } from '#app/utils/tailwind-merge'
export type ImageProps = ImgHTMLAttributes<HTMLImageElement> & { alt: string src: string width: string height: string}
const Img = forwardRef<HTMLImageElement, ImageProps>(({ src, alt, width, height, className, ...props }, ref) => { const [state, setState] = useState<'loading' | 'loaded' | 'failed' | null>(null) const imageRef = useRef<HTMLImageElement>(null) useImperativeHandle<HTMLImageElement | null, HTMLImageElement | null>(ref, () => imageRef.current) // Merge the two refs
useEffect(() => { setState('loading') // Enable loading state // If JS loads before image (otherwise rely on onLoad/onError events) if (imageRef.current && imageRef.current.complete) { const success = imageRef.current.naturalWidth > 0 if (success) { onLoad() } else { onError() } } }, [imageRef])
const onLoad = () => { setState('loaded') } const onError = () => { setState('failed') }
return ( <img ref={imageRef} width={width} height={height} onLoad={onLoad} onError={onError} data-loading={state === 'loading' || undefined} data-loaded={state === 'loaded' || undefined} data-failed={state === 'failed' || undefined} src={src} alt={alt} {...props} className={cn('image', className)} /> )})Img.displayName = 'Img'export { Img }
.image { @apply relative rounded-2xl opacity-0 transition duration-1000 ease-in-out;
&[data-loading] { @apply bg-muted-200 animate-shine from-muted-200 via-muted-100 to-muted-200 bg-gradient-to-r from-0% via-50% to-80% bg-[size:50px_500px] bg-repeat-y opacity-100; } &[data-loaded] { @apply opacity-100; } &[data-failed] { @apply opacity-100;
&:before { @apply bg-muted-100 absolute inset-0 h-full w-full content-['']; }
&:after { @apply bg-muted-300 absolute inset-0 h-full w-full content-[''];
mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 150 150' fill='red' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M60.5 56H90.5C92.9852 56 95 58.0147 95 60.5V90.5C95 92.9852 92.9852 95 90.5 95H60.5C58.0147 95 56 92.9852 56 90.5V60.5C56 58.0147 58.0147 56 60.5 56ZM60.5 59C59.6716 59 59 59.6716 59 60.5V78.0908L64.0454 73.0454C64.3043 72.7865 64.6572 72.6439 65.0232 72.6502C65.3893 72.6565 65.737 72.8112 65.9868 73.0788L76.6206 84.4703L85.0454 76.0454C85.5725 75.5182 86.4275 75.5182 86.9546 76.0454L92 81.0908V60.5C92 59.6716 91.3283 59 90.5 59H60.5ZM59 90.5V81.9092L64.9666 75.9426L75.5933 87.3263L79.8234 92H60.5C59.6716 92 59 91.3283 59 90.5ZM90.5 92H83.465L78.4501 86.459L86 78.9092L92 84.9092V90.5C92 91.3283 91.3283 92 90.5 92ZM72.9477 69.5C72.9477 68.0904 74.0904 66.9477 75.5 66.9477C76.9096 66.9477 78.0523 68.0904 78.0523 69.5C78.0523 70.9096 76.9096 72.0523 75.5 72.0523C74.0904 72.0523 72.9477 70.9096 72.9477 69.5ZM75.5 64.2477C72.5992 64.2477 70.2477 66.5992 70.2477 69.5C70.2477 72.4008 72.5992 74.7523 75.5 74.7523C78.4008 74.7523 80.7523 72.4008 80.7523 69.5C80.7523 66.5992 78.4008 64.2477 75.5 64.2477Z' /%3E%3C/svg%3E") no-repeat center; } }}