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
- React Cloudinary Image Lite Github repository
- react-cloudinary-image-lite NPM package
Primers
- Cloudinary Image and Video API Platform
- Rollup.js The JavaScript module bundler
- Storybook Build UIs without the grunt work
- Jest Delightful JavaScript Testing Framework
- React Testing Library
- ts-jest Delightful testing with Jest and Typescript
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);
});
});