Color Hue Slider
Original · freehue/saturation color sliders
byWeb InnoventixReact + Tailwind
sldcolorhuesliders
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/sld-color-hue.jsonsld-color-hue.tsx
"use client";
import {
useEffect,
useId,
useRef,
useState,
type CSSProperties,
type ChangeEvent,
type KeyboardEvent as ReactKeyboardEvent,
type PointerEvent as ReactPointerEvent,
} from "react";
import { motion, useReducedMotion } from "motion/react";
/* ---------------------------------- math --------------------------------- */
const THUMB = 28;
function clamp(v: number, min: number, max: number): number {
return v < min ? min : v > max ? max : v;
}
function snap(v: number, step: number, min: number, max: number): number {
return clamp(Math.round(v / step) * step, min, max);
}
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
const sN = s / 100;
const lN = l / 100;
const c = (1 - Math.abs(2 * lN - 1)) * sN;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = lN - c / 2;
let r = 0;
let g = 0;
let b = 0;
if (h < 60) { r = c; g = x; }
else if (h < 120) { r = x; g = c; }
else if (h < 180) { g = c; b = x; }
else if (h < 240) { g = x; b = c; }
else if (h < 300) { r = x; b = c; }
else { r = c; b = x; }
return [
Math.round((r + m) * 255),
Math.round((g + m) * 255),
Math.round((b + m) * 255),
];
}
function rgbToHex(r: number, g: number, b: number): string {
return (
"#" +
[r, g, b]
.map((v) => clamp(v, 0, 255).toString(16).padStart(2, "0"))
.join("")
.toUpperCase()
);
}
function hexToRgb(input: string): [number, number, number] | null {
const m = input.trim().replace(/^#/, "");
if (!/^[0-9a-fA-F]{6}$/.test(m)) return null;
return [
parseInt(m.slice(0, 2), 16),
parseInt(m.slice(2, 4), 16),
parseInt(m.slice(4, 6), 16),
];
}
function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
const rN = r / 255;
const gN = g / 255;
const bN = b / 255;
const max = Math.max(rN, gN, bN);
const min = Math.min(rN, gN, bN);
const d = max - min;
let h = 0;
if (d !== 0) {
if (max === rN) h = ((gN - bN) / d) % 6;
else if (max === gN) h = (bN - rN) / d + 2;
else h = (rN - gN) / d + 4;
h = Math.round(h * 60);
if (h < 0) h += 360;
}
const l = (max + min) / 2;
const s = d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1));
return [h, Math.round(s * 100), Math.round(l * 100)];
}
function readableInk(r: number, g: number, b: number): string {
const lum = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
return lum > 0.55 ? "#0f172a" : "#f8fafc";
}
const CHECKER: CSSProperties = {
backgroundImage:
"conic-gradient(from 90deg, #cbd5e1 25%, #f1f5f9 0 50%, #cbd5e1 0 75%, #f1f5f9 0)",
backgroundSize: "16px 16px",
};
type Preset = { name: string; hex: string };
const PRESETS: Preset[] = [
{ name: "Indigo Dusk", hex: "#6366F1" },
{ name: "Electric Violet", hex: "#8B5CF6" },
{ name: "Sky Signal", hex: "#0EA5E9" },
{ name: "Spring Emerald", hex: "#10B981" },
{ name: "Warm Amber", hex: "#F59E0B" },
{ name: "Rose Alert", hex: "#F43F5E" },
{ name: "Graphite Slate", hex: "#475569" },
{ name: "Near Black", hex: "#171717" },
];
const DEFAULT: Preset = PRESETS[0];
/* --------------------------------- slider -------------------------------- */
type SliderProps = {
label: string;
value: number;
min: number;
max: number;
step: number;
bigStep: number;
displayValue: string;
valueText: string;
trackStyle: CSSProperties;
thumbColor: string;
onChange: (v: number) => void;
};
function ChannelSlider({
label,
value,
min,
max,
step,
bigStep,
displayValue,
valueText,
trackStyle,
thumbColor,
onChange,
}: SliderProps) {
const reduce = useReducedMotion();
const trackRef = useRef<HTMLDivElement | null>(null);
const thumbRef = useRef<HTMLDivElement | null>(null);
const draggingRef = useRef(false);
const labelId = useId();
const pct = ((value - min) / (max - min)) * 100;
const valueFromClientX = (clientX: number): number => {
const el = trackRef.current;
if (!el) return value;
const rect = el.getBoundingClientRect();
const usable = rect.width - THUMB;
const x = clientX - rect.left - THUMB / 2;
const ratio = usable <= 0 ? 0 : clamp(x / usable, 0, 1);
return snap(min + ratio * (max - min), step, min, max);
};
const onPointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {
e.preventDefault();
draggingRef.current = true;
e.currentTarget.setPointerCapture(e.pointerId);
onChange(valueFromClientX(e.clientX));
thumbRef.current?.focus();
};
const onPointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {
if (!draggingRef.current) return;
onChange(valueFromClientX(e.clientX));
};
const onPointerUp = (e: ReactPointerEvent<HTMLDivElement>) => {
draggingRef.current = false;
try {
e.currentTarget.releasePointerCapture(e.pointerId);
} catch {
/* pointer already released */
}
};
const onKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
let next = value;
switch (e.key) {
case "ArrowRight":
case "ArrowUp":
next = value + step;
break;
case "ArrowLeft":
case "ArrowDown":
next = value - step;
break;
case "PageUp":
next = value + bigStep;
break;
case "PageDown":
next = value - bigStep;
break;
case "Home":
next = min;
break;
case "End":
next = max;
break;
default:
return;
}
e.preventDefault();
onChange(clamp(next, min, max));
};
return (
<div className="select-none">
<div className="mb-2 flex items-baseline justify-between">
<span
id={labelId}
className="text-[0.68rem] font-semibold uppercase tracking-[0.18em] text-slate-500 dark:text-slate-400"
>
{label}
</span>
<span className="rounded-md bg-slate-100 px-2 py-0.5 font-mono text-xs tabular-nums text-slate-700 dark:bg-slate-800 dark:text-slate-200">
{displayValue}
</span>
</div>
<div
ref={trackRef}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
className="relative h-4 w-full cursor-pointer touch-none rounded-full ring-1 ring-inset ring-black/10 dark:ring-white/15"
style={trackStyle}
>
<div className="pointer-events-none absolute inset-x-[14px] inset-y-0">
<motion.div
ref={thumbRef}
role="slider"
tabIndex={0}
aria-labelledby={labelId}
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={value}
aria-valuetext={valueText}
onKeyDown={onKeyDown}
whileTap={reduce ? undefined : { scale: 1.14 }}
transition={{ type: "spring", stiffness: 520, damping: 30 }}
className="pointer-events-auto absolute top-1/2 grid h-7 w-7 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full bg-white shadow-[0_2px_8px_rgba(15,23,42,0.35)] ring-1 ring-black/15 focus:outline-none focus-visible:ring-4 focus-visible:ring-indigo-500/60 dark:bg-slate-100 dark:ring-black/40 dark:focus-visible:ring-indigo-400/70"
style={{ left: `${pct}%` }}
>
<span
className="block h-3.5 w-3.5 rounded-full ring-1 ring-black/10"
style={{ background: thumbColor }}
/>
</motion.div>
</div>
</div>
</div>
);
}
/* --------------------------------- readout ------------------------------- */
function ReadoutRow({
label,
value,
copied,
onCopy,
}: {
label: string;
value: string;
copied: boolean;
onCopy: () => void;
}) {
const reduce = useReducedMotion();
return (
<div className="flex items-center gap-2 rounded-xl border border-slate-200 bg-white/70 px-3 py-2 dark:border-slate-700/70 dark:bg-slate-900/50">
<span className="w-9 shrink-0 text-[0.68rem] font-semibold uppercase tracking-[0.14em] text-slate-400 dark:text-slate-500">
{label}
</span>
<code className="flex-1 truncate font-mono text-sm text-slate-800 dark:text-slate-100">
{value}
</code>
<button
type="button"
onClick={onCopy}
aria-label={`Copy ${label} value ${value}`}
className="grid h-8 w-8 shrink-0 place-items-center rounded-lg text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white dark:focus-visible:ring-indigo-400"
>
{copied ? (
<motion.svg
key="check"
initial={reduce ? false : { scale: 0.5, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: "spring", stiffness: 600, damping: 22 }}
viewBox="0 0 24 24"
className="h-4 w-4 text-emerald-500 dark:text-emerald-400"
fill="none"
stroke="currentColor"
strokeWidth={2.4}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M20 6 9 17l-5-5" />
</motion.svg>
) : (
<svg
viewBox="0 0 24 24"
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<rect x="9" y="9" width="11" height="11" rx="2.5" />
<path d="M5 15V5a2 2 0 0 1 2-2h8" />
</svg>
)}
</button>
</div>
);
}
/* ------------------------------- main export ----------------------------- */
export default function SldColorHue() {
const startRgb = hexToRgb(DEFAULT.hex) ?? [99, 102, 241];
const [sh, ss, sl] = rgbToHsl(startRgb[0], startRgb[1], startRgb[2]);
const [hue, setHue] = useState<number>(sh);
const [sat, setSat] = useState<number>(ss);
const [light, setLight] = useState<number>(sl);
const [alpha, setAlpha] = useState<number>(100);
const [hexDraft, setHexDraft] = useState<string>(DEFAULT.hex);
const [copied, setCopied] = useState<string | null>(null);
const copyTimer = useRef<number | null>(null);
const [r, g, b] = hslToRgb(hue, sat, light);
const hex = rgbToHex(r, g, b);
const ink = readableInk(r, g, b);
const a = alpha / 100;
const alphaHex = Math.round(a * 255)
.toString(16)
.padStart(2, "0")
.toUpperCase();
const hexOut = alpha < 100 ? hex + alphaHex : hex;
const rgbOut =
alpha < 100
? `rgba(${r}, ${g}, ${b}, ${a.toFixed(2)})`
: `rgb(${r}, ${g}, ${b})`;
const hslOut =
alpha < 100
? `hsla(${hue}, ${sat}%, ${light}%, ${a.toFixed(2)})`
: `hsl(${hue}, ${sat}%, ${light}%)`;
useEffect(() => {
setHexDraft(hex);
}, [hex]);
useEffect(() => {
return () => {
if (copyTimer.current !== null) window.clearTimeout(copyTimer.current);
};
}, []);
const applyHex = (input: string) => {
const rgb = hexToRgb(input);
if (!rgb) return;
const [nh, ns, nl] = rgbToHsl(rgb[0], rgb[1], rgb[2]);
setHue(nh);
setSat(ns);
setLight(nl);
};
const onHexInput = (e: ChangeEvent<HTMLInputElement>) => {
setHexDraft(e.target.value);
applyHex(e.target.value);
};
const applyPreset = (p: Preset) => {
applyHex(p.hex);
setAlpha(100);
};
const randomize = () => {
setHue(Math.floor(Math.random() * 360));
setSat(45 + Math.floor(Math.random() * 45));
setLight(38 + Math.floor(Math.random() * 32));
};
const reset = () => {
setHue(sh);
setSat(ss);
setLight(sl);
setAlpha(100);
};
const copy = (key: string, text: string) => {
if (typeof navigator !== "undefined" && navigator.clipboard) {
void navigator.clipboard.writeText(text);
}
setCopied(key);
if (copyTimer.current !== null) window.clearTimeout(copyTimer.current);
copyTimer.current = window.setTimeout(() => setCopied(null), 1600);
};
const solid = `hsl(${hue} ${sat}% ${light}%)`;
return (
<section className="relative w-full overflow-hidden bg-slate-50 px-4 py-16 text-slate-900 sm:py-24 dark:bg-slate-950 dark:text-slate-100">
<style>{`
@keyframes sldhue-aurora {
0% { transform: translate3d(-6%, -4%, 0) rotate(0deg); }
50% { transform: translate3d(6%, 5%, 0) rotate(8deg); }
100% { transform: translate3d(-6%, -4%, 0) rotate(0deg); }
}
.sldhue-aurora { animation: sldhue-aurora 22s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.sldhue-aurora { animation: none !important; }
}
`}</style>
{/* ambient backdrop tinted by the live color */}
<div
aria-hidden="true"
className="sldhue-aurora pointer-events-none absolute -top-32 left-1/2 h-[42rem] w-[42rem] -translate-x-1/2 rounded-full opacity-25 blur-3xl dark:opacity-30"
style={{
background: `radial-gradient(circle at 50% 50%, ${solid}, transparent 68%)`,
}}
/>
<div className="relative mx-auto max-w-5xl">
<header className="mb-10 max-w-2xl">
<span className="inline-flex items-center gap-2 rounded-full border border-slate-300/70 bg-white/60 px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.22em] text-slate-500 dark:border-slate-700 dark:bg-slate-900/60 dark:text-slate-400">
<span
className="h-2 w-2 rounded-full"
style={{ background: solid }}
/>
HSL Studio
</span>
<h2 className="mt-4 text-3xl font-semibold tracking-tight text-slate-900 sm:text-4xl dark:text-white">
Hue & Saturation Sliders
</h2>
<p className="mt-3 text-base leading-relaxed text-slate-600 dark:text-slate-400">
Drag, arrow-key, or paste a hex value to dial in an exact color.
Every channel updates the preview, the ambient glow, and the copyable
output in real time.
</p>
</header>
<div className="grid gap-6 rounded-3xl border border-slate-200 bg-white/70 p-5 shadow-xl shadow-slate-900/5 backdrop-blur-sm sm:p-7 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.15fr)] dark:border-slate-800 dark:bg-slate-900/60 dark:shadow-black/40">
{/* preview + outputs */}
<div className="flex flex-col gap-4">
<div className="relative aspect-[4/3] overflow-hidden rounded-2xl ring-1 ring-inset ring-black/10 dark:ring-white/10">
<div className="absolute inset-0" style={CHECKER} />
<div
className="absolute inset-0 transition-colors duration-150"
style={{ background: `hsl(${hue} ${sat}% ${light}% / ${a})` }}
/>
<div className="relative flex h-full flex-col justify-between p-5">
<span
className="text-[0.7rem] font-semibold uppercase tracking-[0.2em]"
style={{ color: ink, opacity: 0.75 }}
>
Live preview
</span>
<div>
<div
className="font-mono text-2xl font-semibold tabular-nums sm:text-3xl"
style={{ color: ink }}
>
{hexOut}
</div>
<div
className="mt-1 font-mono text-xs tabular-nums"
style={{ color: ink, opacity: 0.72 }}
>
H {hue}° · S {sat}% · L {light}% · A{" "}
{alpha}%
</div>
</div>
</div>
</div>
<div className="flex flex-col gap-2">
<ReadoutRow
label="HEX"
value={hexOut}
copied={copied === "hex"}
onCopy={() => copy("hex", hexOut)}
/>
<ReadoutRow
label="RGB"
value={rgbOut}
copied={copied === "rgb"}
onCopy={() => copy("rgb", rgbOut)}
/>
<ReadoutRow
label="HSL"
value={hslOut}
copied={copied === "hsl"}
onCopy={() => copy("hsl", hslOut)}
/>
</div>
</div>
{/* controls */}
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-5">
<ChannelSlider
label="Hue"
value={hue}
min={0}
max={360}
step={1}
bigStep={15}
displayValue={`${hue}°`}
valueText={`${hue} degrees`}
thumbColor={`hsl(${hue} 90% 55%)`}
onChange={setHue}
trackStyle={{
background:
"linear-gradient(90deg, hsl(0 100% 50%), hsl(60 100% 50%), hsl(120 100% 50%), hsl(180 100% 50%), hsl(240 100% 50%), hsl(300 100% 50%), hsl(360 100% 50%))",
}}
/>
<ChannelSlider
label="Saturation"
value={sat}
min={0}
max={100}
step={1}
bigStep={10}
displayValue={`${sat}%`}
valueText={`${sat} percent`}
thumbColor={solid}
onChange={setSat}
trackStyle={{
background: `linear-gradient(90deg, hsl(${hue} 0% ${light}%), hsl(${hue} 100% ${light}%))`,
}}
/>
<ChannelSlider
label="Lightness"
value={light}
min={0}
max={100}
step={1}
bigStep={10}
displayValue={`${light}%`}
valueText={`${light} percent`}
thumbColor={solid}
onChange={setLight}
trackStyle={{
background: `linear-gradient(90deg, hsl(${hue} ${sat}% 0%), hsl(${hue} ${sat}% 50%), hsl(${hue} ${sat}% 100%))`,
}}
/>
<ChannelSlider
label="Alpha"
value={alpha}
min={0}
max={100}
step={1}
bigStep={10}
displayValue={`${alpha}%`}
valueText={`${alpha} percent opacity`}
thumbColor={`hsl(${hue} ${sat}% ${light}% / ${a})`}
onChange={setAlpha}
trackStyle={{
backgroundImage: `linear-gradient(90deg, hsl(${hue} ${sat}% ${light}% / 0), hsl(${hue} ${sat}% ${light}% / 1)), ${CHECKER.backgroundImage}`,
backgroundSize: `100% 100%, 16px 16px`,
}}
/>
</div>
<div className="flex flex-col gap-3 border-t border-slate-200 pt-5 dark:border-slate-800">
<div className="flex flex-wrap items-center gap-2">
<label
htmlFor="sldhue-hex"
className="text-[0.68rem] font-semibold uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400"
>
Hex
</label>
<div className="flex items-center gap-2 rounded-xl border border-slate-300 bg-white px-3 py-1.5 focus-within:ring-2 focus-within:ring-indigo-500 dark:border-slate-700 dark:bg-slate-950 dark:focus-within:ring-indigo-400">
<span
className="h-4 w-4 rounded-full ring-1 ring-black/10"
style={{ background: hex }}
/>
<input
id="sldhue-hex"
value={hexDraft}
onChange={onHexInput}
spellCheck={false}
autoComplete="off"
inputMode="text"
maxLength={7}
aria-label="Hex color value"
className="w-[7.5rem] bg-transparent font-mono text-sm uppercase tabular-nums text-slate-800 outline-none placeholder:text-slate-400 dark:text-slate-100"
placeholder="#6366F1"
/>
</div>
<div className="ml-auto flex gap-2">
<button
type="button"
onClick={randomize}
className="inline-flex items-center gap-1.5 rounded-xl border border-slate-300 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800 dark:focus-visible:ring-indigo-400"
>
<svg
viewBox="0 0 24 24"
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M16 3h5v5" />
<path d="M4 20 21 3" />
<path d="M21 16v5h-5" />
<path d="m15 15 6 6" />
<path d="M4 4l5 5" />
</svg>
Random
</button>
<button
type="button"
onClick={reset}
className="inline-flex items-center gap-1.5 rounded-xl border border-slate-300 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800 dark:focus-visible:ring-indigo-400"
>
Reset
</button>
</div>
</div>
<div>
<span className="mb-2 block text-[0.68rem] font-semibold uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400">
Swatches
</span>
<div className="flex flex-wrap gap-2">
{PRESETS.map((p) => {
const active =
p.hex.toUpperCase() === hex.toUpperCase() && alpha === 100;
return (
<button
key={p.hex}
type="button"
onClick={() => applyPreset(p)}
aria-label={`Use ${p.name} (${p.hex})`}
aria-pressed={active}
title={p.name}
className={`h-8 w-8 rounded-full ring-1 ring-inset ring-black/15 transition-transform hover:scale-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-indigo-500 dark:ring-white/20 dark:focus-visible:ring-offset-slate-900 dark:focus-visible:ring-indigo-400 ${
active
? "scale-110 ring-2 ring-slate-900 dark:ring-white"
: ""
}`}
style={{ background: p.hex }}
/>
);
})}
</div>
</div>
</div>
</div>
</div>
<p className="mt-4 text-center text-xs text-slate-400 dark:text-slate-500">
Tip: focus any handle and use the arrow keys for fine steps, or Page
Up / Page Down for larger jumps.
</p>
</div>
<span aria-live="polite" className="sr-only">
{copied ? `${copied.toUpperCase()} value copied to clipboard` : ""}
</span>
</section>
);
}Dependencies
motion
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Basic Slider
Originalbasic range slider with value bubble

Range Dual Slider
Originaldual-handle min/max range

Steps Slider
Originalstepped slider with tick marks

Vertical Slider
Originalvertical sliders

Tooltip Slider
Originalslider with a floating value tooltip

Volume Slider
Originalvolume slider with icon

Price Range Slider
Originalprice range filter slider

Gradient Track Slider
Originalslider with a gradient track

Rating Slider
Originalsatisfaction rating slider

Brightness Slider
Originalbrightness/contrast control sliders

Marks Slider
Originalslider with labelled marks

Progress Drag Slider
Originalmedia scrubber slider

