I sell Next.js templates. Every product needs cover art, and the obvious way to do that is to screenshot each template, export a few sizes, and ship the images.
I didn't do that. There is no public/images directory in the storefront. No PNG, no JPG, no WebP, no SVG illustration. Every product's cover art is drawn at render time from two numbers and a string in a TypeScript file.
Here is the entire visual configuration for a product:
{
slug: "ledgerly",
name: "Ledgerly",
category: "Dashboard",
hues: [160, 200],
motif: "dashboard",
}
hues is a pair of oklch hue angles. motif picks which abstract layout to draw.
Why bother
The honest answer is that I was avoiding a chore, and it turned into an architecture decision.
Screenshots rot. Every time I changed a template's hero section, its cover art was subtly wrong. I'd have to re-shoot, re-export, re-optimise, re-upload. With eight products that's a small job I would do badly and late.
Drawing the art from data removed the chore entirely. Adding a template is now one object in a catalog file, and the art comes with it.
The side effects turned out to matter more than the thing I was avoiding:
- Nothing to lay out badly. No image means no intrinsic size to guess at, so no layout shift while covers load.
- Nothing to download. The covers cost zero network bytes. They're DOM nodes with inline styles, compressed along with the rest of the HTML.
- Rebranding is a find-and-replace. Changing a product's palette is editing two integers.
The part that actually makes it work: oklch
If you do this in HSL, you get a mess. hsl(60 80% 50%) — yellow — is visually much lighter than hsl(240 80% 50%) — blue — even though both claim 50% lightness. So a card generated at hue 60 looks washed out next to one at hue 240, and you end up hand-correcting every product. At which point you have not saved yourself anything.
oklch() is perceptually uniform: the same lightness value looks like the same lightness to a human eye across every hue. That is the single property that makes generated art viable, because it means one set of lightness and chroma values works for every product, and only the hue changes.
The whole color system is one helper:
const o = (l: number, c: number, h: number, a?: number) =>
a === undefined ? `oklch(${l} ${c} ${h})` : `oklch(${l} ${c} ${h} / ${a})`;
And then everything is expressed relative to the product's two hues:
<span
className="h-4 w-12 rounded-full"
style={{
background: `linear-gradient(90deg, ${o(0.6, 0.2, h1)}, ${o(0.65, 0.16, h2)})`,
}}
/>
0.6 lightness and 0.2 chroma are tuned once, by eye, on one product. They then look correct on all of them. That is the entire trick.
Motifs, or: don't draw the template, evoke it
The second decision was what to actually draw.
The temptation is to render a tiny faithful replica of each template. Don't. It's enormous work, it looks like a broken screenshot at small sizes, and it drifts out of date exactly like the screenshots you were trying to avoid.
Instead each motif is an abstract arrangement of blocks suggesting a kind of interface. The dashboard motif is a narrow sidebar, a row of stat tiles, and a bar chart:
<div className="flex min-h-0 flex-1 items-end gap-1 rounded-md bg-white/[0.04] p-2">
{[38, 62, 48, 78, 58, 88, 70, 95, 64, 82].map((height, i) => (
<span
key={i}
className="flex-1 rounded-sm"
style={{ height: `${height}%`, background: /* … */ }}
/>
))}
</div>
Those bar heights are hardcoded. They're not data, they're composition: a shape chosen because it reads as "chart" at 300px wide. Random heights look like noise; a designed sequence looks intentional.
Neutral elements use plain white at low alpha (bg-white/[0.06]) rather than the brand hue. If everything is tinted, nothing reads as accent. The hues are spent only where they carry meaning: the active nav item, the primary button, the chart.
Each motif sits inside a shared browser frame which does a lot of work for very little code. It tells you "this is a website" instantly, so the abstract shapes inside are read as an interface rather than as decoration.
When this is a bad idea
It works here because I'm selling interfaces, and interfaces are made of rectangles. Abstract rectangles evoke them honestly.
It would be a terrible approach for a photography portfolio, an illustration marketplace, or anything where the specific visual content is the product. You cannot procedurally generate a photograph, and you shouldn't try.
There's also a real trade-off I accepted: a generated cover tells you the category, not the design. It can't sell the actual template. That's fine for me because every product links to a complete live demo, so the cover only has to get someone to click. If you have no demo, a real screenshot is probably doing more work than a pretty abstraction.
The takeaway
If your product art is systematic — categories, tiers, types — consider whether it can be derived from data instead of drawn by hand. You get consistency for free, and adding a product stops involving a design task.
Start with oklch. Without perceptual uniformity you'll spend all the time you saved hand-correcting colors.
I write this stuff while building Nocturne, a store of dark, animation-heavy Next.js templates. The free starter is MIT and has the same token system if you want to poke at it: github.com/Rucs0/nocturne-ember.
Top comments (0)