In modern e-commerce applications, customers demand tailor-made products and the ability to customize their shopping experience for products.
With product variant customizations, customers can personalize the products themselves in online stores, exactly how they want them before purchase.
This post outlines how to create a customizable product component on a Next.js page.
You can find a complete version of the application on Codesandbox and GitHub.
GitHub URL: https://github.com/iamfortune/cloudinary-product-customization-component/tree/main/
Following through with this post requires basic knowledge of JavaScript and React.js.
To get started, we install the Next.js CLI and create a Next application using NPX – a node package runner. We can also use yarn to install packages and create a new project. Next.js ships with starters, and in our application, we’ll use the Next.js default starter.
To get a Next.js project started, we navigate to our desired directory and run the terminal command below:
npx create-next-app
Follow the prompt to create a Next.js project using the default starter.
This project uses Cloudinary to store and transform media assets.
Cloudinary provides a robust media experience for any media type, covering image and video storage, transformation, delivery, and optimization. We’ll use the cloudinary-react package, which allows us to utilize Cloudinary assets in our Next.js application.
We proceed to install the following dependencies using the command below:
npm install cloudinary-react react-icons
React-icons is an icon library for React applications.
TailwindCSS classes are used in styling the React components in this project. You can find how to install Tailwind in a Next.js project here.
The product customizer has the following parts:
- A color selector with a custom color picker
- A texture selector from two provided textures
- A logo selector
- A text input to add free text.
- A rendered image of the product that updates with each modification.
We’ll proceed to implement each component.
We create a new directory called components
, which will contain our color schemes, elements and layout for our application.
In the components
folder, we create new folder called Colors
and in it, we create a new file named ColorDropPicker.jsx
that will house the custom color picker of our product customizer. Next, we import all required dependencies in the file.
// components/Colors/ColorDropPicker.jsx
import { FaCaretDown } from 'react-icons/fa';
Code language: JavaScript (javascript)
FaCaretDown
is a caret icon component from react-icons. With it, users can toggle to display a list of colors to choose from.
const ColorDropPicker = ({ onChange }) => {
return (
<span className={`flex flex-row justify-between items-center border border-gray-500 bg-gray-200`} style={{ width: 52, padding: `10px auto` }}>
<input
type={`color`}
style={{ width: 32, height: 32 }}
className={`outline-none cursor-pointer`}
onChange={ e => onChange(e.currentTarget.value.slice(1)) }
// slice to remove the '#' at the beginning of the hex color code
/>
<FaCaretDown size={12} />
</span>
);
};
export default ColorDropPicker;
Code language: JavaScript (javascript)
We created a component ColorDropPicker
which takes in an onChange
prop. The component returned an input for a user to input colors that will apply to the product. We defined some default color schemes, to update a product’s color scheme with one chosen by a user. We used the native JavaScript Slice to remove the #
in the array containing our default color code.
Finally, we set a size of 12 to faCaretDown
.
In the Colors directory, we proceed to create a new file called ColorPickerSquare.jsx
. This component will display a square containing the color selected using the color picker.
const ColorPickerSquare = ({ color, onSelect, className, isFluid, isFocused, onClick }) => {
const squareImage = `https://res.cloudinary.com/demo-robert/image/upload/w_30,h_30/e_replace_color:${color}:60:white/l_heather_texture,o_0,w_30,h_30,c_crop/white-bar.jpg`;
return (
<div
className={`${isFocused && 'border-2 border-gray-500'} transition duration-300 cursor-pointer ${className} ${color == 'white' || color == 'ffffff' ? 'border border-gray-500' : null}`}
style={{
backgroundColor: color ?? 'transparent', width: !isFluid ? 32 : `auto`, height: !isFluid ? 32 : `auto`,
backgroundImage: `url('${squareImage}')`, backgroundRepeat: `no-repeat`, backgroundPosition: `center`,
minWidth: !isFluid ? 32 : isFluid[0], minHeight: !isFluid ? 32 : isFluid[1]
}}
onClick={ e => {
onSelect ? onSelect(e) : null;
onClick ? onClick(e): null;
}}
>
</div>
);
};
export default ColorPickerSquare;
Code language: JavaScript (javascript)
In the code above, we added a square image from Cloudinary which accepts a variable color transformation. This color will be updated based on the user selected product color. We added variable tailwind classes to the component to handle transitions and border styling.
To the square component, we added styles for background color, image, minimum height and width. The functions passed to the component via the onClick
and onSelect
props are also propagated to the component’s onClick
handler.
Next, in the Colors directory, we create a new file called ColorPickerArray.jsx
. This component will contain an array of data for specific product colors, CSS classes for each color, and an array of width and height for each color.
// components/Colors/ColorPickerArray.jsx
import { useState } from "react";
import { ColorPickerSquare } from ".";
const ColorPickerArray = ({ colorsArray, elementClassName, isFluid, onSelect }) => {
// SET INDEX OBJECT TO AUTO-FOCUSED
const [focusedSquare, setFocusedSquare] = useState(0);
return (
<div className={`flex flex-row`}>
{
colorsArray.map((color, index) => (
<ColorPickerSquare
isFocused={ index === focusedSquare }
onClick={ e => setFocusedSquare(index) }
color={color}
onSelect={e => onSelect ? onSelect(e, index) : null}
className={elementClassName}
isFluid={isFluid}
/>
))
}
</div>
);
};
export default ColorPickerArray;
Code language: JavaScript (javascript)
In the file above, we initialized a function component called ColorPickerArray
which takes in colorsArray
, elementClassName
, isFluid
and onSelect
as props.
We created a state variable using the useState
hook to store the index of any selected color. Using the native JavaScript map method, we loop through the provided colors and render a ColorPickerSquare
component for each.
We require custom text in a select number of fonts which we will specify. We create a new component in components/FontPicker/index.js
with:
export const FontPicker = ({ fonts, className, onChange, value }) => {
const renderOptionElements = () =>
fonts.map((font) => (
<option
key={font.id}
value={font.value}
selected={font.selected}
hidden={font.hidden}
>
{font.title}
</option>
));
return (
<select
key={className.id}
className={className}
onChange={(e) => (onChange ? onChange(e) : null)}
value={value}
>
{renderOptionElements()}
</select>
);
};
Code language: JavaScript (javascript)
In the created FontPicker
component, we defined props of fonts
, className
, onChange
, and value
. Using the native Javascript map object, we iterate over the list of fonts and display the font title. The list of font options are rendered in a select
element and selecting a font option triggers the function passed in the onChange
props.
We require logo overlays on the product. These logos can be selected from a list of rendered square logos. We will create an overlay selector which renders a provided logo enclosed in a square. In a new component components/Overlays/OverlayPicker.jsx
, we create a component which renders the overlay selector.
export const OverlayPickerSquare = ({
onSelect,
className,
isFluid,
isFocused,
onClick,
image
}) => {
return (
<div
className={`${
isFocused && "border-2 border-gray-500"
} transition duration-300 cursor-pointer ${className}`}
style={{
width: !isFluid ? 32 : `auto`,
height: !isFluid ? 32 : `auto`,
backgroundImage: `url('${image}')`,
backgroundRepeat: `no-repeat`,
backgroundPosition: `center`,
minWidth: !isFluid ? 32 : isFluid[0],
minHeight: !isFluid ? 32 : isFluid[1]
}}
onClick={(e) => {
onSelect ? onSelect(e) : null;
onClick ? onClick(e) : null;
}}
>
</div>
);
};
Code language: JavaScript (javascript)
In the code snippet above, we created and exported an Over``lay``PickerSquare
component having props of onSelect
, className
, isFluid
, isFocused
, onClick
, and image
. We added custom style properties to the component and triggered the onSelect
and onClick
functions if passed, to the component when it’s clicked.
To complete the overlay picker, we’ll create a parent component that renders multiple overlay squares once we pass an array of image logos to it. We do this in a new component components/Overlays/OverlayPickerArray.jsx
with:
import { useState } from "react";
import { OverlayPickerSquare } from "./OverlayPicker";
export const OverlayPickerArray = ({
overlaysArray,
elementClassName,
isFluid,
onSelect
}) => {
// SET INDEX OBJECT TO AUTO-FOCUSED
const [focusedSquare, setFocusedSquare] = useState(0);
return (
<div className={`flex flex-row`}>
{overlaysArray.map((overlay, index) => (
<OverlayPickerSquare
isFocused={index === focusedSquare}
onClick={(e) => setFocusedSquare(index)}
onSelect={(e) => (onSelect ? onSelect(e, overlay.overlay) : null)}
className={elementClassName}
isFluid={isFluid}
image={overlay.image}
/>
))}
</div>
);
};
Code language: JavaScript (javascript)
In the code snippet above, we created the OverlayPickerArray
component which takes props of provided logos with overlaysArray
, loops through each overlay and renders the imported OverlayPickerSquare
component for each logo.
The index of the currently selected square is stored in a focusedSquare
state variable for use in the isFocused
prop value of OverlayPickerSquare
.
Furthermore, we require a texture picker. This is similar to the overlay picker. We create similar components of TexturePickerSquare
and TexturePickerArray
in components/Textures
to create squares of product texture selectors and render an array of provided textures.
In components/Textures/TexturePickerSquare.jsx
we have:
export const TexturePickerSquare = ({
onSelect,
className,
isFluid,
isFocused,
onClick,
image
}) => {
return (
<div
className={`${
isFocused && "border-2 border-gray-500"
} transition duration-300 cursor-pointer ${className}`}
style={{
width: !isFluid ? 32 : `auto`,
height: !isFluid ? 32 : `auto`,
backgroundImage: `url('${image}')`,
backgroundRepeat: `no-repeat`,
backgroundPosition: `center`,
minWidth: !isFluid ? 32 : isFluid[0],
minHeight: !isFluid ? 32 : isFluid[1]
}}
onClick={(e) => {
onSelect ? onSelect(e) : null;
onClick ? onClick(e) : null;
}}
>
</div>
);
};
Code language: JavaScript (javascript)
And in component/Textures/TexturePickerArray.jsx
we have:
Comment: [William] No, the current naming is correct
import { useState } from "react";
import { TexturePickerSquare } from "./TexturePicker";
export const TexturePickerArray = ({
texturesArray,
elementClassName,
isFluid,
onSelect
}) => {
// SET INDEX OBJECT TO AUTO-FOCUSED
const [focusedSquare, setFocusedSquare] = useState(0);
return (
<div className={`flex flex-row`}>
{texturesArray.map((texture, index) => (
<TexturePickerSquare
key={texture.id}
isFocused={index === focusedSquare}
onClick={(e) => setFocusedSquare(index)}
onSelect={(e) => (onSelect ? onSelect(e, texture.texture) : null)}
className={elementClassName}
isFluid={isFluid}
image={texture.image}
/>
))}
</div>
);
};
Code language: JavaScript (javascript)
In this component, we’ll create an image viewer, which will act as a wrapper for our image. To do this, inside the components
directory we create a new folder called ImageViewer
and in it, create an index.j``sx
file with the following content:
import {CloudinaryContext, Transformation, Image} from 'cloudinary-react';
const ProductImage = ({ alt, displacement, productColor, overlay, texture, text, font}) => {
return (
<CloudinaryContext cloudName="demo-robert">
<Image publicId="Hanging_T-Shirt_v83je9" width="650" secure="true">
<Transformation width="300" fetchFormat="auto" />
<Transformation overlay={texture} quality="auto" fetchFormat="auto" />
<Transformation overlay={overlay} quality="auto" fetchFormat="auto" width="110" />
<Transformation overlay={{fontFamily: font, fontSize: 33, fontWeight: "bold", text: text}} gravity="center" y={ overlay === '' ? 40 : 130 } color={`#666`} />
<Transformation rawTransformation={`e_replace_color:${productColor}`} />
</Image>
</CloudinaryContext>
);
};
export default ProductImage;
Code language: JavaScript (javascript)
In the code above, we wrapped our ProductImage
component in CloudinaryContext
. Cloudinary context specifies the cloud_name
, with which we fetch media assets from Cloudinary. All data passed to CloudinaryContext
as props are provided to its children Cloudinary components.
To obtain our
cloud_name
, we need to create an account on Cloudinary and get the cloud name value from our Cloudinary dashboard.
At the crust of this application is Cloudinary’s robust transformation capabilities.
We rendered an image from Cloudinary using the Image
component, and applied Cloudinary transformations to it using the Transformation
component. We specified transformations for texture, an overlain text, general overlays and color.
To create our project’s navbar, we create a new folder called base
inside our component directory, and inside it we create a new file Navbar.jsx
. We create a Navbar
component with the following:
import Link from "next/link";
export const Navbar = ({ className }) => {
return (
<nav>
<div
className={`flex flex-row justify-start items-end font-lighter pb-3 pt-2 ${className}`}
>
<div>
<Link href="/">
<img
src="https://res-1.cloudinary.com/cloudinary/image/asset/dpr_2.0/logo-e0df892053afd966cc0bfe047ba93ca4.png"
width={172}
height={38}
alt="logo"
/>
</Link>
</div>
<h1
className="capitalize text-blue-deep relative top-2 ml-3"
style={{ fontSize: 26 }}
>
Product personalization demo
</h1>
</div>
</nav>
);
};
Code language: JavaScript (javascript)
In the code above, we added a logo with a home
route path. Next, we added a title to the navbar using a h1
tag.
We created a wrapper component for the page which contains the navigation bar and houses all other child elements. In the components
directory, we created a file with the path layout/page.jsx
and the following content:
import Head from "next/head";
import { Navbar } from "../Base/Navbar";
export const Page = ({ children }) => {
return (
<div className="min-h-screen relative h-screen max-w-full">
<Head>
<meta name="author" content="Fortune Ikechi" />
<meta name="description" content="Cloudinary Product Personalization" />
<meta name="viewport" content="initial-scale=1.0, width=device-width" />
<title>Product Personalization Demo</title>
</Head>
<div className={`flex flex-col items-center min-h-screen w-full`}>
<div className={`w-full pl-44`}>
<Navbar className={`w-8/12 ml-20`} />
</div>
{children}
</div>
</div>
);
};
Code language: JavaScript (javascript)
In the code above, we imported our Navbar
component from the B``ase
directory. Next, we created a Page
component that will take in a children
prop. In the component’s head
element, we added the author and page information in meta
tags before rendering a child element or component.
Part of the product customization requires we add text content on the product. To do this, in the Base
directory, we create a new file TextInput.jsx
with the following content:
export const DefaultTextInput = ({ className, onChange, placeholder }) => {
return (
<input
type="text"
className={`${className} default-styled-input`}
onChange={(e) => (onChange ? onChange(e) : null)}
placeholder={placeholder || ""}
/>
);
};
Code language: JavaScript (javascript)
Here, we created a component DefaultTextInput
with an input element that takes a text
type and an onChange
function for updating the input text, we also added a placeholder text.
Similar to a default text input, we will create a default button component with an onClick
function and a title tag for the button. In the Base
folder, create a new file DefaultButton.jsx
with the content:
export const DefaultButton = ({ title, onClick }) => {
return (
<button
className={`bg-orange rounded-sm px-5 py-1 text-white flex flex-row items-center justify-center outline-none focus:ring-2 focus:ring-yellow-300`}
onClick={(e) => (onClick ? onClick(e) : null)}
>
{title}
</button>
);
};
Code language: JavaScript (javascript)
In this section, we’ll put together all the different parts of our application. To do this, we navigate to the pages/i``ndex.js
file and add all the features needed by the user to customize a product.
import { useState, useEffect } from 'react';
import { ProductImage } from "../components/ImageViewer";
import { Page } from "../components/layout/Page";
import { ColorDropPicker } from "../components/Colors/ColorDropPicker";
import { ColorPickerArray } from "../components/Colors/ColorPickerArray";
import { FontPicker } from "../components/FontPicker";
import { DefaultTextInput } from "../components/Base/TextInput";
import { DefaultButton } from "../components/Base/DefaultButton";
import { OverlayPickerArray } from "../components/Overlays/OverlayPickerArray";
import { TexturePickerArray } from "../components/Textures/TexturePickerArray";
export default function Home() {
// COLORS
const availableColors = [`ffffff`, `47E8D2`, `DCA381`, `702C3C`, `E9C660`, `A11D1F`, `897115`, `598DE6`];
//OVERLAYS
const overlayOptionsArray = [
{ image: `https://res.cloudinary.com/demo-robert/image/upload/w_30,h_30,e_red:0/e_green:0/e_blue:0/l_heather_texture,o_0,w_30,h_30,c_crop/white-bar.jpg`, overlay: `` },
{ image: `https://res.cloudinary.com/demo-robert/image/upload/q_auto,f_auto,h_30/cloudinary-logo.jpg`, overlay: `cloudinary-logo` },
{ image: `https://res.cloudinary.com/demo-robert/image/upload/q_auto,f_auto,h_30/fire.png`, overlay: `fire` }
];
Code language: JavaScript (javascript)
Here, we created data for customizable colors and the overlain logo.
Next, we add default data for textures, and fonts.
// TEXTURES
const textureOptionsArray = [
{ image: `https://res.cloudinary.com/demo-robert/image/upload/w_30,h_30,e_red:0/e_green:0/e_blue:0/l_heather_texture,o_0,w_30,h_30,c_crop/white-bar.jpg`, texture: `` },
{ image: `https://res.cloudinary.com/demo-robert/image/upload/w_30,h_30,e_red:0/e_green:0/e_blue:0/l_heather_texture,o_30,w_30,h_30,c_crop/white-bar.jpg`, texture: `hanging-shirt-texture` },
];
// PERSONALIZATION TEXT FONTS
const availableFonts = [
{ title: `Arial`, value: `Arial`, selected: true },
{ title: `Georgia`, value: `Georgia` },
{ title: `Sacremento`, value: `Sacramento` },
{ title: `Roboto`, value: `Roboto` },
{ title: `Montserrat`, value: `Montserrat` },
{ title: `Bitter`, value: `Bitter` }
];
Code language: JavaScript (javascript)
Next, we add data on perspectives and displacements for a user’s product:
// PERSPECTIVES
const imagePerspectives = [
{ image: `https://res.cloudinary.com/demo-robert/q_auto,f_auto/$text_!%20!/o_0/l_sample,o_0,w_220,ar_30:25,c_fit,y_-40,x_-5,e_overlay/l_text:arial_100_bold:$(text),y_90,co_rgb:333,o_70,w_250/l_hanging_displace,e_displace,x_10,y_10/u_Hanging_T-Shirt_v83je9,e_replace_color:white:60:white/l_hanging-shirt-texture,o_0/l_Hanger_qa2diz,fl_relative,w_1.0/w_75,ar_1:1,c_pad/shirt_only.jpg` },
{ image: `https://res.cloudinary.com/demo-robert/q_auto,f_auto/$text_!%20!/o_0/l_sample,o_0,w_330,ar_30:25,c_fit,y_-30,x_-5,e_overlay/l_text:arial_100_bold:$(text),y_150,co_rgb:333,o_70,w_350/l_laying_displace,e_displace,x_10,y_10/u_laying-shirt_xqstgr,e_replace_color:white:60:white/l_laying-shirt-texture,o_0/w_75,ar_1:1,c_pad/shirt_only.jpg` },
{ image: `https://res.cloudinary.com/demo-robert/q_auto,f_auto/$text_!%20!/o_0/l_sample,o_0,w_300,ar_30:25,c_fit,y_-200,x_-5,e_overlay/l_text:arial_100_bold:$(text),y_-40,co_rgb:333,o_70,w_300/l_shirt_displace,e_displace,x_10,y_10/u_shirt_only,e_replace_color:white:60:white/l_heather_texture,o_0/u_model2/w_75,ar_1:1,c_pad/shirt_only.jpg` },
];
// DISPLACEMENTS
const availableImageDisplacements = [
`hanging_displace`, `laying_displace`, `shirt_displace`
];
Code language: JavaScript (javascript)
To complete our customization component, we will add functions enabling users to add or update the color, texture, text, images, and logos on products.
// PRODUCT IMAGE
const [productColor, setProductColor] = useState(availableColors[0]);
const [productOverlay, setProductOverlay] = useState('');
const [productTexture, setProductTexture] = useState('');
const [productText, setProductText] = useState(' '); // space important here, or image will not be displayed
const [tempText, setTempText] = useState('');
const [productFont, setProductFont] = useState(availableFonts[0].value);
const [productDisplacement, setProductDisplacement] = useState(availableImageDisplacements[1]);
// CHANGE TEXT ON TEXT OVERLAY
const changeProductText = e => {
tempText == '' ? setProductText(' ') : setProductText(tempText);
};
Code language: JavaScript (javascript)
We created state variables with their update methods for each transformation type. We also created a function to update or add a text on a product. Next, we will update the rendered JSX with the components, state variables and update methods as in this final GitHub Gist.
https://gist.github.com/Chuloo/cc50dcb4db88e695f9d7025dfd42b818
https://gist.github.com/Chuloo/cc50dcb4db88e695f9d7025dfd42b818
Our final application should look like this:
In this post, we created a product variant customization component using Next.js and Cloudinary. Customization features include color, text, texture, and logo. Furthermore, we can improve the application by utilizing other image transformation properties.
You can check out the following valuable resources.