From 3e99f1fd97c999c5a72595fdd15559758bf15602 Mon Sep 17 00:00:00 2001
From: Mahnoor-Zaffar <1999mahnoor@gmail.com>
Date: Sun, 5 Jul 2026 20:06:26 +0500
Subject: [PATCH] feat: add image color palette extractor utility
Add a client-side tool to extract dominant colors from uploaded images and export palettes as CSS, Tailwind, Sass, or JSON.
---
src/App.jsx | 5 +
src/config/sidebarSections.js | 6 +
src/pages/DevUtilities/DevUtilities.jsx | 21 +
.../devutilities/ColorPaletteExtractor.jsx | 644 ++++++++++++++++++
4 files changed, 676 insertions(+)
create mode 100644 src/pages/DevUtilities/devutilities/ColorPaletteExtractor.jsx
diff --git a/src/App.jsx b/src/App.jsx
index 4f7ec31..082fe87 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -79,6 +79,7 @@ import UuidGenerator from "./pages/DevUtilities/devutilities/UuidGenerator";
import WordCounter from "./pages/DevUtilities/devutilities/WordCounter";
import GitCommandBuilder from "./pages/DevUtilities/devutilities/GitCommandBuilder";
import ImageOptimizer from "./pages/DevUtilities/devutilities/ImageOptimizer";
+import ColorPaletteExtractor from "./pages/DevUtilities/devutilities/ColorPaletteExtractor";
import Footer from "./components/Footer";
import Navbar from "./components/Navbar";
@@ -538,6 +539,10 @@ function AppInner({ toggleHUD, hudVisible }) {
path="/devutilities/image-optimizer"
element={}
/>
+ }
+ />
}
diff --git a/src/config/sidebarSections.js b/src/config/sidebarSections.js
index c817d76..68fb151 100644
--- a/src/config/sidebarSections.js
+++ b/src/config/sidebarSections.js
@@ -318,6 +318,12 @@ const SIDEBAR_SECTIONS = [
description: "Convert colors and check contrast",
path: "/devutilities/color",
},
+ {
+ label: "Image Color Palette Extractor",
+ description:
+ "Extract dominant color palettes and HEX codes from any uploaded image client-side.",
+ path: "/devutilities/color-extractor",
+ },
{
label: "QR Code Generator",
description: "Generate QR codes",
diff --git a/src/pages/DevUtilities/DevUtilities.jsx b/src/pages/DevUtilities/DevUtilities.jsx
index f8c02ee..80f77e8 100644
--- a/src/pages/DevUtilities/DevUtilities.jsx
+++ b/src/pages/DevUtilities/DevUtilities.jsx
@@ -392,6 +392,27 @@ const DevUtilities = () => {
),
},
+ {
+ title: "Image Color Palette Extractor",
+ description:
+ "Extract dominant color palettes and HEX, RGB, or HSL codes from any uploaded image. Export as CSS variables, Tailwind config, Sass, or JSON. Fully offline.",
+ path: "/devutilities/color-extractor",
+ icon: (
+
+ ),
+ },
{
title: "QR Code Generator",
description:
diff --git a/src/pages/DevUtilities/devutilities/ColorPaletteExtractor.jsx b/src/pages/DevUtilities/devutilities/ColorPaletteExtractor.jsx
new file mode 100644
index 0000000..011d6a1
--- /dev/null
+++ b/src/pages/DevUtilities/devutilities/ColorPaletteExtractor.jsx
@@ -0,0 +1,644 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { Link } from "react-router-dom";
+import { toast } from "sonner";
+import { useTheme } from "../../../context/ThemeContext";
+
+const BUCKET_BITS = 4;
+const BUCKET_SHIFT = 8 - BUCKET_BITS;
+const COLOR_NAMES = [
+ "primary",
+ "secondary",
+ "accent",
+ "muted",
+ "surface",
+ "highlight",
+ "neutral",
+ "contrast",
+];
+const EXPORT_TABS = ["CSS", "Tailwind", "Sass", "JSON"];
+const ACCEPTED_TYPES = ["image/png", "image/jpeg", "image/webp"];
+
+function clamp(value, min, max) {
+ return Math.min(max, Math.max(min, value));
+}
+
+function toHexByte(value) {
+ return Math.round(clamp(value, 0, 255))
+ .toString(16)
+ .padStart(2, "0")
+ .toUpperCase();
+}
+
+function rgbToHex({ r, g, b }) {
+ return `#${toHexByte(r)}${toHexByte(g)}${toHexByte(b)}`;
+}
+
+function rgbToHsl(r, g, b) {
+ const red = r / 255;
+ const green = g / 255;
+ const blue = b / 255;
+ const max = Math.max(red, green, blue);
+ const min = Math.min(red, green, blue);
+ const delta = max - min;
+
+ let hue = 0;
+ let saturation = 0;
+ const lightness = (max + min) / 2;
+
+ if (delta !== 0) {
+ saturation = delta / (1 - Math.abs(2 * lightness - 1));
+
+ switch (max) {
+ case red:
+ hue = 60 * (((green - blue) / delta) % 6);
+ break;
+ case green:
+ hue = 60 * ((blue - red) / delta + 2);
+ break;
+ default:
+ hue = 60 * ((red - green) / delta + 4);
+ break;
+ }
+ }
+
+ return {
+ h: Math.round((hue + 360) % 360),
+ s: Math.round(saturation * 100),
+ l: Math.round(lightness * 100),
+ };
+}
+
+function rgbToHslString(r, g, b) {
+ const { h, s, l } = rgbToHsl(r, g, b);
+ return `hsl(${h}, ${s}%, ${l}%)`;
+}
+
+function bucketKey(r, g, b) {
+ return (
+ ((r >> BUCKET_SHIFT) << (BUCKET_BITS * 2)) |
+ ((g >> BUCKET_SHIFT) << BUCKET_BITS) |
+ (b >> BUCKET_SHIFT)
+ );
+}
+
+function isNearWhite({ r, g, b }) {
+ return r > 240 && g > 240 && b > 240;
+}
+
+function isNearBlack({ r, g, b }) {
+ return r < 15 && g < 15 && b < 15;
+}
+
+function colorDistance(a, b) {
+ return Math.abs(a.r - b.r) + Math.abs(a.g - b.g) + Math.abs(a.b - b.b);
+}
+
+function analyzeImage(img, canvas) {
+ const maxDimension = 150;
+ const scale = Math.min(1, maxDimension / Math.max(img.width, img.height));
+ const width = Math.max(1, Math.round(img.width * scale));
+ const height = Math.max(1, Math.round(img.height * scale));
+
+ canvas.width = width;
+ canvas.height = height;
+
+ const ctx = canvas.getContext("2d", { willReadFrequently: true });
+ if (!ctx) return null;
+
+ ctx.drawImage(img, 0, 0, width, height);
+ return ctx.getImageData(0, 0, width, height);
+}
+
+function extractPalette(imageData, { maxColors = 6, minShare = 0.02 } = {}) {
+ const { data } = imageData;
+ const buckets = new Map();
+ let total = 0;
+
+ for (let i = 0; i < data.length; i += 4) {
+ const alpha = data[i + 3];
+ if (alpha < 128) continue;
+
+ const r = data[i];
+ const g = data[i + 1];
+ const b = data[i + 2];
+ const key = bucketKey(r, g, b);
+
+ let entry = buckets.get(key);
+ if (!entry) {
+ entry = { r: 0, g: 0, b: 0, count: 0 };
+ buckets.set(key, entry);
+ }
+
+ entry.r += r;
+ entry.g += g;
+ entry.b += b;
+ entry.count += 1;
+ total += 1;
+ }
+
+ if (total === 0) return [];
+
+ const candidates = [...buckets.values()]
+ .map(({ r, g, b, count }) => {
+ const avg = {
+ r: Math.round(r / count),
+ g: Math.round(g / count),
+ b: Math.round(b / count),
+ };
+ return { ...avg, count, share: count / total };
+ })
+ .sort((a, b) => b.count - a.count);
+
+ const filtered = candidates.filter((color, index) => {
+ if (color.share < minShare) return false;
+ if (isNearWhite(color) || isNearBlack(color)) {
+ return index < 2;
+ }
+ return true;
+ });
+
+ const merged = [];
+ for (const color of filtered) {
+ const duplicate = merged.find((entry) => colorDistance(entry, color) < 30);
+ if (duplicate) {
+ duplicate.count += color.count;
+ duplicate.share += color.share;
+ duplicate.r = Math.round((duplicate.r + color.r) / 2);
+ duplicate.g = Math.round((duplicate.g + color.g) / 2);
+ duplicate.b = Math.round((duplicate.b + color.b) / 2);
+ } else {
+ merged.push({ ...color });
+ }
+ }
+
+ return merged
+ .sort((a, b) => b.count - a.count)
+ .slice(0, maxColors)
+ .map((color) => ({
+ hex: rgbToHex(color),
+ rgb: `rgb(${color.r}, ${color.g}, ${color.b})`,
+ hsl: rgbToHslString(color.r, color.g, color.b),
+ share: Math.round(color.share * 100),
+ }));
+}
+
+function colorName(index) {
+ return COLOR_NAMES[index] ?? `palette-${index + 1}`;
+}
+
+function generateCSS(palette) {
+ const lines = [":root {"];
+ palette.forEach((color, index) => {
+ lines.push(` --color-${colorName(index)}: ${color.hex};`);
+ });
+ lines.push("}");
+ return lines.join("\n");
+}
+
+function generateTailwind(palette) {
+ const entries = palette
+ .map((color, index) => ` '${colorName(index)}': '${color.hex}',`)
+ .join("\n");
+
+ return `/** @type {import('tailwindcss').Config} */
+module.exports = {
+ theme: {
+ extend: {
+ colors: {
+${entries}
+ },
+ },
+ },
+};`;
+}
+
+function generateSass(palette) {
+ return palette
+ .map((color, index) => `$color-${colorName(index)}: ${color.hex};`)
+ .join("\n");
+}
+
+function generateJSON(palette) {
+ return JSON.stringify(palette.map((color) => color.hex), null, 2);
+}
+
+export default function ColorPaletteExtractor() {
+ const { dark } = useTheme();
+ const canvasRef = useRef(null);
+ const previewUrlRef = useRef(null);
+ const prevMaxColorsRef = useRef(6);
+
+ const [file, setFile] = useState(null);
+ const [preview, setPreview] = useState(null);
+ const [palette, setPalette] = useState([]);
+ const [maxColors, setMaxColors] = useState(6);
+ const [isDragging, setIsDragging] = useState(false);
+ const [isAnalyzing, setIsAnalyzing] = useState(false);
+ const [error, setError] = useState("");
+ const [exportTab, setExportTab] = useState("CSS");
+ const [copied, setCopied] = useState(false);
+
+ useEffect(() => {
+ return () => {
+ if (previewUrlRef.current) URL.revokeObjectURL(previewUrlRef.current);
+ };
+ }, []);
+
+ const analyzeFromImage = useCallback((img) => {
+ const canvas = canvasRef.current;
+ if (!canvas) {
+ setError("Canvas is not available in this browser.");
+ setIsAnalyzing(false);
+ return;
+ }
+
+ const imageData = analyzeImage(img, canvas);
+ if (!imageData) {
+ setError("Could not read image pixels.");
+ setIsAnalyzing(false);
+ return;
+ }
+
+ const extracted = extractPalette(imageData, { maxColors });
+ setPalette(extracted);
+ setIsAnalyzing(false);
+
+ if (extracted.length === 0) {
+ toast.error("No dominant colors found in this image.");
+ } else {
+ toast.success(`Extracted ${extracted.length} dominant colors`);
+ }
+ }, [maxColors]);
+
+ const loadFile = useCallback(
+ (selectedFile) => {
+ if (!selectedFile) return;
+
+ if (!ACCEPTED_TYPES.includes(selectedFile.type)) {
+ setError("Please upload a PNG, JPG, or WebP image.");
+ return;
+ }
+
+ if (selectedFile.size > 20 * 1024 * 1024) {
+ setError("Please upload an image smaller than 20 MB.");
+ return;
+ }
+
+ setError("");
+ setIsAnalyzing(true);
+ setPalette([]);
+ prevMaxColorsRef.current = maxColors;
+
+ if (previewUrlRef.current) URL.revokeObjectURL(previewUrlRef.current);
+
+ const imageUrl = URL.createObjectURL(selectedFile);
+ previewUrlRef.current = imageUrl;
+ setPreview(imageUrl);
+ setFile(selectedFile);
+
+ const img = new Image();
+ img.onload = () => analyzeFromImage(img);
+ img.onerror = () => {
+ setError("Could not read this image file.");
+ setIsAnalyzing(false);
+ };
+ img.src = imageUrl;
+ },
+ [analyzeFromImage],
+ );
+
+ useEffect(() => {
+ if (!preview || !file) return;
+ if (prevMaxColorsRef.current === maxColors) return;
+
+ prevMaxColorsRef.current = maxColors;
+ setIsAnalyzing(true);
+
+ const img = new Image();
+ img.onload = () => analyzeFromImage(img);
+ img.onerror = () => {
+ setError("Could not re-analyze this image.");
+ setIsAnalyzing(false);
+ };
+ img.src = preview;
+ }, [maxColors, analyzeFromImage, preview, file]);
+
+ const handleFile = (event) => {
+ loadFile(event.target.files?.[0]);
+ event.target.value = "";
+ };
+
+ const handleDrop = (event) => {
+ event.preventDefault();
+ setIsDragging(false);
+ loadFile(event.dataTransfer.files?.[0]);
+ };
+
+ const handleClear = () => {
+ if (previewUrlRef.current) URL.revokeObjectURL(previewUrlRef.current);
+ previewUrlRef.current = null;
+ prevMaxColorsRef.current = 6;
+ setFile(null);
+ setPreview(null);
+ setPalette([]);
+ setError("");
+ };
+
+ const copyHex = async (hex) => {
+ try {
+ await navigator.clipboard.writeText(hex);
+ toast.success(`Copied ${hex}`);
+ } catch {
+ toast.error("Failed to copy");
+ }
+ };
+
+ const exportCode = useMemo(() => {
+ if (palette.length === 0) return "";
+ if (exportTab === "CSS") return generateCSS(palette);
+ if (exportTab === "Tailwind") return generateTailwind(palette);
+ if (exportTab === "Sass") return generateSass(palette);
+ return generateJSON(palette);
+ }, [exportTab, palette]);
+
+ const handleCopyExport = async () => {
+ if (!exportCode) return;
+ try {
+ await navigator.clipboard.writeText(exportCode);
+ toast.success("Copied to clipboard");
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ } catch {
+ toast.error("Failed to copy");
+ }
+ };
+
+ const smallBtnCls = `px-3 py-1 rounded-lg text-xs font-bold transition-colors ${
+ dark
+ ? "bg-white text-black hover:bg-zinc-200"
+ : "bg-black text-white hover:bg-zinc-800"
+ }`;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ ←
+
+
+
+ Image Color Palette Extractor
+
+
+ Extract dominant color palettes from any image completely offline.
+
+
+
+
+
+
+
+ Upload & Preview
+
+
+
+
+ {error &&
{error}
}
+
+
+ {preview ? (
+

+ ) : (
+
+ Image preview will appear here
+
+ )}
+
+
+
+
+
+ setMaxColors(Number(event.target.value))}
+ disabled={!preview}
+ className="w-full mt-3 accent-purple-500 h-1.5 bg-zinc-200 dark:bg-zinc-800 rounded-lg cursor-pointer disabled:opacity-40"
+ />
+
+ {file && (
+
+ )}
+
+
+
+
+
+ Color Palette
+
+
+ {isAnalyzing ? (
+
Analyzing image pixels…
+ ) : palette.length > 0 ? (
+
+ {palette.map((color) => (
+
+ ))}
+
+ ) : (
+
+ Upload an image to extract its dominant colors. Click any swatch to
+ copy its HEX value.
+
+ )}
+
+
+
+
+ {EXPORT_TABS.map((tab) => (
+
+ ))}
+
+
+
+
+
+
+
+ Generated client-side — no data leaves your browser
+
+
+
+
+
+
+ );
+}