UI Components Hard
Color Picker
HSL color picker with canvas gradient, hue slider, hex/rgb output, and opacity slider.
Open in Lab
MCP
canvas vanilla-js
Targets: JS HTML
Code
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: Inter, system-ui, sans-serif;
background: #050910;
color: #f2f6ff;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.demo {
width: 100%;
max-width: 360px;
}
.demo-title {
font-size: 1.5rem;
font-weight: 800;
margin-bottom: 0.375rem;
}
.demo-sub {
color: #475569;
font-size: 0.875rem;
margin-bottom: 2rem;
}
/* ── Picker card ── */
.picker {
background: #0d1525;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
overflow: hidden;
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.5);
}
/* ── Canvas area ── */
.canvas-wrap {
position: relative;
width: 100%;
cursor: crosshair;
user-select: none;
}
.canvas-wrap canvas {
display: block;
width: 100%;
height: 200px;
}
/* ── Crosshair ── */
.crosshair {
position: absolute;
width: 14px;
height: 14px;
border-radius: 50%;
border: 2px solid #fff;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.4), inset 0 0 0 1px rgba(0, 0, 0, 0.2);
transform: translate(-50%, -50%);
top: 10%;
left: 50%;
pointer-events: none;
}
/* ── Sliders row ── */
.sliders-row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.875rem 1rem;
}
/* ── Swatch ── */
.swatch-wrap {
position: relative;
width: 2.625rem;
height: 2.625rem;
border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.12);
}
.swatch-checker {
position: absolute;
inset: 0;
background-image: linear-gradient(45deg, #555 25%, transparent 25%),
linear-gradient(-45deg, #555 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #555 75%),
linear-gradient(-45deg, transparent 75%, #555 75%);
background-size: 8px 8px;
background-position: 0 0, 0 4px, 4px -4px, -4px 0;
background-color: #222;
}
.swatch {
position: absolute;
inset: 0;
border-radius: 50%;
}
/* ── Slider column ── */
.sliders-col {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.625rem;
}
/* ── Slider track ── */
.slider-track {
position: relative;
height: 12px;
border-radius: 999px;
cursor: pointer;
user-select: none;
}
/* hue gradient */
.hue-track {
background: linear-gradient(
to right,
hsl(0, 100%, 50%),
hsl(30, 100%, 50%),
hsl(60, 100%, 50%),
hsl(90, 100%, 50%),
hsl(120, 100%, 50%),
hsl(150, 100%, 50%),
hsl(180, 100%, 50%),
hsl(210, 100%, 50%),
hsl(240, 100%, 50%),
hsl(270, 100%, 50%),
hsl(300, 100%, 50%),
hsl(330, 100%, 50%),
hsl(360, 100%, 50%)
);
}
/* opacity track — canvas drawn by JS */
.opacity-track {
background: none;
overflow: hidden;
}
.opacity-canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border-radius: 999px;
pointer-events: none;
}
/* ── Slider thumb ── */
.slider-thumb {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 18px;
height: 18px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5), 0 0 0 1.5px rgba(0, 0, 0, 0.2);
pointer-events: none;
left: 0;
}
/* ── Outputs ── */
.outputs {
display: flex;
gap: 0.5rem;
padding: 0 1rem 1rem;
}
.output-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
flex: 1;
}
.output-group--wide {
flex: 1.4;
}
.output-label {
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #475569;
}
.output-input {
width: 100%;
height: 2rem;
padding: 0 0.5rem;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 7px;
color: #cbd5e1;
font-family: "SFMono-Regular", Consolas, monospace;
font-size: 0.75rem;
cursor: text;
outline: none;
transition: border-color 0.15s;
}
.output-input:focus {
border-color: rgba(99, 179, 237, 0.4);
}
.output-input:hover {
border-color: rgba(255, 255, 255, 0.15);
}(function () {
// ── Elements ──
var canvasWrap = document.getElementById("canvas-wrap");
var gradCanvas = document.getElementById("gradient-canvas");
var crosshair = document.getElementById("crosshair");
var hueTrack = document.getElementById("hue-track");
var hueThumb = document.getElementById("hue-thumb");
var opTrack = document.getElementById("opacity-track");
var opThumb = document.getElementById("opacity-thumb");
var opCanvas = document.getElementById("opacity-canvas");
var swatch = document.getElementById("swatch");
var outHex = document.getElementById("out-hex");
var outRgb = document.getElementById("out-rgb");
var outRgba = document.getElementById("out-rgba");
// ── State ──
var hue = 210; // 0-360
var sat = 0.7; // 0-1 (x on gradient canvas)
var light = 0.4; // 0-1 (y on gradient canvas, inverted)
var alpha = 1; // 0-1
// ── Helpers ──
function clamp(v, lo, hi) {
return Math.min(Math.max(v, lo), hi);
}
function hslToRgb(h, s, l) {
// h: 0-360, s: 0-1, l: 0-1
var c = (1 - Math.abs(2 * l - 1)) * s;
var x = c * (1 - Math.abs(((h / 60) % 2) - 1));
var m = l - c / 2;
var r, g, b;
if (h < 60) {
r = c;
g = x;
b = 0;
} else if (h < 120) {
r = x;
g = c;
b = 0;
} else if (h < 180) {
r = 0;
g = c;
b = x;
} else if (h < 240) {
r = 0;
g = x;
b = c;
} else if (h < 300) {
r = x;
g = 0;
b = c;
} else {
r = c;
g = 0;
b = x;
}
return [Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255)];
}
function toHex(r, g, b) {
return (
"#" +
[r, g, b]
.map(function (v) {
return ("0" + v.toString(16)).slice(-2);
})
.join("")
);
}
// ── Draw gradient canvas ──
function drawGradient() {
var ctx = gradCanvas.getContext("2d");
var w = gradCanvas.width;
var h = gradCanvas.height;
// Base hue fill
ctx.fillStyle = "hsl(" + hue + ", 100%, 50%)";
ctx.fillRect(0, 0, w, h);
// White → transparent (left→right)
var wGrad = ctx.createLinearGradient(0, 0, w, 0);
wGrad.addColorStop(0, "rgba(255,255,255,1)");
wGrad.addColorStop(1, "rgba(255,255,255,0)");
ctx.fillStyle = wGrad;
ctx.fillRect(0, 0, w, h);
// Transparent → black (top→bottom)
var bGrad = ctx.createLinearGradient(0, 0, 0, h);
bGrad.addColorStop(0, "rgba(0,0,0,0)");
bGrad.addColorStop(1, "rgba(0,0,0,1)");
ctx.fillStyle = bGrad;
ctx.fillRect(0, 0, w, h);
}
// ── Draw opacity canvas ──
function drawOpacity(rgb) {
var ctx = opCanvas.getContext("2d");
var w = opCanvas.offsetWidth || opTrack.offsetWidth;
var h = opCanvas.offsetHeight || 12;
opCanvas.width = w;
opCanvas.height = h;
// Checkerboard
var tileSize = 6;
for (var row = 0; row < Math.ceil(h / tileSize); row++) {
for (var col = 0; col < Math.ceil(w / tileSize); col++) {
ctx.fillStyle = (row + col) % 2 === 0 ? "#888" : "#555";
ctx.fillRect(col * tileSize, row * tileSize, tileSize, tileSize);
}
}
// Gradient overlay
var grad = ctx.createLinearGradient(0, 0, w, 0);
grad.addColorStop(0, "rgba(" + rgb[0] + "," + rgb[1] + "," + rgb[2] + ",0)");
grad.addColorStop(1, "rgba(" + rgb[0] + "," + rgb[1] + "," + rgb[2] + ",1)");
ctx.fillStyle = grad;
ctx.fillRect(0, 0, w, h);
}
// ── Compute current HSL given canvas sat/light position ──
// On the canvas: x=0 → white (s=0, l=1), x=1 → pure hue (s=1, l~0.5)
// y=0 → bright, y=1 → black
// We map directly: hslS = sat, hslL = 0.5 * (1 - sat) + sat * (1 - light) ... standard formula
// Simpler: lightness from canvas coords:
function canvasToHsl(sx, ly) {
// sx = saturation on x axis (0-1), ly = darkness on y (0-1)
// Convert from "canvas space" to real HSL:
// white point at (0,0), black at (x, 1), pure hue at (1, 0)
var s = sx;
var l = (1 - ly) * (1 - sx / 2);
// avoid s=0 when l=0
return { s: s, l: clamp(l, 0, 1) };
}
// ── Update everything ──
function update() {
var hslCoords = canvasToHsl(sat, 1 - light);
var rgb = hslToRgb(hue, hslCoords.s, hslCoords.l);
var hex = toHex(rgb[0], rgb[1], rgb[2]);
// Swatch
swatch.style.background = "rgba(" + rgb[0] + "," + rgb[1] + "," + rgb[2] + "," + alpha + ")";
// Outputs
outHex.value = hex;
outRgb.value = "rgb(" + rgb.join(", ") + ")";
outRgba.value = "rgba(" + rgb.join(", ") + ", " + alpha.toFixed(2) + ")";
// Crosshair position (sat on x, light on y, light=1 → top)
var canvW = gradCanvas.offsetWidth || gradCanvas.width;
var canvH = gradCanvas.offsetHeight || gradCanvas.height;
crosshair.style.left = sat * canvW + "px";
crosshair.style.top = (1 - light) * canvH + "px";
// Hue thumb
hueThumb.style.left = (hue / 360) * 100 + "%";
// Opacity thumb
opThumb.style.left = alpha * 100 + "%";
// Redraw opacity strip
drawOpacity(rgb);
}
// ── Canvas drag ──
function pickFromCanvas(e) {
var rect = gradCanvas.getBoundingClientRect();
var x = clamp((e.clientX - rect.left) / rect.width, 0, 1);
var y = clamp((e.clientY - rect.top) / rect.height, 0, 1);
sat = x;
light = 1 - y;
update();
}
var draggingCanvas = false;
canvasWrap.addEventListener("mousedown", function (e) {
draggingCanvas = true;
pickFromCanvas(e);
});
document.addEventListener("mousemove", function (e) {
if (draggingCanvas) pickFromCanvas(e);
});
document.addEventListener("mouseup", function () {
draggingCanvas = false;
});
// touch
canvasWrap.addEventListener(
"touchstart",
function (e) {
draggingCanvas = true;
pickFromCanvas(e.touches[0]);
e.preventDefault();
},
{ passive: false }
);
document.addEventListener(
"touchmove",
function (e) {
if (draggingCanvas) {
pickFromCanvas(e.touches[0]);
e.preventDefault();
}
},
{ passive: false }
);
document.addEventListener("touchend", function () {
draggingCanvas = false;
});
// ── Hue slider drag ──
function pickHue(e) {
var rect = hueTrack.getBoundingClientRect();
hue = clamp(((e.clientX - rect.left) / rect.width) * 360, 0, 360);
drawGradient();
update();
}
var draggingHue = false;
hueTrack.addEventListener("mousedown", function (e) {
draggingHue = true;
pickHue(e);
});
document.addEventListener("mousemove", function (e) {
if (draggingHue) pickHue(e);
});
document.addEventListener("mouseup", function () {
draggingHue = false;
});
hueTrack.addEventListener(
"touchstart",
function (e) {
draggingHue = true;
pickHue(e.touches[0]);
e.preventDefault();
},
{ passive: false }
);
document.addEventListener(
"touchmove",
function (e) {
if (draggingHue) {
pickHue(e.touches[0]);
e.preventDefault();
}
},
{ passive: false }
);
document.addEventListener("touchend", function () {
draggingHue = false;
});
// ── Opacity slider drag ──
function pickOpacity(e) {
var rect = opTrack.getBoundingClientRect();
alpha = clamp((e.clientX - rect.left) / rect.width, 0, 1);
update();
}
var draggingOp = false;
opTrack.addEventListener("mousedown", function (e) {
draggingOp = true;
pickOpacity(e);
});
document.addEventListener("mousemove", function (e) {
if (draggingOp) pickOpacity(e);
});
document.addEventListener("mouseup", function () {
draggingOp = false;
});
opTrack.addEventListener(
"touchstart",
function (e) {
draggingOp = true;
pickOpacity(e.touches[0]);
e.preventDefault();
},
{ passive: false }
);
document.addEventListener(
"touchmove",
function (e) {
if (draggingOp) {
pickOpacity(e.touches[0]);
e.preventDefault();
}
},
{ passive: false }
);
document.addEventListener("touchend", function () {
draggingOp = false;
});
// ── Click-to-copy outputs ──
[outHex, outRgb, outRgba].forEach(function (el) {
el.addEventListener("click", function () {
el.select();
document.execCommand("copy");
});
});
// ── Init ──
drawGradient();
update();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Color Picker</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="demo">
<h1 class="demo-title">Color Picker</h1>
<p class="demo-sub">HSL canvas picker with hue/opacity sliders and hex/rgb output.</p>
<div class="picker">
<!-- Canvas gradient area -->
<div class="canvas-wrap" id="canvas-wrap">
<canvas id="gradient-canvas" width="320" height="200" aria-label="Color gradient — drag to pick saturation and lightness"></canvas>
<div class="crosshair" id="crosshair" role="presentation"></div>
</div>
<!-- Sliders row -->
<div class="sliders-row">
<div class="swatch-wrap" aria-label="Color preview">
<div class="swatch-checker"></div>
<div class="swatch" id="swatch"></div>
</div>
<div class="sliders-col">
<!-- Hue -->
<div class="slider-track hue-track" id="hue-track" role="slider" aria-label="Hue" aria-valuemin="0" aria-valuemax="360" aria-valuenow="0">
<div class="slider-thumb" id="hue-thumb"></div>
</div>
<!-- Opacity -->
<div class="slider-track opacity-track" id="opacity-track" role="slider" aria-label="Opacity" aria-valuemin="0" aria-valuemax="100" aria-valuenow="100">
<div class="slider-thumb" id="opacity-thumb"></div>
<canvas id="opacity-canvas" class="opacity-canvas" height="12" aria-hidden="true"></canvas>
</div>
</div>
</div>
<!-- Outputs -->
<div class="outputs">
<div class="output-group">
<label class="output-label" for="out-hex">Hex</label>
<input id="out-hex" class="output-input" type="text" readonly spellcheck="false" />
</div>
<div class="output-group">
<label class="output-label" for="out-rgb">RGB</label>
<input id="out-rgb" class="output-input" type="text" readonly spellcheck="false" />
</div>
<div class="output-group output-group--wide">
<label class="output-label" for="out-rgba">RGBA</label>
<input id="out-rgba" class="output-input" type="text" readonly spellcheck="false" />
</div>
</div>
</div>
</div>
<script src="script.js"></script>
</body>
</html>Color Picker
Full HSL color picker built on <canvas>. Drag the crosshair to pick saturation and lightness, drag the hue slider to change hue, and adjust the opacity slider. Outputs Hex, RGB, and RGBA.
Components
| Element | Description |
|---|---|
| Canvas gradient | White→hue horizontal, opaque→black vertical |
| Crosshair | Draggable saturation/lightness picker |
| Hue slider | 0–360° hue strip |
| Opacity slider | 0–100% alpha |
| Preview swatch | Live color swatch with checkerboard background |
| Outputs | Hex, RGB, RGBA text fields |
Implementation
The canvas renders two overlapping linearGradient fills: white-to-currentHue (x-axis) and transparent-to-black (y-axis). The crosshair position maps to s and l in HSL space. Hex is computed by converting HSL → RGB → hex string.