generating cover images from project names
Gravatar generates unique avatars from email hashes. Same email, same avatar, every time. I wanted the same thing for my portfolio: abstract art generated from project IDs that could serve as cover images and social previews.
The key constraint was determinism. Same input → same output. Forever. No database, no storage, just pure computation.
the problem with math.random
JavaScript's Math.random() can't be seeded. It produces different results every time. I needed a pseudorandom number generator that accepts a seed value and produces repeatable sequences.
The mulberry32 algorithm is fast and deterministic:
export class SeededRandom {
private seed: number;
constructor(seed: number) {
this.seed = seed;
}
next(): number {
let t = (this.seed += 0x6d2b79f5);
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
nextInt(min: number, max: number): number {
return Math.floor(this.next() * (max - min)) + min;
}
pick<T>(array: T[]): T {
return array[this.nextInt(0, array.length)];
}
}converting strings to seeds
Projects have string IDs like "my-cool-project". I needed a hash function to convert strings to numbers:
export function stringToSeed(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash;
}
return Math.abs(hash);
}Same string → same hash → same seed → same art.
the generator
The art is soft, blurred, colorful blobs layered on top of each other:
function generateAbstractArt(
width: number,
height: number,
stringSeed: string,
darkMode: boolean = false
): string {
const rng = createSeededRandom(stringSeed);
const colors = [
darkMode ? "#ff62b8" : "#ff3ea5",
darkMode ? "#818cf8" : "#6366f1",
darkMode ? "#fbbf24" : "#f59e0b",
];
const clouds: Cloud[] = [];
const cloudCount = rng.nextInt(4, 9);
for (let i = 0; i < cloudCount; i++) {
clouds.push({
type: "blob",
x: rng.nextFloat(-width * 0.4, width * 0.7),
y: rng.nextFloat(-height * 0.4, height * 0.7),
width: rng.nextFloat(width * 0.35, width * 0.9),
height: rng.nextFloat(height * 0.35, height * 0.9),
color: rng.pick(colors),
opacity: rng.nextFloat(0.12, 0.28),
blur: rng.nextFloat(50, 100),
rotation: rng.nextFloat(0, 360),
});
}
return generateSVG(clouds, backgroundColor, width, height);
}Because the RNG is seeded, the composition is identical for identical input.
the api endpoint
Exposed via Next.js API route with aggressive caching:
export async function GET(request: NextRequest) {
const params = request.nextUrl.searchParams;
const width = parseInt(params.get("width") || "400");
const height = parseInt(params.get("height") || "400");
const stringSeed = params.get("string") || "default";
const darkMode = params.get("dark") === "true";
const svg = generateAbstractArt(width, height, stringSeed, darkMode);
return new Response(svg, {
headers: {
"Content-Type": "image/svg+xml",
"Cache-Control": "public, max-age=31536000, immutable",
},
});
}Generated once per unique input, cached for a year, served from edge forever.
usage
When a project doesn't have a custom image:
function getImageUrl(project: Project, isDark: boolean): string {
if (project.image) return project.image;
return `/api/abstract-art?width=${width}&height=${height}&string=${encodeURIComponent(project.id)}&dark=${isDark}`;
}Dark mode changes the color palette but keeps blob positions unchanged. Same project, consistent visual identity across themes.
why this works
| Property | Benefit |
|---|---|
| No database | Zero storage costs |
| Deterministic | Infinitely cacheable |
| SVG output | Lightweight, scales perfectly |
| Pure JS | No image processing libraries |
I tried more complex generators with geometric shapes and gradients. The soft blobs looked best. Sometimes simpler is actually better.