Dynamic Placeholder Images for Testing

I have now released this concept as its own npm package.

Placeholder images are crucial when building out design systems. Whether it is a card, a hero or just an image component you will need actual images to use while developing. I have typically seen 2 approaches for this in the wild: a folder full of images of various sizes that are referenced directly, or using a service like https://placehold.co/ where you can just pass an arbitrary size. The former soon bubbles out of control, having various similar aspect ratios and having a mix of colours and fonts depending on who created them. The latter is better as you can manage the images where they are used, and it does not balloon the size of your repo, and all of them are styled the same. However, placeholder services aren’t perfect as for one they can be paid services or like placeholder.com might be bought out, and additionally they require a network request, which for visual regression specifically is one more thing to wait for before a screenshot can be taken.

So, is there a better way. I came up with a solution that somewhat mimics the placeholder services but dynamically generates the image locally. You may be thinking this is akin to hosting a placeholder service in a local docker image, but it is much simpler than that. It uses the data: format which can be used inline in an images src in both HTML and CSS. So, how does it work? Well it is a simple case of generating an SVG and inlining it with image/svg+xml. We can do a bit of optimisation to stop the length getting out of control. Before we get into the implementation it is worth noting that this is written in JavaScript, meaning you would need to be using a component library of some-sort that renders your components HTML via JavaScript, and similarly CSS-in-JS or a pre-processor for CSS.

Below is a simplified implementation with inline comments, you can see a live demo of it in use here.

 * Generates a placholder image as an inline image data string.
 * @param width - Width of the placeholder image.
 * @param height - Height of the placeholder image.
 * @param colour - The background colour of the image.
 * @returns Image data string.
export function function placeholderImage(width: number, height: number, colour: string): stringplaceholderImage(
  width: numberwidth: number,
  height: numberheight: number,
  colour: stringcolour: string
): string {
  // Generate the viewbox size, this is an aspect ratio where the larger
  // dimension is 100.
  const const viewboxWidth: numberviewboxWidth = var Math: MathMath.Math.min(...values: number[]): numbermin(100, (width: numberwidth / height: numberheight) * 100);
  const const viewboxHeight: numberviewboxHeight = var Math: MathMath.Math.min(...values: number[]): numbermin(100, (height: numberheight / width: numberwidth) * 100);
  // You could use something like https://polished.js.org/docs/#readablecolor here
  // to generate an appropriate colour from the background colour. But for
  // simplicity we are just using black.
  const const textColor: "#000"textColor = "#000";
  // Here is the meat and potatoes, we create an appropriately sized svg with a
  // full size rectangle in the provided colour and some text centered in the
  // middle.
  let let svg: stringsvg = `<svg
              viewBox="0 0 ${const viewboxWidth: numberviewboxWidth} ${const viewboxHeight: numberviewboxHeight}"
              width="${width: numberwidth}"
              height="${height: numberheight}">
              <style>*{font:400 16px sans-serif;}</style>
              <rect width="100%" height="100%" fill="${colour: stringcolour}" />
                fill="${const textColor: "#000"textColor}">
              ${width: numberwidth}x${height: numberheight}
  // You could probably remove the spacing above, or even use a library to minify
  // the html. But you want the svg code to be a single line, so I have done a
  // very simple remapping below.
  let svg: stringsvg = let svg: stringsvg
    .String.split(separator: string | RegExp, limit?: number | undefined): string[] (+1 overload)split("\n")
    .Array<string>.map<string>(callbackfn: (value: string, index: number, array: string[]) => string, thisArg?: any): string[]map((line: stringline) => line: stringline.String.trim(): stringtrim())
    .Array<string>.join(separator?: string | undefined): stringjoin(" ");
  const const base64: stringbase64 = function btoa(data: string): stringbtoa(let svg: stringsvg);
  const const utf8: stringutf8 = function encodeURIComponent(uriComponent: string | number | boolean): stringencodeURIComponent(let svg: stringsvg);
  // This is the extra optimisation I eluded to. Essentially we compare the full
  // text length (encoded) to the base64 representation and use the smaller
  // version.
  const const unicode: booleanunicode = !const base64: stringbase64 || const utf8: stringutf8.String.length: numberlength < const base64: stringbase64.String.length: numberlength;
  // Finally we return the data string with the relevant encoding.
  return `data:image/svg+xml;${const unicode: booleanunicode ? "utf8," + const utf8: stringutf8 : "base64," + const base64: stringbase64}`;