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 cloudinary23import { v2 as cloudinary } from "cloudinary";4567// Initialize the SDK with cloud_name, api_key, and api_secret89cloudinary.config({1011cloud_name: process.env.CLOUD_NAME,1213api_key: process.env.API_KEY,1415api_secret: process.env.API_SECRET,1617});18192021export const handleCloudinaryUpload = (path) => {2223// Create and return a new Promise2425return new Promise((resolve, reject) => {2627// Use the SDK to upload media2829cloudinary.uploader.upload(3031path,3233{3435// Folder to store video in3637folder: "images/",3839// Type of resource4041resource_type: "image",4243},4445(error, result) => {4647if (error) {4849// Reject the promise with an error if any5051return reject(error);5253}54555657// Resolve the promise with a successful result5859return resolve(result);6061}6263);6465});6667};
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_NAME23API_KEY=YOUR_API_KEY45API_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.js23// Next.js API route support: https://nextjs.org/docs/api-routes/introduction45import { handleCloudinaryUpload } from "../../lib/cloudinary";6789export const config = {1011api: {1213bodyParser: false,1415},1617};18192021export default async function handler(req, res) {2223switch (req.method) {2425case "POST": {2627try {2829const result = await handlePostRequest(req);30313233return res.status(200).json({ message: "Success", result });3435} catch (error) {3637return res.status(400).json({ message: "Error", error });3839}4041}42434445default: {4647return res.status(405).json({ message: "Method not allowed" });4849}5051}5253}54555657const handlePostRequest = async (req) => {5859const data = await parseForm(req);60616263const uploadResult = await handleCloudinaryUpload(data?.files?.file.path);64656667return { uploadResult };6869};
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.js23import { IncomingForm, Fields, Files } from "formidable";
and add the following function in the same file :
1// pages/api/images.js2345/**67*89* @param {*} req1011* @returns {Promise<{ fields:Fields; files:Files; }>}1213*/1415const parseForm = (req) => {1617return new Promise((resolve, reject) => {1819const form = new IncomingForm({ keepExtensions: true, multiples: true });20212223form.parse(req, (error, fields, files) => {2425if (error) {2627return reject(error);2829}30313233return resolve({ fields, files });3435});3637});3839};
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.js2345import * as Vibrant from "node-vibrant";
Modify handlePostRequest
to read like so :
1const handlePostRequest = async (req) => {23const data = await parseForm(req);4567const palette = await Vibrant.from(data?.files?.file.path).getPalette();891011const uploadResult = await handleCloudinaryUpload(data?.files?.file.path);12131415return { palette, uploadResult };1617};
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/introduction23import { IncomingForm, Fields, Files } from "formidable";45import * as Vibrant from "node-vibrant";67import { handleCloudinaryUpload } from "../../lib/cloudinary";891011export const config = {1213api: {1415bodyParser: false,1617},1819};20212223export default async function handler(req, res) {2425switch (req.method) {2627case "POST": {2829try {3031const result = await handlePostRequest(req);32333435return res.status(200).json({ message: "Success", result });3637} catch (error) {3839return res.status(400).json({ message: "Error", error });4041}4243}44454647default: {4849return res.status(405).json({ message: "Method not allowed" });5051}5253}5455}56575859const handlePostRequest = async (req) => {6061const data = await parseForm(req);62636465const palette = await Vibrant.from(data?.files?.file.path).getPalette();66676869const uploadResult = await handleCloudinaryUpload(data?.files?.file.path);70717273return { palette, uploadResult };7475};76777879/**8081*8283* @param {*} req8485* @returns {Promise<{ fields:Fields; files:Files; }>}8687*/8889const parseForm = (req) => {9091return new Promise((resolve, reject) => {9293const form = new IncomingForm({ keepExtensions: true, multiples: true });94959697form.parse(req, (error, fields, files) => {9899if (error) {100101return reject(error);102103}104105106107return resolve({ fields, files });108109});110111});112113};
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 {23--primary-color: #001aff;45--secondary-color: #ffd000;67--background-color: #ae00ff;89}
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";23import Image from "next/image";45import { useState } from "react";67import { Palette } from "@vibrant/color";891011export default function Home() {1213/**1415* Holds the selected image file1617* @type {[File,Function]}1819*/2021const [file, setFile] = useState(null);22232425/**2627* Holds the uploading/loading state2829* @type {[boolean,Function]}3031*/3233const [loading, setLoading] = useState(false);34353637/**3839* Holds the result of the upload. This contains the cloudinary upload result and the color palette4041* @type {[{palette:Palette,uploadResult:UploadApiResponse},Function]}4243*/4445const [result, setResult] = useState();46474849const handleFormSubmit = async (e) => {5051e.preventDefault();52535455setLoading(true);5657try {5859const formData = new FormData(e.target);60616263const response = await fetch("/api/images", {6465method: "POST",6667body: formData,6869});70717273const data = await response.json();74757677if (response.ok) {7879setResult(data.result);80818283// Get the root document8485const htmlDoc = document.querySelector("html");86878889// Set the primary color CSS variable to the palette's DarkVibrant color9091htmlDoc.style.setProperty(9293"--primary-color",9495`rgb(${data.result.palette.DarkVibrant.rgb.join(" ")})`9697);9899100101// Set the secondary color CSS variable to the palette's Muted color102103htmlDoc.style.setProperty(104105"--secondary-color",106107`rgb(${data.result.palette.Muted.rgb.join(" ")})`108109);110111112113// Set the background color CSS variable to the palette's Vibrant color114115htmlDoc.style.setProperty(116117"--background-color",118119`rgb(${data.result.palette.Vibrant.rgb.join(" ")})`120121);122123124125return;126127}128129130131throw data;132133} catch (error) {134135// TODO: Show error message to the user136137console.error(error);138139} finally {140141setLoading(false);142143}144145};146147148149return (150151<div className="">152153<Head>154155<title>Generate Color Palette with Next.js</title>156157<meta158159name="description"160161content="Generate Color Palette with Next.js"162163/>164165<link rel="icon" href="/favicon.ico" />166167</Head>168169170171<main className="container">172173<div className="header">174175<h1>Generate Color Palette with Next.js</h1>176177</div>178179{!result && (180181<form className="upload" onSubmit={handleFormSubmit}>182183{file && <p>{file.name} selected</p>}184185<label htmlFor="file">186187<p>188189<b>Tap To Select Image</b>190191</p>192193</label>194195<br />196197<input198199type="file"200201name="file"202203id="file"204205accept=".jpg,.png"206207multiple={false}208209required210211disabled={loading}212213onChange={(e) => {214215const file = e.target.files[0];216217218219setFile(file);220221}}222223/>224225<button type="submit" disabled={loading || !file}>226227Upload Image228229</button>230231</form>232233)}234235{loading && (236237<div className="loading">238239<hr />240241<p>Please wait as the image uploads</p>242243<hr />244245</div>246247)}248249{result && (250251<div className="image-container">252253<div className="image-wrapper">254255<Image256257className="image"258259src={result.uploadResult.secure_url}260261alt={result.uploadResult.secure_url}262263layout="fill"264265></Image>266267<div className="palette">268269{Object.entries(result.palette).map(([key, value], index) => (270271<div272273key={index}274275className="color"276277style={{278279backgroundColor: `rgb(${value.rgb.join(" ")})`,280281}}282283>284285<b>{key}</b>286287</div>288289))}290291</div>292293</div>294295</div>296297)}298299</main>300301</div>302303);304305}
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";23import Image from "next/image";45import { useState } from "react";67import { Palette } from "@vibrant/color";891011export default function Home() {1213/**1415* Holds the selected image file1617* @type {[File,Function]}1819*/2021const [file, setFile] = useState(null);22232425/**2627* Holds the uploading/loading state2829* @type {[boolean,Function]}3031*/3233const [loading, setLoading] = useState(false);34353637/**3839* Holds the result of the upload. This contains the cloudinary upload result and the color palette4041* @type {[{palette:Palette,uploadResult:UploadApiResponse},Function]}4243*/4445const [result, setResult] = useState();46474849const handleFormSubmit = async (e) => {5051e.preventDefault();52535455setLoading(true);5657try {5859const formData = new FormData(e.target);60616263const response = await fetch("/api/images", {6465method: "POST",6667body: formData,6869});70717273const data = await response.json();74757677if (response.ok) {7879setResult(data.result);80818283// Get the root document8485const htmlDoc = document.querySelector("html");86878889// Set the primary color CSS variable to the palette's DarkVibrant color9091htmlDoc.style.setProperty(9293"--primary-color",9495`rgb(${data.result.palette.DarkVibrant.rgb.join(" ")})`9697);9899100101// Set the secondary color CSS variable to the palette's Muted color102103htmlDoc.style.setProperty(104105"--secondary-color",106107`rgb(${data.result.palette.Muted.rgb.join(" ")})`108109);110111112113// Set the background color CSS variable to the palette's Vibrant color114115htmlDoc.style.setProperty(116117"--background-color",118119`rgb(${data.result.palette.Vibrant.rgb.join(" ")})`120121);122123124125return;126127}128129130131throw data;132133} catch (error) {134135// TODO: Show error message to the user136137console.error(error);138139} finally {140141setLoading(false);142143}144145};146147148149return (150151<div className="">152153<Head>154155<title>Generate Color Palette with Next.js</title>156157<meta158159name="description"160161content="Generate Color Palette with Next.js"162163/>164165<link rel="icon" href="/favicon.ico" />166167</Head>168169170171<main className="container">172173<div className="header">174175<h1>Generate Color Palette with Next.js</h1>176177</div>178179{!result && (180181<form className="upload" onSubmit={handleFormSubmit}>182183{file && <p>{file.name} selected</p>}184185<label htmlFor="file">186187<p>188189<b>Tap To Select Image</b>190191</p>192193</label>194195<br />196197<input198199type="file"200201name="file"202203id="file"204205accept=".jpg,.png"206207multiple={false}208209required210211disabled={loading}212213onChange={(e) => {214215const file = e.target.files[0];216217218219setFile(file);220221}}222223/>224225<button type="submit" disabled={loading || !file}>226227Upload Image228229</button>230231</form>232233)}234235{loading && (236237<div className="loading">238239<hr />240241<p>Please wait as the image uploads</p>242243<hr />244245</div>246247)}248249{result && (250251<div className="image-container">252253<div className="image-wrapper">254255<Image256257className="image"258259src={result.uploadResult.secure_url}260261alt={result.uploadResult.secure_url}262263layout="fill"264265></Image>266267<div className="palette">268269{Object.entries(result.palette).map(([key, value], index) => (270271<div272273key={index}274275className="color"276277style={{278279backgroundColor: `rgb(${value.rgb.join(" ")})`,280281}}282283>284285<b>{key}</b>286287</div>288289))}290291</div>292293</div>294295</div>296297)}298299</main>300301<style jsx>{`302303main {304305width: 100%;306307height: 100vh;308309background-color: var(--background-color);310311display: flex;312313flex-flow: column;314315justify-content: flex-start;316317align-items: center;318319}320321322323main .header {324325width: 100%;326327display: flex;328329justify-content: center;330331align-items: center;332333background-color: var(--secondary-color);334335padding: 0 40px;336337color: white;338339}340341342343main .header h1 {344345-webkit-text-stroke: 1px #000000;346347}348349350351main .loading {352353color: white;354355}356357358359main form {360361width: 50%;362363padding: 20px;364365display: flex;366367flex-flow: column;368369justify-content: center;370371align-items: center;372373border-radius: 5px;374375margin: 20px auto;376377background-color: #ffffff;378379}380381382383main form label {384385height: 100%;386387width: 100%;388389display: flex;390391justify-content: center;392393align-items: center;394395cursor: pointer;396397background-color: #777777;398399color: #ffffff;400401border-radius: 5px;402403}404405406407main form label:hover:not([disabled]) {408409background-color: var(--primary-color);410411}412413414415main form input {416417opacity: 0;418419width: 0.1px;420421height: 0.1px;422423}424425426427main form button {428429padding: 15px 30px;430431border: none;432433background-color: #e0e0e0;434435border-radius: 5px;436437color: #000000;438439font-weight: bold;440441font-size: 18px;442443}444445446447main form button:hover:not([disabled]) {448449background-color: var(--primary-color);450451color: #ffffff;452453}454455456457main div.image-container {458459position: relative;460461width: 100%;462463flex: 1 0;464465}466467468469main div.image-container .image-wrapper {470471position: relative;472473margin: auto;474475width: 80%;476477height: 100%;478479}480481482483main div.image-container div.image-wrapper .image-wrapper .image {484485object-fit: cover;486487}488489490491main div.image-container .image-wrapper .palette {492493width: 100%;494495height: 150px;496497position: absolute;498499bottom: 0;500501left: 0;502503background-color: rgba(255, 255, 255, 50%);504505display: flex;506507flex-flow: row nowrap;508509justify-content: flex-start;510511}512513514515main div.image-container .image-wrapper .palette .color {516517flex: 1;518519margin: 5px;520521}522523524525main div.image-container .image-wrapper .palette .color b {526527background-color: #ffffff;528529padding: 0 5px;530531}532533`}</style>534535</div>536537);538539}
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.js2345module.exports = {67// ... other settings89images: {1011domains: ["res.cloudinary.com"],1213},1415};
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