Last active
August 27, 2025 02:56
-
-
Save MrJackdaw/300c2621b8ac8c13183e3892dce2a0ce to your computer and use it in GitHub Desktop.
ReactJS ImageLoader Component (TSX, CSS, and example usage)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* ImageLoader styles */ | |
| .image-loader.image-loader--rounded { | |
| border-radius: 100%; | |
| } | |
| .image-loader, | |
| .image-loader .caption { | |
| display: flex; | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // IF YOU DON'T HAVE A LUCIDE-REACT DEPENDENCY, | |
| // replace the `ImageIcon` import with something else. | |
| import { | |
| type ComponentPropsWithRef, | |
| useEffect, | |
| useMemo, | |
| useRef, | |
| useState | |
| } from "react"; | |
| import { ImageIcon } from "lucide-react"; | |
| import "./ImageLoader.css"; | |
| export type ImageLoaderProps = { | |
| round?: boolean; | |
| src?: string | null; | |
| } & Omit<ComponentPropsWithRef<"img">, "src">; | |
| /** @component Loads images with smooth entry animations; has error handler */ | |
| const ImageLoader = (props: ImageLoaderProps) => { | |
| const { src, className = "", round, onClick, ...imgProps } = props; | |
| const rounded = round ? "image-loader--rounded rounded-full" : "rounded-sm"; | |
| let cName = `image-loader ${rounded} ${className}`.trim(); | |
| if (!src) cName = `${cName} border-2 border-dashed border-orange-500/25`; | |
| const [loading, setLoading] = useState(false); | |
| const loaded = useRef(""); | |
| const containerRef = useRef<HTMLDivElement>(null); | |
| const isInViewport = useIsVisible(containerRef, true); | |
| const scrollOpts = { capture: true, passive: true }; | |
| const canRender = useMemo(() => { | |
| if (loading || !src) return false; | |
| return Boolean(loaded.current) || isInViewport; | |
| }, [isInViewport, loaded, loading, src]); | |
| const loadImageWhenInView = () => { | |
| window.removeEventListener("scroll", loadImageWhenInView, scrollOpts); | |
| if (!src || loaded.current === src) return; | |
| setLoading(true); | |
| const img = new Image(); | |
| img.onerror = () => { | |
| setLoading(false); | |
| }; | |
| img.onload = () => { | |
| loaded.current = src; | |
| setLoading(false); | |
| }; | |
| img.src = src; // load image | |
| }; | |
| useEffect(() => { | |
| if (!src) return unmount; | |
| if (src === loaded.current) return () => {}; | |
| const { current } = containerRef; | |
| const notInView = current ? !isInViewport : false; | |
| if (notInView) { | |
| window.addEventListener("scroll", loadImageWhenInView, scrollOpts); | |
| } else loadImageWhenInView(); | |
| return unmount; | |
| function unmount() { | |
| window.removeEventListener("scroll", loadImageWhenInView, scrollOpts); | |
| } | |
| }, [src]); | |
| return ( | |
| <ImageContainer ref={containerRef} className={cName}> | |
| {canRender ? ( | |
| <img | |
| {...imgProps} | |
| onClick={onClick} | |
| className={cName} | |
| src={src!} | |
| alt={imgProps.alt} | |
| /> | |
| ) : ( | |
| <div | |
| className="items-center place-content-center" | |
| style={placeholderSize(props)} | |
| title={imgProps.alt || "No image"} | |
| > | |
| {loading ? <Spinner /> : <ImageIcon className="mx-auto" />} | |
| </div> | |
| )} | |
| </ImageContainer> | |
| ); | |
| }; | |
| export default ImageLoader; | |
| function Spinner() { | |
| return ( | |
| <span | |
| className={cn( | |
| "spinner--before z-50 mx-auto", | |
| " [&::before]:rounded-full [&::before]:h-[32px] [&::before]:w-[32px]" | |
| )} | |
| /> | |
| ); | |
| } | |
| /** @component Default no-image placeholder */ | |
| function ImageContainer(props: ComponentPropsWithRef<"div">) { | |
| const { className = "", children = "Title", ...divProps } = props; | |
| let classlist = cn(className, "overflow-hidden text-gray-400 text-lg"); | |
| if (classlist.indexOf("min-w-") === -1 && classlist.indexOf("max-w-") === -1) | |
| classlist += " min-w-[120px]"; | |
| return ( | |
| <div | |
| {...divProps} | |
| $placeContent="place-content-center text-center" | |
| className={classlist.trim()} | |
| > | |
| {children} | |
| </div> | |
| ); | |
| } | |
| /** @helper Generate style attributes for image placeholder element */ | |
| function placeholderSize(props: ImageLoaderProps) { | |
| const toPixelStr = (v?: string | number) => | |
| v && isNaN(Number(v)) ? (v as string) : `${v}px`; | |
| const { width = "64px", height = "64px" } = props; | |
| return { | |
| minWidth: toPixelStr(width), | |
| width: toPixelStr(width), | |
| height: toPixelStr(height) | |
| }; | |
| } | |
| import { RefObject, useState, useRef, useEffect } from "react"; | |
| import { noOp } from "lib/utils"; | |
| type $ElementRef = RefObject<Element | null>; | |
| /** | |
| * @hook Assert that an element is on-screen | |
| * @param ref HTML container reference (track container's viewport visibility) | |
| * @param loadOnce When true, stop tracking visibility after initial viewport entry | |
| */ | |
| default function useIsVisible<T extends $ElementRef>( | |
| ref: T, | |
| loadOnce = false | |
| ) { | |
| const [isOnScreen, setIsOnScreen] = useState(false); | |
| const observerRef = useRef<IntersectionObserver>(null); | |
| useEffect(() => { | |
| // Exit if target container hasn't been mounted in DOM | |
| if (!ref.current) return noOp; | |
| // Stop observing if container has loaded once | |
| if (isOnScreen && loadOnce) { | |
| observerRef.current?.disconnect(); | |
| return noOp; | |
| } | |
| // Build and attach observer | |
| observerRef.current = new IntersectionObserver(([entry]) => { | |
| setIsOnScreen(entry.isIntersecting); | |
| }); | |
| observerRef.current?.observe(ref.current); | |
| return () => observerRef.current?.disconnect(); | |
| }, [ref, isOnScreen]); | |
| return isOnScreen; | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment