website: add pixelated image placeholders with fade-in transition

Build-time script generates tiny 32px-wide versions of all images in
public/ using sharp. At runtime, PixelatedImage component renders these
with CSS image-rendering: pixelated (nearest-neighbor / point sampling)
for a crisp mosaic effect, then fades in the real image on load.

- Auto-discovers images in public/, skips placeholder- prefixed files
- Mtime check skips regeneration when placeholder is newer than source
- Callback ref handles cached images where onLoad fires before mount
- Explicit z-index prevents placeholder from covering the real image
- Runs automatically as part of pnpm build in website package
This commit is contained in:
Tommy D. Rossi
2026-02-21 15:22:39 +01:00
parent 00baca9c2b
commit 3b3959f5ab
9 changed files with 179 additions and 10 deletions
+89 -1
View File
@@ -6,7 +6,7 @@
* --link-accent, --page-border.
*/
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import Prism from "prismjs";
import "prismjs/components/prism-jsx";
import "prismjs/components/prism-tsx";
@@ -416,6 +416,94 @@ export function CodeBlock({ children, lang = "jsx", lineHeight = "1.85" }: { chi
);
}
/* =========================================================================
Pixelated placeholder image
Uses a tiny pre-generated image with CSS image-rendering: pixelated
(nearest-neighbor / point sampling in GPU terms) for a crisp mosaic
effect. The real image fades in on top once loaded — no flash because
the placeholder stays underneath and the real image starts at opacity 0.
========================================================================= */
export function PixelatedImage({
src,
placeholder,
alt,
width,
height,
className = "",
style,
}: {
src: string;
placeholder: string;
alt: string;
width: number;
height: number;
className?: string;
style?: React.CSSProperties;
}) {
const [loaded, setLoaded] = useState(false);
// Handles both the normal onLoad event and the case where the image is
// already cached (img.complete is true before React mounts the handler).
const imgRef = useCallback((img: HTMLImageElement | null) => {
if (img?.complete && img.naturalWidth > 0) {
setLoaded(true);
}
}, []);
return (
<div
className={className}
style={{
position: "relative",
width: "100%",
maxWidth: `${width}px`,
aspectRatio: `${width} / ${height}`,
overflow: "hidden",
...style,
}}
>
{/* Placeholder: tiny image rendered with nearest-neighbor sampling */}
<img
src={placeholder}
alt=""
aria-hidden
width={width}
height={height}
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
objectFit: "cover",
imageRendering: "pixelated",
zIndex: 0,
}}
/>
{/* Real image: starts invisible, fades in over the placeholder */}
<img
ref={imgRef}
src={src}
alt={alt}
width={width}
height={height}
onLoad={() => {
setLoaded(true);
}}
style={{
position: "relative",
width: "100%",
height: "100%",
objectFit: "cover",
opacity: loaded ? 1 : 0,
transition: "opacity 0.4s ease",
zIndex: 1,
}}
/>
</div>
);
}
/* =========================================================================
Chart placeholder (dark box with animated line)
========================================================================= */