Web InnoventixFreeCode

Color Hue Slider

Original · free

hue/saturation color sliders

byWeb InnoventixReact + Tailwind
sldcolorhuesliders
Open

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.json
sld-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 &amp; 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}&deg; &middot; S {sat}% &middot; L {light}% &middot; 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 quote

Similar components

Browse all →