In this short tutorial, we’ll look at how we can extract colors from an image, generate a color palette and use it to style different elements using CSS Variables.
The final project can be viewed on Codesandbox.
The completed project is available on Codesandbox.
Ensure you have Node.js and NPM installed. Have a look at the official Node.js website to learn how you can install it. This tutorial also assumes that you have basic knowledge of Javascript, Node.js, and React/Next.js.
We’re going to be using Cloudinary to store our images. Cloudinary provides an API that allows us to store and optimize media. It’s easy to get started and you can do it for free. They also have amazing documentation that is easy to follow. Let’s get started.
Sign in to Cloudinary or create a new account. Once that’s done, make your way to the console. You will notice your API credentials in the top left corner.
Pay particular attention to your Cloud name
API Key
and API Secret
. You can take note of these since we’ll be using them later.
The first thing we need to do is create a new Next js project. Open your terminal/command line in your desired folder and run the following command.
npx create-next-app
You will be prompted to give your application a name. Just give it any appropriate name. If you’re following along, I named mine `nextjs-color-palette-generator. This will create a basic Next.js app. If you’d like to use features such as Typescript, have a look at the official docs. Switch into the newly created project.
cd nextjs-color-palette-generator
Finally, open your project in your favorite code editor.
Before we proceed any further, let’s install the Cloudinary NPM package. We’ll use this to communicate with their API
npm install --save cloudinary
Create a new folder called lib
at the root of your new project. Inside the lib
folder, create a file called cloudinary.js
and paste the following code inside.
// Import the v2 api and rename it to cloudinary
import { v2 as cloudinary } from "cloudinary";
// Initialize the SDK with cloud_name, api_key, and api_secret
cloudinary.config({
cloud_name: process.env.CLOUD_NAME,
api_key: process.env.API_KEY,
api_secret: process.env.API_SECRET,
});
export const handleCloudinaryUpload = (path) => {
// Create and return a new Promise
return new Promise((resolve, reject) => {
// Use the SDK to upload media
cloudinary.uploader.upload(
path,
{
// Folder to store video in
folder: "images/",
// Type of resource
resource_type: "image",
},
(error, result) => {
if (error) {
// Reject the promise with an error if any
return reject(error);
}
// Resolve the promise with a successful result
return resolve(result);
}
);
});
};
Code language: JavaScript (javascript)
We first import the v2
API and rename it to cloudinary
for better readability. We then initialize the SDK by calling the config
method with the cloud_name
, api_key
, and api_secret
. We’ve used environment variables that we have not defined yet. Let’s do that. Create a file called .env.local
at the root of your project and paste the following inside
CLOUD_NAME=YOUR_CLOUD_NAME
API_KEY=YOUR_API_KEY
API_SECRET=YOUR_API_SECRET
Don’t forget to replace YOUR_CLOUD_NAME
YOUR_API_KEY
and YOUR_API_SECRET
with the appropriate values that we got from the Obtaining Cloudinary Credentials
section above. You can learn more about support for environment variables in Next.js from the official docs
In our lib/cloudinary.js
file we also have a function called handleCloudinaryUpload
. This function takes in a path to the file we want to upload. We then call the uploader.upload
method on the SDK. Read more about the upload options from the official documentation. That’s it for that file. Let’s move on to the next step.
API routes are a core concept of Next.js. I highly recommend you have some knowledge of how they work. The official docs is a great place to get started.
Create a new file called images.js
under the pages/api
folder. Paste the following code inside
// pages/api/images.js
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import { handleCloudinaryUpload } from "../../lib/cloudinary";
export const config = {
api: {
bodyParser: false,
},
};
export default async function handler(req, res) {
switch (req.method) {
case "POST": {
try {
const result = await handlePostRequest(req);
return res.status(200).json({ message: "Success", result });
} catch (error) {
return res.status(400).json({ message: "Error", error });
}
}
default: {
return res.status(405).json({ message: "Method not allowed" });
}
}
}
const handlePostRequest = async (req) => {
const data = await parseForm(req);
const uploadResult = await handleCloudinaryUpload(data?.files?.file.path);
return { uploadResult };
};
Code language: JavaScript (javascript)
We’re importing the handleCloudinaryUpload
function we created earlier. We’re going to be using a custom parser to get the uploaded file so we’re using a custom config for our route’s API middleware. Our API route handle switches the HTTP request method to handle only the POST request and returning a failure response for all other HTTP methods. In our handlePostRequest
we parse the incoming form to get the uploaded file then upload that file to cloudinary and return the upload result. You’ll quickly notice that we haven’t defined parseForm
yet. Now is a good time to do that.
We will use a package called Formidable to parse the form. Run the following in your terminal, inside your project folder’s root to install.
npm install --save formidable
Add the following import at the top of pages/api/images.js
// pages/api/images.js
import { IncomingForm, Fields, Files } from "formidable";
Code language: JavaScript (javascript)
and add the following function in the same file :
// pages/api/images.js
/**
*
* @param {*} req
* @returns {Promise<{ fields:Fields; files:Files; }>}
*/
const parseForm = (req) => {
return new Promise((resolve, reject) => {
const form = new IncomingForm({ keepExtensions: true, multiples: true });
form.parse(req, (error, fields, files) => {
if (error) {
return reject(error);
}
return resolve({ fields, files });
});
});
};
Code language: JavaScript (javascript)
Read about the Formidable API to better understand what’s happening here. We’re creating a new incoming form and then using that to parse the incoming request that includes the image being uploaded.
There’s one last piece to the puzzle. We need to generate a color palette from the image. We can do this either on the frontend after we’ve uploaded our image to cloudinary or do it on the backend before we upload the image to cloudinary. We’ll go with the latter. Let me explain the decision. With cloudinary, you can apply transformations to your image before uploading. Have a look at the Transformation URL docs and the Upload docs. If we have a color palette ready before we upload the image we can use some of the colors and apply them to our transformations. Enough talk, let’s implement it.
Install the node-vibrant package
npm install --save node-vibrant
Add the following import to the top of pages/api/images.js
// pages/api/images.js
import * as Vibrant from "node-vibrant";
Code language: JavaScript (javascript)
Modify handlePostRequest
to read like so :
const handlePostRequest = async (req) => {
const data = await parseForm(req);
const palette = await Vibrant.from(data?.files?.file.path).getPalette();
const uploadResult = await handleCloudinaryUpload(data?.files?.file.path);
return { palette, uploadResult };
};
Code language: JavaScript (javascript)
Now we’re using the node-vibrant
package to generate a color palette from the image then proceeding to upload the image to cloudinary and returning both the palette and the upload result. With this, if you wish to apply transformations to your images using the colors you can do so as you upload. We won’t be doing that in this tutorial though.
Here’s the complete pages/api/images.js
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import { IncomingForm, Fields, Files } from "formidable";
import * as Vibrant from "node-vibrant";
import { handleCloudinaryUpload } from "../../lib/cloudinary";
export const config = {
api: {
bodyParser: false,
},
};
export default async function handler(req, res) {
switch (req.method) {
case "POST": {
try {
const result = await handlePostRequest(req);
return res.status(200).json({ message: "Success", result });
} catch (error) {
return res.status(400).json({ message: "Error", error });
}
}
default: {
return res.status(405).json({ message: "Method not allowed" });
}
}
}
const handlePostRequest = async (req) => {
const data = await parseForm(req);
const palette = await Vibrant.from(data?.files?.file.path).getPalette();
const uploadResult = await handleCloudinaryUpload(data?.files?.file.path);
return { palette, uploadResult };
};
/**
*
* @param {*} req
* @returns {Promise<{ fields:Fields; files:Files; }>}
*/
const parseForm = (req) => {
return new Promise((resolve, reject) => {
const form = new IncomingForm({ keepExtensions: true, multiples: true });
form.parse(req, (error, fields, files) => {
if (error) {
return reject(error);
}
return resolve({ fields, files });
});
});
};
Code language: JavaScript (javascript)
With that, we’re now ready to move on to the front end.
CSS variables are a powerful tool in web frontend development. And today we’re going to be leveraging their power. MDN Web Docs define them as entities defined by CSS authors that contain specific values to be reused throughout a document. Read more about them from MDN Web Docs
Open styles/globals.css
and paste the following code inside
:root {
--primary-color: #001aff;
--secondary-color: #ffd000;
--background-color: #ae00ff;
}
Code language: CSS (css)
What we’ve done here is define three CSS variables namely primary-color,
secondary-color, and background-color. These can be named whatever you want and can be any valid CSS value. For our use case, we're using them to define three colors. The variables are defined under the
:root` selector which selects the root node, usually the html element.
Open pages/index.js
and paste the following code inside
import Head from "next/head";
import Image from "next/image";
import { useState } from "react";
import { Palette } from "@vibrant/color";
export default function Home() {
/**
* Holds the selected image file
* @type {[File,Function]}
*/
const [file, setFile] = useState(null);
/**
* Holds the uploading/loading state
* @type {[boolean,Function]}
*/
const [loading, setLoading] = useState(false);
/**
* Holds the result of the upload. This contains the cloudinary upload result and the color palette
* @type {[{palette:Palette,uploadResult:UploadApiResponse},Function]}
*/
const [result, setResult] = useState();
const handleFormSubmit = async (e) => {
e.preventDefault();
setLoading(true);
try {
const formData = new FormData(e.target);
const response = await fetch("/api/images", {
method: "POST",
body: formData,
});
const data = await response.json();
if (response.ok) {
setResult(data.result);
// Get the root document
const htmlDoc = document.querySelector("html");
// Set the primary color CSS variable to the palette's DarkVibrant color
htmlDoc.style.setProperty(
"--primary-color",
`rgb(${data.result.palette.DarkVibrant.rgb.join(" ")})`
);
// Set the secondary color CSS variable to the palette's Muted color
htmlDoc.style.setProperty(
"--secondary-color",
`rgb(${data.result.palette.Muted.rgb.join(" ")})`
);
// Set the background color CSS variable to the palette's Vibrant color
htmlDoc.style.setProperty(
"--background-color",
`rgb(${data.result.palette.Vibrant.rgb.join(" ")})`
);
return;
}
throw data;
} catch (error) {
// TODO: Show error message to the user
console.error(error);
} finally {
setLoading(false);
}
};
return (
<div className="">
<Head>
<title>Generate Color Palette with Next.js</title>
<meta
name="description"
content="Generate Color Palette with Next.js"
/>
<link rel="icon" href="/favicon.ico" />
</Head>
<main className="container">
<div className="header">
<h1>Generate Color Palette with Next.js</h1>
</div>
{!result && (
<form className="upload" onSubmit={handleFormSubmit}>
{file && <p>{file.name} selected</p>}
<label htmlFor="file">
<p>
<b>Tap To Select Image</b>
</p>
</label>
<br />
<input
type="file"
name="file"
id="file"
accept=".jpg,.png"
multiple={false}
required
disabled={loading}
onChange={(e) => {
const file = e.target.files[0];
setFile(file);
}}
/>
<button type="submit" disabled={loading || !file}>
Upload Image
</button>
</form>
)}
{loading && (
<div className="loading">
<hr />
<p>Please wait as the image uploads</p>
<hr />
</div>
)}
{result && (
<div className="image-container">
<div className="image-wrapper">
<Image
className="image"
src={result.uploadResult.secure_url}
alt={result.uploadResult.secure_url}
layout="fill"
></Image>
<div className="palette">
{Object.entries(result.palette).map(([key, value], index) => (
<div
key={index}
className="color"
style={{
backgroundColor: `rgb(${value.rgb.join(" ")})`,
}}
>
<b>{key}</b>
</div>
))}
</div>
</div>
</div>
)}
</main>
</div>
);
}
Code language: JavaScript (javascript)
A basic react component. We have a few useState
hooks to store the selected image file state, loading state, and the result from the call to /api/images
endpoint. We also have a function that will handle the form submission. The function posts the form data to the /api/images
endpoint that we created earlier. It then updates the resulting state with the result. Remember that the result contains the generated palette and the cloudinary upload result. The function then updates the CSS variables that we just defined to a few colors from the palette. The palette contains 6 color swatches: Vibrant,DarkVibrant,LightVibrant,Muted,DarkMuted,LightMuted. Here we’re only using the DarkVibrant,Muted, and Vibrant swatches to set the --primary-color,
–secondary-color, and
–background-color variables respectively. Here’s how you can get the actual color from a Swatch.
Moving on to the HTML, we have a form and input for image selection. Below that we have a container that will show the uploaded image and also a container that shows the colors in the palette. The colors on the page have been set to the CSS variables we defined. Once the image has been uploaded, the variables are set to some colors from the generated palette, consequently, the colors on the page will change to match those in the image.
Here’s the full code for pages/index.js
, including the CSS
import Head from "next/head";
import Image from "next/image";
import { useState } from "react";
import { Palette } from "@vibrant/color";
export default function Home() {
/**
* Holds the selected image file
* @type {[File,Function]}
*/
const [file, setFile] = useState(null);
/**
* Holds the uploading/loading state
* @type {[boolean,Function]}
*/
const [loading, setLoading] = useState(false);
/**
* Holds the result of the upload. This contains the cloudinary upload result and the color palette
* @type {[{palette:Palette,uploadResult:UploadApiResponse},Function]}
*/
const [result, setResult] = useState();
const handleFormSubmit = async (e) => {
e.preventDefault();
setLoading(true);
try {
const formData = new FormData(e.target);
const response = await fetch("/api/images", {
method: "POST",
body: formData,
});
const data = await response.json();
if (response.ok) {
setResult(data.result);
// Get the root document
const htmlDoc = document.querySelector("html");
// Set the primary color CSS variable to the palette's DarkVibrant color
htmlDoc.style.setProperty(
"--primary-color",
`rgb(${data.result.palette.DarkVibrant.rgb.join(" ")})`
);
// Set the secondary color CSS variable to the palette's Muted color
htmlDoc.style.setProperty(
"--secondary-color",
`rgb(${data.result.palette.Muted.rgb.join(" ")})`
);
// Set the background color CSS variable to the palette's Vibrant color
htmlDoc.style.setProperty(
"--background-color",
`rgb(${data.result.palette.Vibrant.rgb.join(" ")})`
);
return;
}
throw data;
} catch (error) {
// TODO: Show error message to the user
console.error(error);
} finally {
setLoading(false);
}
};
return (
<div className="">
<Head>
<title>Generate Color Palette with Next.js</title>
<meta
name="description"
content="Generate Color Palette with Next.js"
/>
<link rel="icon" href="/favicon.ico" />
</Head>
<main className="container">
<div className="header">
<h1>Generate Color Palette with Next.js</h1>
</div>
{!result && (
<form className="upload" onSubmit={handleFormSubmit}>
{file && <p>{file.name} selected</p>}
<label htmlFor="file">
<p>
<b>Tap To Select Image</b>
</p>
</label>
<br />
<input
type="file"
name="file"
id="file"
accept=".jpg,.png"
multiple={false}
required
disabled={loading}
onChange={(e) => {
const file = e.target.files[0];
setFile(file);
}}
/>
<button type="submit" disabled={loading || !file}>
Upload Image
</button>
</form>
)}
{loading && (
<div className="loading">
<hr />
<p>Please wait as the image uploads</p>
<hr />
</div>
)}
{result && (
<div className="image-container">
<div className="image-wrapper">
<Image
className="image"
src={result.uploadResult.secure_url}
alt={result.uploadResult.secure_url}
layout="fill"
></Image>
<div className="palette">
{Object.entries(result.palette).map(([key, value], index) => (
<div
key={index}
className="color"
style={{
backgroundColor: `rgb(${value.rgb.join(" ")})`,
}}
>
<b>{key}</b>
</div>
))}
</div>
</div>
</div>
)}
</main>
<style jsx>{`
main {
width: 100%;
height: 100vh;
background-color: var(--background-color);
display: flex;
flex-flow: column;
justify-content: flex-start;
align-items: center;
}
main .header {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
background-color: var(--secondary-color);
padding: 0 40px;
color: white;
}
main .header h1 {
-webkit-text-stroke: 1px #000000;
}
main .loading {
color: white;
}
main form {
width: 50%;
padding: 20px;
display: flex;
flex-flow: column;
justify-content: center;
align-items: center;
border-radius: 5px;
margin: 20px auto;
background-color: #ffffff;
}
main form label {
height: 100%;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
background-color: #777777;
color: #ffffff;
border-radius: 5px;
}
main form label:hover:not([disabled]) {
background-color: var(--primary-color);
}
main form input {
opacity: 0;
width: 0.1px;
height: 0.1px;
}
main form button {
padding: 15px 30px;
border: none;
background-color: #e0e0e0;
border-radius: 5px;
color: #000000;
font-weight: bold;
font-size: 18px;
}
main form button:hover:not([disabled]) {
background-color: var(--primary-color);
color: #ffffff;
}
main div.image-container {
position: relative;
width: 100%;
flex: 1 0;
}
main div.image-container .image-wrapper {
position: relative;
margin: auto;
width: 80%;
height: 100%;
}
main div.image-container div.image-wrapper .image-wrapper .image {
object-fit: cover;
}
main div.image-container .image-wrapper .palette {
width: 100%;
height: 150px;
position: absolute;
bottom: 0;
left: 0;
background-color: rgba(255, 255, 255, 50%);
display: flex;
flex-flow: row nowrap;
justify-content: flex-start;
}
main div.image-container .image-wrapper .palette .color {
flex: 1;
margin: 5px;
}
main div.image-container .image-wrapper .palette .color b {
background-color: #ffffff;
padding: 0 5px;
}
`}</style>
</div>
);
}
Code language: JavaScript (javascript)
There’s one final thing we need to do. We’re using the Image
component from Next.js which optimizes images. Read about it here. When we use this component to load and display external images, we need to add the respective domains to a whitelist. This is better explained here. For our use case, we need to add the Cloudinary domain.
Open next.config.js
and modify the code to include images config in the module exports
// next.config.js
module.exports = {
// ... other settings
images: {
domains: ["res.cloudinary.com"],
},
};
Code language: JavaScript (javascript)
Our App is ready. You can preview it by running
npm run dev
Now, go ahead and select an image and upload it. Once the upload is complete you’ll notice that the color scheme of the page changes because the CSS variables we defined in styles/globals.css
are changed dynamically and set to some colors from the generated palette.
You can find the full code on my Github. https://github.com/musebe/Color-Pallete-Generator.git