Building react-cloudinary-image-lite npm package

How to create and use a NPM package for a React Cloudinary Image component. This component is preparing WebP and AVIF image sets from assets hosted on Cloudinary, using density or resolution switching and a blurred preloading image placeholder.

January 02, 2024

This a simple no fuss React Image component to help you get up and running with responsive image sets from Cloudinary. For a more complex use case you can find other packages on NPM registry like Cloudinary React SDK @cloudinary/react.

What is covered

Code repository

Primers

Creating this npm package

You can take a look on the commit to set up Rollup. The config file at /rollup.config.js for the module bundler looks like this:

import commonjs from '@rollup/plugin-commonjs';
import resolve from '@rollup/plugin-node-resolve';
import terser from '@rollup/plugin-terser';
import typescript from '@rollup/plugin-typescript';
import { dts } from 'rollup-plugin-dts';
import peerDepsExternal from 'rollup-plugin-peer-deps-external';
import postcss from 'rollup-plugin-postcss';

import pkg from './package.json' assert { type: 'json' };

export default [
  {
    input: 'src/index.ts',
    output: [
      {
        file: pkg.main,
        format: 'cjs',
        sourcemap: true,
      },
      {
        file: pkg.module,
        format: 'esm',
        sourcemap: true,
      },
    ],
    plugins: [
      peerDepsExternal(),
      resolve(),
      commonjs(),
      typescript({ tsconfig: './tsconfig.json' }),
      postcss(),
      terser(),
    ],
  },
  {
    input: 'dist/esm/types/index.d.ts',
    output: [{ file: 'dist/index.d.ts', format: 'esm' }],
    plugins: [dts()],
    external: ['react', 'react-dom'],
  },
];

Installing and cofiguring Storybook can be done easily with this command. It will also save some examples in your src directory to help with the onboarding process.

npx sb init

This is configuration file for Jest at /jest.config.js. I am using ts-jest, as you can see. See the commit where I configured testing.

/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'jsdom',
  clearMocks: true,
  collectCoverage: true,
  coverageDirectory: 'coverage',
  coverageProvider: 'v8',
  coverageReporters: ['json-summary', 'text', 'lcov'],
};

Look at this fragment of the /package.json file to see how the module library is built and configured. You can also see the scripts used during the development process.

"main": "dist/cjs/bundle.js",
"module": "dist/esm/bundle.js",
"types": "dist/index.d.ts",
"files": [
  "dist"
],
"scripts": {
  "build": "rollup -c --bundleConfigAsCjs",
  "clean": "rimraf dist",
  "coverage:badge": "coverage-badge-creator",
  "format": "prettier --write \"src/**/*.{ts,tsx}\"",
  "format:check": "prettier --list-different \"src/**/*.{ts,tsx}\"",
  "husky:configure": "npx husky install && npx husky add .husky/pre-commit \"npx --no-install lint-staged\" && npx husky add .husky/commit-msg \"npx --no-install commitlint --edit \"$1\"\"",
  "lint": "eslint \"src/**/*.{ts,tsx}\"",
  "prebuild": "npm run clean",
  "release:dry": "release-it --dry-run",
  "release": "release-it",
  "test": "jest --coverage",
  "test:watch": "jest --watch",
  "storybook:build": "storybook build",
  "storybook:dev": "storybook dev -p 6006",
  "storybook:serve": "cd storybook-static && http-server -p 6006"
}

Release scripts are using release-it package to automate the process of creating another release on GitHub. Publishing to the NPM registry is not automated, as a safety precaution in case something is wrong with the release. To be able to receive relevant suggestions related to version bump, I am using conventional commits.

{
  "git": {
    "requireBranch": "main",
    "commitMessage": "chore: release ${version}"
  },
  "hooks": {
    "before:init": [
      "npm run lint",
      "npm run format",
      "npm run test",
      "npm run coverage:badge"
    ],
    "after:bump": "npm run build"
  },
  "plugins": {
    "@release-it/conventional-changelog": {
      "infile": "CHANGELOG.md",
      "preset": {
        "name": "conventionalcommits",
        "types": [
          {
            "type": "feat",
            "section": "Features"
          },
          {
            "type": "fix",
            "section": "Bug Fixes"
          }
        ]
      }
    }
  },
  "npm": {
    "publish": false
  },
  "github": {
    "release": true
  }
}

commitlint package is enforcing the conventional commits naming.

module.exports = {
  extends: ['@commitlint/config-conventional'],
  rules: {
    //   TODO Add Scope Enum Here
    // 'scope-enum': [2, 'always', ['yourscope', 'yourscope']],
    'type-enum': [
      2,
      'always',
      [
        'feat',
        'fix',
        'docs',
        'chore',
        'style',
        'refactor',
        'ci',
        'test',
        'revert',
        'perf',
        'vercel',
      ],
    ],
  },
};

Using react-cloudinary-image-lite

Install the NPM package

npm i react-cloudinary-image-lite

Import the Image component and the Switching enum

import { Image, Switching } from 'react-cloudinary-image-lite';

For density switching you can use it like so. Make sure you replace the src of the image with your Cloudinary asset ID, the width, the height and also replace the cloudName with your product environment from the Cloudinary Console.

<Image
  src="blog/jedi-knight-3-v6_grl6fe.png"
  width={1823}
  height={1660}
  alt="jedi knight"
  switching={Switching.Density}
  quality={90}
  sizes="480px"
  cloudName="catalinworks"
  apiVersion="1"
/>

For resolution switching you can use it by setting the sizes prop like this:

<Image
  src="blog/rabbitmq_rctwjf.png"
  width={850}
  height={882}
  aspectRatio={0.9}
  switching={Switching.Resolution}
  alt="rabbitmq"
  priority
  quality={90}
  sizes="(min-width: 1536px) 500px, (min-width: 1280px) 400px, (min-width: 1024px) 400px, (min-width: 768px) 450px, 360px"
  cloudName="catalinworks"
  apiVersion="1"
/>

Or you can use a template string like this:

const imageWidths: { [key: string]: number } = {
  sm: 360,
  md: 450,
  lg: 400,
  xl: 400,
  '2xl': 500,
};

const breakpoints: { [key: string]: number } = {
  sm: 640,
  md: 768,
  lg: 1024,
  xl: 1280,
  '2xl': 1536,
};

And later in TSX

<Image
  src="blog/rabbitmq_rctwjf.png"
  width={850}
  height={882}
  aspectRatio={0.9}
  alt="rabbitmq"
  switching={Switching.Resolution}
  quality={90}
  sizes={`(min-width: ${breakpoints['2xl']}px) ${imageWidths['2xl']}px, (min-width: ${breakpoints.xl}px) ${imageWidths.xl}px, (min-width: ${breakpoints.lg}px) ${imageWidths.lg}px, (min-width: ${breakpoints.md}px) ${imageWidths.md}px, ${imageWidths.sm}px`}
  priority
  cloudName="catalinworks"
  apiVersion="1"
/>

Props marked with * are mandatory.

Prop name Type Example Value Explanation
src* string "blog/rabbitmq_rctwjf.png" check Cloudinary console for the asset ID
width* number 850
height* number 882
switching* Switching Switching.Density or Switching.Resolution
alt* string "rabbitmq"
quality* number 90
sizes* string "480px" or longer string for resolution switching (see above)
cloudName* string "catalinworks" check Cloudinary console for product environment
apiVersion* string "1"
aspectRatio number 0.9 aspect ratio to crop the original image
noPlaceholder boolean true removes the blurred preloading placeholder imag
priority boolean true sets loading="eager" and fetchpriority="high"
className string "some-class" optional prop for the parent div
style CSSProperties { marginTop: '10px' } optional prop for the parent div
placeholderClassName string "some-other-class" optional prop for the blurred preloading image placeholder
notResponsive boolean true sets flex-shrink: 0
dataIndex string "1" identifier for the onLoaded callback function
onLoaded (dataIndex: string) => void callback function triggered after image is loaded

Storybook for Image component

Inside the /src/components/Image/Image.stories.ts there are 3 stories, one for density switching, one for resolution switching and one for the case we don't want the blurred preloading placeholder image:

import type { Meta, StoryObj } from '@storybook/react';

import { Image, Switching } from './Image';

const meta = {
  title: 'catarizea/react-cloudinary-image-lite/Image',
  component: Image,
  tags: ['autodocs'],
  parameters: {
    layout: 'centered',
  },
} satisfies Meta<typeof Image>;

export default meta;

type Story = StoryObj<typeof meta>;

export const Density: Story = {
  args: {
    src: 'blog/jedi-knight-3-v6_grl6fe.png',
    width: 1823,
    height: 1660,
    alt: 'jedi knight',
    switching: Switching.Density,
    quality: 90,
    sizes: '480px',
    cloudName: 'catalinworks',
    apiVersion: '1',
  },
};

export const Resolution: Story = {
  args: {
    src: 'blog/rabbitmq_rctwjf.png',
    width: 850,
    height: 882,
    aspectRatio: 0.9,
    alt: 'rabbitmq',
    switching: Switching.Resolution,
    quality: 90,
    priority: true,
    sizes:
      '(min-width: 1536px) 500px, (min-width: 1280px) 400px, (min-width: 1024px) 400px, (min-width: 768px) 450px, 360px',
    cloudName: 'catalinworks',
    apiVersion: '1',
  },
};

export const NoPlaceholder: Story = {
  args: {
    src: 'blog/jedi-knight-3-v6_grl6fe.png',
    width: 1823,
    height: 1660,
    alt: 'jedi knight',
    switching: Switching.Density,
    quality: 90,
    sizes: '480px',
    cloudName: 'catalinworks',
    apiVersion: '1',
    noPlaceholder: true,
  },
};

React Testing Library and Jest

The tests for the Image component are making sure that image sets are rendered for both switching methods, that it throws an error if the sizes prop is not valid, that it hides the placeholder image after the main image is loaded, and that is calling the onLoad callback function after the image is loaded.

import '@testing-library/jest-dom';

import { fireEvent, render } from '@testing-library/react';
import React from 'react';

import Image, { Switching } from './Image';

describe('Image', () => {
  it('should render the image set with density switching', () => {
    const { getAllByAltText } = render(
      <Image
        alt="jedi knight"
        apiVersion="1"
        cloudName="catalinworks"
        height={1660}
        quality={90}
        sizes="480px"
        src="blog/jedi-knight-3-v6_grl6fe.png"
        switching={Switching.Density}
        width={1823}
      />,
    );

    const image = getAllByAltText('jedi knight');

    expect(image.length).toEqual(2);
    expect(image[0]).toHaveAttribute('loading', 'lazy');
    expect(image[1]).toHaveAttribute('loading', 'lazy');
    expect(image[0]).toHaveAttribute('fetchpriority', 'low');
    expect(image[1]).toHaveAttribute('fetchpriority', 'low');
    expect(image[1]).toHaveAttribute(
      'srcset',
      'https://res.cloudinary.com/catalinworks/image/upload/q_90,f_webp,w_480/v1/blog/jedi-knight-3-v6_grl6fe.png 1x, https://res.cloudinary.com/catalinworks/image/upload/q_90,f_webp,w_480,dpr_2/v1/blog/jedi-knight-3-v6_grl6fe.png 2x,  https://res.cloudinary.com/catalinworks/image/upload/q_90,f_webp,w_480,dpr_3/v1/blog/jedi-knight-3-v6_grl6fe.png 3x',
    );
  });

  it('should render the image set with resolution switching', () => {
    const { getAllByAltText } = render(
      <Image
        alt="rabbitmq"
        apiVersion="1"
        aspectRatio={0.9}
        cloudName="catalinworks"
        height={882}
        onLoaded={() => {}}
        priority
        quality={90}
        sizes="(min-width: 1536px) 500px, (min-width: 1280px) 400px, (min-width: 1024px) 400px, (min-width: 768px) 450px, 360px"
        src="blog/rabbitmq_rctwjf.png"
        switching={Switching.Resolution}
        width={850}
      />,
    );

    const image = getAllByAltText('rabbitmq');

    expect(image.length).toEqual(2);
    expect(image[0]).toHaveAttribute('loading', 'eager');
    expect(image[1]).toHaveAttribute('loading', 'eager');
    expect(image[0]).toHaveAttribute('fetchpriority', 'high');
    expect(image[1]).toHaveAttribute('fetchpriority', 'high');
    expect(image[1]).toHaveAttribute(
      'srcset',
      'https://res.cloudinary.com/catalinworks/image/upload/q_90,f_webp,c_fill,w_500,ar_0.9/v1/blog/rabbitmq_rctwjf.png 500w, https://res.cloudinary.com/catalinworks/image/upload/q_90,f_webp,c_fill,w_400,ar_0.9/v1/blog/rabbitmq_rctwjf.png 400w, https://res.cloudinary.com/catalinworks/image/upload/q_90,f_webp,c_fill,w_400,ar_0.9/v1/blog/rabbitmq_rctwjf.png 400w, https://res.cloudinary.com/catalinworks/image/upload/q_90,f_webp,c_fill,w_450,ar_0.9/v1/blog/rabbitmq_rctwjf.png 450w, https://res.cloudinary.com/catalinworks/image/upload/q_90,f_webp,c_fill,w_360,ar_0.9/v1/blog/rabbitmq_rctwjf.png 360w',
    );
  });

  it('should not render the placeholder image if noPlaceholder prop is set to true', () => {
    const { getAllByAltText } = render(
      <Image
        alt="jedi knight"
        apiVersion="1"
        cloudName="catalinworks"
        height={1660}
        quality={90}
        sizes="480px"
        src="blog/jedi-knight-3-v6_grl6fe.png"
        switching={Switching.Density}
        width={1823}
        noPlaceholder
      />,
    );

    const image = getAllByAltText('jedi knight');

    expect(image.length).toEqual(1);
    expect(image[0]).toHaveAttribute('loading', 'lazy');
    expect(image[0]).toHaveAttribute('fetchpriority', 'low');
    expect(image[0]).toHaveAttribute(
      'srcset',
      'https://res.cloudinary.com/catalinworks/image/upload/q_90,f_webp,w_480/v1/blog/jedi-knight-3-v6_grl6fe.png 1x, https://res.cloudinary.com/catalinworks/image/upload/q_90,f_webp,w_480,dpr_2/v1/blog/jedi-knight-3-v6_grl6fe.png 2x,  https://res.cloudinary.com/catalinworks/image/upload/q_90,f_webp,w_480,dpr_3/v1/blog/jedi-knight-3-v6_grl6fe.png 3x',
    );
  });

  it('should throw an error if provided with an invalid sizes prop', () => {
    expect(() =>
      render(
        <Image
          alt="jedi knight"
          apiVersion="1"
          cloudName="catalinworks"
          height={1660}
          quality={90}
          sizes="480"
          src="blog/jedi-knight-3-v6_grl6fe.png"
          switching={Switching.Density}
          width={1823}
        />,
      ),
    ).toThrow('Sizes attribute is not valid');
  });

  it('should hide the placeholder image after the image is loaded', () => {
    const { getAllByAltText } = render(
      <Image
        alt="jedi knight"
        apiVersion="1"
        cloudName="catalinworks"
        height={1660}
        quality={90}
        sizes="480px"
        src="blog/jedi-knight-3-v6_grl6fe.png"
        switching={Switching.Density}
        width={1823}
      />,
    );

    const image = getAllByAltText('jedi knight');

    expect(image[0]).toHaveAttribute('style', 'position: absolute; inset: 0;');

    fireEvent.load(image[1]);

    expect(image[0]).toHaveAttribute(
      'style',
      'position: absolute; inset: 0; display: none;',
    );
  });

  it('should call the onLoaded callback function after the image is loaded', () => {
    const mockFunc = jest.fn();

    const { getAllByAltText } = render(
      <Image
        alt="jedi knight"
        apiVersion="1"
        cloudName="catalinworks"
        height={1660}
        quality={90}
        sizes="480px"
        src="blog/jedi-knight-3-v6_grl6fe.png"
        switching={Switching.Density}
        width={1823}
        dataIndex="1"
        onLoaded={mockFunc}
      />,
    );

    const image = getAllByAltText('jedi knight');

    fireEvent.load(image[1]);

    expect(mockFunc).toHaveBeenCalledTimes(1);
  });
});