Creating procedural art deterministically
Gravatar is a service that creates unique avatar images from the hash of an email address. Inspired by this idea, I set out to build something similar for my developer portfolio. Rather than generating faces or icons, I wanted to create abstract art to use as cover images for my project cards and as visual previews for social media sharing. The goal was that the same input (like a project ID or title) would always produce the same art, making it easy to cache and instantly recognizable across the site, whether viewed in light or dark mode.
The Problem
When working on the portfolio, I noticed that many of my projects lacked custom images. Stock photos felt too impersonal and using the same image everywhere looked generic. Manually crafting a unique cover image for each project was unrealistic, especially as the collection grew. I needed a system that could generate a unique visual for every project—something that would always produce the same output for the same input, could be cached forever, and looked fresh and consistent with my branding, even as the site switched between light and dark themes.
The Foundation
The key requirement was determinism: the same seed should always generate the same artwork. JavaScript's standard Math.random() function can't be controlled in this way because it doesn't accept a seed. To solve this, I used a lightweight pseudorandom number generator: the mulberry32 algorithm. It is fast and produces a repeatable sequence of numbers when provided with a specific seed value.
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;
}
nextFloat(min: number, max: number): number {
return this.next() * (max - min) + min;
}
pick<T>(array: T[]): T {
return array[this.nextInt(0, array.length)];
}
}From Strings to Seeds
Because projects are identified by strings like "my-cool-project", I needed a way to convert any string into a consistent number to use as the seed. For this, I wrote a simple hash function:
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; // Keep as 32-bit integer
}
return Math.abs(hash);
}
export function createSeededRandom(
stringSeed: string,
numericSeed?: number
): SeededRandom {
const baseSeed = stringToSeed(stringSeed);
const finalSeed = numericSeed ? baseSeed + numericSeed : baseSeed;
return new SeededRandom(finalSeed);
}This gives me a way to use a project’s unique string ID as the source for seeded randomness, ensuring that each project’s art is stable and predictable.
The Art Generator
With seeded randomness in place, I built an API endpoint that produces abstract, minimal SVG art. The art is made up of soft, blurred, colorful blobs using a color palette that fits my branding and switches for dark mode.
function generateAbstractArt(
width: number,
height: number,
stringSeed: string,
darkMode: boolean = false
): string {
const rng = createSeededRandom(stringSeed);
// Brand colors
const colors = [
darkMode ? "#ff62b8" : "#ff3ea5",
darkMode ? "#818cf8" : "#6366f1",
darkMode ? "#fbbf24" : "#f59e0b",
];
const backgroundColor = darkMode ? "#0a0a0a" : "#fafafa";
const clouds: Cloud[] = [];
// Generate a random number of blobs
const cloudCount = rng.nextInt(4, 9);
for (let i = 0; i < cloudCount; i++) {
const color = rng.pick(colors);
const opacity = rng.nextFloat(0.12, 0.28);
const blur = rng.nextFloat(50, 100);
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,
opacity,
blur,
rotation: rng.nextFloat(0, 360),
});
}
// Additional gradients for depth omitted for brevity.
return generateSVG(clouds, backgroundColor, width, height);
}Because the randomness is seeded, the generated composition is always the same for identical input. This allows the system to be completely cache-friendly and predictable while still feeling organic and artistic.
Building the API Endpoint
I made this generator available via a Next.js API route, which takes in query parameters for width, height, project string, and dark mode. Since the output is always the same for the same input, I told the browser and CDN to cache each result for a full year by setting the cache headers:
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",
},
});
}This approach ensures images are created on demand, but are only rendered once per unique input, and then instantly served from cache.
Real-World Usage in Project Cards
Whenever a project lacks a custom image, I use this endpoint to produce a fallback illustration. The URL includes parameters from the project ID and current theme:
function getImageUrl(project: Project, isDark: boolean): string {
if (project.image) {
return project.image;
}
const width = project.imageWidth || 400;
const height = project.imageHeight || 400;
const seed = project.id;
return `/api/abstract-art?width=${width}&height=${height}&string=${encodeURIComponent(seed)}&dark=${isDark}`;
}Projects with the same ID get the same art, and switching to dark mode changes the color palette but keeps blob positions and shapes unchanged.
Auto-Generated Social Media Previews
I extended the same concept to create unique Open Graph images for social media cards, combining a procedural background with text overlays:
function generateOGImage(
title: string,
section: "blog" | "work" | "home",
darkMode: boolean
): string {
const width = 1200;
const height = 630;
const rng = createSeededRandom(title);
const clouds = generateClouds(rng, width, height);
const svg = `
<svg width="${width}" height="${height}">
${renderClouds(clouds)}
<text x="80" y="140" class="title">${title}</text>
<text x="80" y="180" class="section">${section}.</text>
<text x="${width - 180}" y="${height - 60}" class="brand">
ryana.
</text>
</svg>
`;
return svg;
}This means every blog post and project page gets its own on-brand, reproducible preview for sharing—no manual image editing required.
Performance and Lessons Learned
The solution avoids all database queries, as visuals are generated from the seed alone. Because every result is deterministic, cache headers can be set to "immutable," so the CDN keeps every image until the end of time. SVGs are lightweight and render instantly, and everything runs in pure JavaScript without image processing libraries or heavy compute.
This project taught me how powerful seeded randomness can be for creative, scalable generative systems. SVG turned out to be the perfect format: easy to generate, easy to scale, and infinitely customizable. Most importantly, sticking to soft, simple blobs produced the most visually appealing results, proving that sometimes less really is more.