Skip to content

Instantly share code, notes, and snippets.

@MrJackdaw
Last active August 27, 2025 02:56
Show Gist options
  • Select an option

  • Save MrJackdaw/300c2621b8ac8c13183e3892dce2a0ce to your computer and use it in GitHub Desktop.

Select an option

Save MrJackdaw/300c2621b8ac8c13183e3892dce2a0ce to your computer and use it in GitHub Desktop.
ReactJS ImageLoader Component (TSX, CSS, and example usage)
/* ImageLoader styles */
.image-loader.image-loader--rounded {
border-radius: 100%;
}
.image-loader,
.image-loader .caption {
display: flex;
}
// 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