Image Color Palette Generator

Eugene Musebe

Introduction

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.

Codesandbox

The completed project is available on Codesandbox.

Getting started

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.

Obtain Cloudinary credentials.

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.

Diving into the code

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.

1npx 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.

1cd nextjs-color-palette-generator

Finally, open your project in your favorite code editor.

Set-up code to upload to Cloudinary

Before we proceed any further, let's install the Cloudinary NPM package. We'll use this to communicate with their API

1npm 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.

1// Import the v2 api and rename it to cloudinary
2
3import { v2 as cloudinary } from "cloudinary";
4
5
6
7// Initialize the SDK with cloud_name, api_key, and api_secret
8
9cloudinary.config({
10
11cloud_name: process.env.CLOUD_NAME,
12
13api_key: process.env.API_KEY,
14
15api_secret: process.env.API_SECRET,
16
17});
18
19
20
21export const handleCloudinaryUpload = (path) => {
22
23// Create and return a new Promise
24
25return new Promise((resolve, reject) => {
26
27// Use the SDK to upload media
28
29cloudinary.uploader.upload(
30
31path,
32
33{
34
35// Folder to store video in
36
37folder: "images/",
38
39// Type of resource
40
41resource_type: "image",
42
43},
44
45(error, result) => {
46
47if (error) {
48
49// Reject the promise with an error if any
50
51return reject(error);
52
53}
54
55
56
57// Resolve the promise with a successful result
58
59return resolve(result);
60
61}
62
63);
64
65});
66
67};

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

1CLOUD_NAME=YOUR_CLOUD_NAME
2
3API_KEY=YOUR_API_KEY
4
5API_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.

Create an API route to handle image upload

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

1// pages/api/images.js
2
3// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
4
5import { handleCloudinaryUpload } from "../../lib/cloudinary";
6
7
8
9export const config = {
10
11api: {
12
13bodyParser: false,
14
15},
16
17};
18
19
20
21export default async function handler(req, res) {
22
23switch (req.method) {
24
25case "POST": {
26
27try {
28
29const result = await handlePostRequest(req);
30
31
32
33return res.status(200).json({ message: "Success", result });
34
35} catch (error) {
36
37return res.status(400).json({ message: "Error", error });
38
39}
40
41}
42
43
44
45default: {
46
47return res.status(405).json({ message: "Method not allowed" });
48
49}
50
51}
52
53}
54
55
56
57const handlePostRequest = async (req) => {
58
59const data = await parseForm(req);
60
61
62
63const uploadResult = await handleCloudinaryUpload(data?.files?.file.path);
64
65
66
67return { uploadResult };
68
69};

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.

1npm install --save formidable

Add the following import at the top of pages/api/images.js

1// pages/api/images.js
2
3import { IncomingForm, Fields, Files } from "formidable";

and add the following function in the same file :

1// pages/api/images.js
2
3
4
5/**
6
7*
8
9* @param {*} req
10
11* @returns {Promise<{ fields:Fields; files:Files; }>}
12
13*/
14
15const parseForm = (req) => {
16
17return new Promise((resolve, reject) => {
18
19const form = new IncomingForm({ keepExtensions: true, multiples: true });
20
21
22
23form.parse(req, (error, fields, files) => {
24
25if (error) {
26
27return reject(error);
28
29}
30
31
32
33return resolve({ fields, files });
34
35});
36
37});
38
39};

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

1npm install --save node-vibrant

Add the following import to the top of pages/api/images.js

1// pages/api/images.js
2
3
4
5import * as Vibrant from "node-vibrant";

Modify handlePostRequest to read like so :

1const handlePostRequest = async (req) => {
2
3const data = await parseForm(req);
4
5
6
7const palette = await Vibrant.from(data?.files?.file.path).getPalette();
8
9
10
11const uploadResult = await handleCloudinaryUpload(data?.files?.file.path);
12
13
14
15return { palette, uploadResult };
16
17};

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

1// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
2
3import { IncomingForm, Fields, Files } from "formidable";
4
5import * as Vibrant from "node-vibrant";
6
7import { handleCloudinaryUpload } from "../../lib/cloudinary";
8
9
10
11export const config = {
12
13api: {
14
15bodyParser: false,
16
17},
18
19};
20
21
22
23export default async function handler(req, res) {
24
25switch (req.method) {
26
27case "POST": {
28
29try {
30
31const result = await handlePostRequest(req);
32
33
34
35return res.status(200).json({ message: "Success", result });
36
37} catch (error) {
38
39return res.status(400).json({ message: "Error", error });
40
41}
42
43}
44
45
46
47default: {
48
49return res.status(405).json({ message: "Method not allowed" });
50
51}
52
53}
54
55}
56
57
58
59const handlePostRequest = async (req) => {
60
61const data = await parseForm(req);
62
63
64
65const palette = await Vibrant.from(data?.files?.file.path).getPalette();
66
67
68
69const uploadResult = await handleCloudinaryUpload(data?.files?.file.path);
70
71
72
73return { palette, uploadResult };
74
75};
76
77
78
79/**
80
81*
82
83* @param {*} req
84
85* @returns {Promise<{ fields:Fields; files:Files; }>}
86
87*/
88
89const parseForm = (req) => {
90
91return new Promise((resolve, reject) => {
92
93const form = new IncomingForm({ keepExtensions: true, multiples: true });
94
95
96
97form.parse(req, (error, fields, files) => {
98
99if (error) {
100
101return reject(error);
102
103}
104
105
106
107return resolve({ fields, files });
108
109});
110
111});
112
113};

With that, we're now ready to move on to the front end.

The frontend - Upload form, Image display, Color manipulation

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

1:root {
2
3--primary-color: #001aff;
4
5--secondary-color: #ffd000;
6
7--background-color: #ae00ff;
8
9}

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

1import Head from "next/head";
2
3import Image from "next/image";
4
5import { useState } from "react";
6
7import { Palette } from "@vibrant/color";
8
9
10
11export default function Home() {
12
13/**
14
15* Holds the selected image file
16
17* @type {[File,Function]}
18
19*/
20
21const [file, setFile] = useState(null);
22
23
24
25/**
26
27* Holds the uploading/loading state
28
29* @type {[boolean,Function]}
30
31*/
32
33const [loading, setLoading] = useState(false);
34
35
36
37/**
38
39* Holds the result of the upload. This contains the cloudinary upload result and the color palette
40
41* @type {[{palette:Palette,uploadResult:UploadApiResponse},Function]}
42
43*/
44
45const [result, setResult] = useState();
46
47
48
49const handleFormSubmit = async (e) => {
50
51e.preventDefault();
52
53
54
55setLoading(true);
56
57try {
58
59const formData = new FormData(e.target);
60
61
62
63const response = await fetch("/api/images", {
64
65method: "POST",
66
67body: formData,
68
69});
70
71
72
73const data = await response.json();
74
75
76
77if (response.ok) {
78
79setResult(data.result);
80
81
82
83// Get the root document
84
85const htmlDoc = document.querySelector("html");
86
87
88
89// Set the primary color CSS variable to the palette's DarkVibrant color
90
91htmlDoc.style.setProperty(
92
93"--primary-color",
94
95`rgb(${data.result.palette.DarkVibrant.rgb.join(" ")})`
96
97);
98
99
100
101// Set the secondary color CSS variable to the palette's Muted color
102
103htmlDoc.style.setProperty(
104
105"--secondary-color",
106
107`rgb(${data.result.palette.Muted.rgb.join(" ")})`
108
109);
110
111
112
113// Set the background color CSS variable to the palette's Vibrant color
114
115htmlDoc.style.setProperty(
116
117"--background-color",
118
119`rgb(${data.result.palette.Vibrant.rgb.join(" ")})`
120
121);
122
123
124
125return;
126
127}
128
129
130
131throw data;
132
133} catch (error) {
134
135// TODO: Show error message to the user
136
137console.error(error);
138
139} finally {
140
141setLoading(false);
142
143}
144
145};
146
147
148
149return (
150
151<div className="">
152
153<Head>
154
155<title>Generate Color Palette with Next.js</title>
156
157<meta
158
159name="description"
160
161content="Generate Color Palette with Next.js"
162
163/>
164
165<link rel="icon" href="/favicon.ico" />
166
167</Head>
168
169
170
171<main className="container">
172
173<div className="header">
174
175<h1>Generate Color Palette with Next.js</h1>
176
177</div>
178
179{!result && (
180
181<form className="upload" onSubmit={handleFormSubmit}>
182
183{file && <p>{file.name} selected</p>}
184
185<label htmlFor="file">
186
187<p>
188
189<b>Tap To Select Image</b>
190
191</p>
192
193</label>
194
195<br />
196
197<input
198
199type="file"
200
201name="file"
202
203id="file"
204
205accept=".jpg,.png"
206
207multiple={false}
208
209required
210
211disabled={loading}
212
213onChange={(e) => {
214
215const file = e.target.files[0];
216
217
218
219setFile(file);
220
221}}
222
223/>
224
225<button type="submit" disabled={loading || !file}>
226
227Upload Image
228
229</button>
230
231</form>
232
233)}
234
235{loading && (
236
237<div className="loading">
238
239<hr />
240
241<p>Please wait as the image uploads</p>
242
243<hr />
244
245</div>
246
247)}
248
249{result && (
250
251<div className="image-container">
252
253<div className="image-wrapper">
254
255<Image
256
257className="image"
258
259src={result.uploadResult.secure_url}
260
261alt={result.uploadResult.secure_url}
262
263layout="fill"
264
265></Image>
266
267<div className="palette">
268
269{Object.entries(result.palette).map(([key, value], index) => (
270
271<div
272
273key={index}
274
275className="color"
276
277style={{
278
279backgroundColor: `rgb(${value.rgb.join(" ")})`,
280
281}}
282
283>
284
285<b>{key}</b>
286
287</div>
288
289))}
290
291</div>
292
293</div>
294
295</div>
296
297)}
298
299</main>
300
301</div>
302
303);
304
305}

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

1import Head from "next/head";
2
3import Image from "next/image";
4
5import { useState } from "react";
6
7import { Palette } from "@vibrant/color";
8
9
10
11export default function Home() {
12
13/**
14
15* Holds the selected image file
16
17* @type {[File,Function]}
18
19*/
20
21const [file, setFile] = useState(null);
22
23
24
25/**
26
27* Holds the uploading/loading state
28
29* @type {[boolean,Function]}
30
31*/
32
33const [loading, setLoading] = useState(false);
34
35
36
37/**
38
39* Holds the result of the upload. This contains the cloudinary upload result and the color palette
40
41* @type {[{palette:Palette,uploadResult:UploadApiResponse},Function]}
42
43*/
44
45const [result, setResult] = useState();
46
47
48
49const handleFormSubmit = async (e) => {
50
51e.preventDefault();
52
53
54
55setLoading(true);
56
57try {
58
59const formData = new FormData(e.target);
60
61
62
63const response = await fetch("/api/images", {
64
65method: "POST",
66
67body: formData,
68
69});
70
71
72
73const data = await response.json();
74
75
76
77if (response.ok) {
78
79setResult(data.result);
80
81
82
83// Get the root document
84
85const htmlDoc = document.querySelector("html");
86
87
88
89// Set the primary color CSS variable to the palette's DarkVibrant color
90
91htmlDoc.style.setProperty(
92
93"--primary-color",
94
95`rgb(${data.result.palette.DarkVibrant.rgb.join(" ")})`
96
97);
98
99
100
101// Set the secondary color CSS variable to the palette's Muted color
102
103htmlDoc.style.setProperty(
104
105"--secondary-color",
106
107`rgb(${data.result.palette.Muted.rgb.join(" ")})`
108
109);
110
111
112
113// Set the background color CSS variable to the palette's Vibrant color
114
115htmlDoc.style.setProperty(
116
117"--background-color",
118
119`rgb(${data.result.palette.Vibrant.rgb.join(" ")})`
120
121);
122
123
124
125return;
126
127}
128
129
130
131throw data;
132
133} catch (error) {
134
135// TODO: Show error message to the user
136
137console.error(error);
138
139} finally {
140
141setLoading(false);
142
143}
144
145};
146
147
148
149return (
150
151<div className="">
152
153<Head>
154
155<title>Generate Color Palette with Next.js</title>
156
157<meta
158
159name="description"
160
161content="Generate Color Palette with Next.js"
162
163/>
164
165<link rel="icon" href="/favicon.ico" />
166
167</Head>
168
169
170
171<main className="container">
172
173<div className="header">
174
175<h1>Generate Color Palette with Next.js</h1>
176
177</div>
178
179{!result && (
180
181<form className="upload" onSubmit={handleFormSubmit}>
182
183{file && <p>{file.name} selected</p>}
184
185<label htmlFor="file">
186
187<p>
188
189<b>Tap To Select Image</b>
190
191</p>
192
193</label>
194
195<br />
196
197<input
198
199type="file"
200
201name="file"
202
203id="file"
204
205accept=".jpg,.png"
206
207multiple={false}
208
209required
210
211disabled={loading}
212
213onChange={(e) => {
214
215const file = e.target.files[0];
216
217
218
219setFile(file);
220
221}}
222
223/>
224
225<button type="submit" disabled={loading || !file}>
226
227Upload Image
228
229</button>
230
231</form>
232
233)}
234
235{loading && (
236
237<div className="loading">
238
239<hr />
240
241<p>Please wait as the image uploads</p>
242
243<hr />
244
245</div>
246
247)}
248
249{result && (
250
251<div className="image-container">
252
253<div className="image-wrapper">
254
255<Image
256
257className="image"
258
259src={result.uploadResult.secure_url}
260
261alt={result.uploadResult.secure_url}
262
263layout="fill"
264
265></Image>
266
267<div className="palette">
268
269{Object.entries(result.palette).map(([key, value], index) => (
270
271<div
272
273key={index}
274
275className="color"
276
277style={{
278
279backgroundColor: `rgb(${value.rgb.join(" ")})`,
280
281}}
282
283>
284
285<b>{key}</b>
286
287</div>
288
289))}
290
291</div>
292
293</div>
294
295</div>
296
297)}
298
299</main>
300
301<style jsx>{`
302
303main {
304
305width: 100%;
306
307height: 100vh;
308
309background-color: var(--background-color);
310
311display: flex;
312
313flex-flow: column;
314
315justify-content: flex-start;
316
317align-items: center;
318
319}
320
321
322
323main .header {
324
325width: 100%;
326
327display: flex;
328
329justify-content: center;
330
331align-items: center;
332
333background-color: var(--secondary-color);
334
335padding: 0 40px;
336
337color: white;
338
339}
340
341
342
343main .header h1 {
344
345-webkit-text-stroke: 1px #000000;
346
347}
348
349
350
351main .loading {
352
353color: white;
354
355}
356
357
358
359main form {
360
361width: 50%;
362
363padding: 20px;
364
365display: flex;
366
367flex-flow: column;
368
369justify-content: center;
370
371align-items: center;
372
373border-radius: 5px;
374
375margin: 20px auto;
376
377background-color: #ffffff;
378
379}
380
381
382
383main form label {
384
385height: 100%;
386
387width: 100%;
388
389display: flex;
390
391justify-content: center;
392
393align-items: center;
394
395cursor: pointer;
396
397background-color: #777777;
398
399color: #ffffff;
400
401border-radius: 5px;
402
403}
404
405
406
407main form label:hover:not([disabled]) {
408
409background-color: var(--primary-color);
410
411}
412
413
414
415main form input {
416
417opacity: 0;
418
419width: 0.1px;
420
421height: 0.1px;
422
423}
424
425
426
427main form button {
428
429padding: 15px 30px;
430
431border: none;
432
433background-color: #e0e0e0;
434
435border-radius: 5px;
436
437color: #000000;
438
439font-weight: bold;
440
441font-size: 18px;
442
443}
444
445
446
447main form button:hover:not([disabled]) {
448
449background-color: var(--primary-color);
450
451color: #ffffff;
452
453}
454
455
456
457main div.image-container {
458
459position: relative;
460
461width: 100%;
462
463flex: 1 0;
464
465}
466
467
468
469main div.image-container .image-wrapper {
470
471position: relative;
472
473margin: auto;
474
475width: 80%;
476
477height: 100%;
478
479}
480
481
482
483main div.image-container div.image-wrapper .image-wrapper .image {
484
485object-fit: cover;
486
487}
488
489
490
491main div.image-container .image-wrapper .palette {
492
493width: 100%;
494
495height: 150px;
496
497position: absolute;
498
499bottom: 0;
500
501left: 0;
502
503background-color: rgba(255, 255, 255, 50%);
504
505display: flex;
506
507flex-flow: row nowrap;
508
509justify-content: flex-start;
510
511}
512
513
514
515main div.image-container .image-wrapper .palette .color {
516
517flex: 1;
518
519margin: 5px;
520
521}
522
523
524
525main div.image-container .image-wrapper .palette .color b {
526
527background-color: #ffffff;
528
529padding: 0 5px;
530
531}
532
533`}</style>
534
535</div>
536
537);
538
539}

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

1// next.config.js
2
3
4
5module.exports = {
6
7// ... other settings
8
9images: {
10
11domains: ["res.cloudinary.com"],
12
13},
14
15};

Concluding

Our App is ready. You can preview it by running

1npm 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

Eugene Musebe

Software Developer

I’m a full-stack software developer, content creator, and tech community builder based in Nairobi, Kenya. I am addicted to learning new technologies and loves working with like-minded people.