Skip to content

Instantly share code, notes, and snippets.

@mattrothenberg
Created August 1, 2024 00:56
Show Gist options
  • Save mattrothenberg/46dfb9a9badfb422515dbf2e2c0ccd7f to your computer and use it in GitHub Desktop.
Save mattrothenberg/46dfb9a9badfb422515dbf2e2c0ccd7f to your computer and use it in GitHub Desktop.
LQIP Images in Remix MDX files

I've been working on a new docs site for https://replicate.com/ and we're using Remix as a front-end framework and MDX for all of our technical writing.

We co-locate all of our high-res image assets in the repository itself, and were looking for a way to, at dev and build time:

  • Convert and resize images on-demand (e.g., to next-gen formats like .webp)
  • Generate LQIP assets
  • Extract metadata from the underlying image asset, such as width and height.

The most recent of version of Remix works as a Vite plugin, which means you as the developer have access to the wonderful ecosystem of Vite at your disposal when building your site.

We discovered an awesome library which handles most of the aforementioned functionality: vite-imagetools.

In so many words, it allows you to specify a whole host of "directives" when importing an image asset, most of which correspond neatly to Sharp – the Node-based image handling library – transformations.

So, say you import an image like this 👇

import dreambooth from '/app/assets/docs/dreambooth.png?format=webp&as=metadata';

dreambooth is now a Javascript object with a bunch of goodies on it:

{
  width: 1024,
  height: 1024,
  src: "...",
  // etcetera
}

While vite-imagetools didn't come out of the box with an LQIP transformation, it does have a wonderful API for extending the core functionality of the library.

Specifically, you can specify additional transforms like this, and even specify default sets of directives based on whatever criteria you desire. For us, we created a special ?optimize directive which converts to webp, grabs the width/height, and generates a LQIP.

const placeholderTransform: TransformFactory = (config) => {
  return async (image) => {
    if (!("lqip" in config)) return image;

    /** @ts-ignore it's a string */
    const href = await createPlaceholder(image.options.input.file);
    setMetadata(image, "lqip", href);
    return image;
  };
};

// Pass this object when initializing the plugin in your `vite.config.ts`.
export const imageToolsConfig: Partial<VitePluginOptions> = {
  extendTransforms: (builtins) => {
    return [placeholderTransform, ...builtins];
  },
  defaultDirectives: (url) => {
    if (url.searchParams.has("optimize")) {
      return new URLSearchParams({
        as: "meta:height;width;lqip;src",
        lqip: "true",
        format: "webp",
      });
    }

    return new URLSearchParams({});
  },
};

The implementation of createPlaceholder was lifted verbatim from a great GitHub issue on this topic. The real magic in the snippet above, though, is the setMetadata function exposed by vite-imagetools which allows you to set arbitrary additional pieces of metadata on the resolved image object.

import sharp from "sharp";
import {
  setMetadata,
  type TransformFactory,
  type VitePluginOptions,
} from "vite-imagetools";

const PLACEHOLDER_WIDTH = 16;

async function resizeImage(
  inputFile: string,
  options: { width: number; height?: number; quality?: number }
) {
  if (!inputFile) {
    throw new Error("Input file is required");
  }

  let sharpInstance = sharp(inputFile).toFormat("webp").blur(3);

  sharpInstance = sharpInstance.resize(options.width, options.height);

  return sharpInstance.toBuffer();
}

/**
 * @param {string} inputFile
 */
export async function getImageMetadata(inputFile: any) {
  if (!inputFile) {
    throw new Error("Input file is required");
  }

  return sharp(inputFile).metadata();
}

// sharp only supports a very specific list of image formats,
// no point depending on a complete mime type database
/**
 * @param {string | undefined} format
 */
export function getMimeType(format?: keyof sharp.FormatEnum) {
  switch (format) {
    case "jpeg":
    case "png":
    case "webp":
    case "avif":
    case "tiff":
    case "gif":
      return `image/${format}`;
    case "svg":
      return "image/svg+xml";
  }
  return "";
}

/**
 * @param {string} inputFile
 */
export async function createPlaceholder(inputFile: sharp.Sharp) {
  if (!inputFile) {
    throw new Error("Input file is required");
  }

  const [{ format }, blurData] = await Promise.all([
    getImageMetadata(inputFile),
    // @ts-ignore
    resizeImage(inputFile, { width: PLACEHOLDER_WIDTH }),
  ]);
  const blur64 = blurData.toString("base64");
  const mime = getMimeType(format);
  const href = `data:${mime};base64,${blur64}`;
  return href;
}

The end result here is that now, when you import your image module in code, you have an additional lqip field on the resolve object.

import dreambooth from '/app/assets/docs/dreambooth.png?optimize';
{
  width: 1024,
  height: 1024,
  src: "...",
  lqip: "huge-base64-string",
  // etcetera
}

With this, you can pass all of this data to a React component and do things like:

  • set the lqip as a background image, and fade the image on top
  • set an aspect ratio that's a function of the width and height

Happy hacking!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment