Web InnoventixFreeCode

Colour Hex Input

Original · free

hex/rgb input with preview

byWeb InnoventixReact + Tailwind
colorhexinputpickers
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/color-hex-input.json
color-hex-input.tsx
"use client";

import type { KeyboardEvent } from "react";
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import { motion, useReducedMotion } from "motion/react";

type Rgb = { r: number; g: number; b: number };
type Hsl = { h: number; s: number; l: number };
type Mode = "hex" | "rgb" | "hsl";
type Status = "idle" | "copied";

const SWATCHES: { hex: string; name: string }[] = [
  { hex: "#0F172A", name: "Slate 900" },
  { hex: "#4F46E5", name: "Indigo 600" },
  { hex: "#7C3AED", name: "Violet 600" },
  { hex: "#0EA5E9", name: "Sky 500" },
  { hex: "#10B981", name: "Emerald 500" },
  { hex: "#F59E0B", name: "Amber 500" },
  { hex: "#F43F5E", name: "Rose 500" },
  { hex: "#FAFAF9", name: "Stone 50" },
];

function clamp(value: number, min: number, max: number): number {
  return Math.min(max, Math.max(min, value));
}

function normalizeHex(raw: string): string | null {
  const cleaned = raw.trim().replace(/^#/, "").toLowerCase();
  if (/^[0-9a-f]{3}$/.test(cleaned)) {
    return `#${cleaned[0]}${cleaned[0]}${cleaned[1]}${cleaned[1]}${cleaned[2]}${cleaned[2]}`.toUpperCase();
  }
  if (/^[0-9a-f]{6}$/.test(cleaned)) {
    return `#${cleaned}`.toUpperCase();
  }
  return null;
}

function hexToRgb(hex: string): Rgb {
  const v = hex.replace("#", "");
  return {
    r: parseInt(v.slice(0, 2), 16),
    g: parseInt(v.slice(2, 4), 16),
    b: parseInt(v.slice(4, 6), 16),
  };
}

function rgbToHex({ r, g, b }: Rgb): string {
  const part = (n: number): string =>
    clamp(Math.round(n), 0, 255).toString(16).padStart(2, "0");
  return `#${part(r)}${part(g)}${part(b)}`.toUpperCase();
}

function rgbToHsl({ r, g, b }: Rgb): Hsl {
  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 delta = max - min;
  let h = 0;
  if (delta !== 0) {
    if (max === rn) h = ((gn - bn) / delta) % 6;
    else if (max === gn) h = (bn - rn) / delta + 2;
    else h = (rn - gn) / delta + 4;
  }
  h = Math.round(h * 60);
  if (h < 0) h += 360;
  const l = (max + min) / 2;
  const s = delta === 0 ? 0 : delta / (1 - Math.abs(2 * l - 1));
  return { h, s: Math.round(s * 100), l: Math.round(l * 100) };
}

function hslToRgb({ h, s, l }: Hsl): Rgb {
  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 rp = 0;
  let gp = 0;
  let bp = 0;
  if (h < 60) [rp, gp, bp] = [c, x, 0];
  else if (h < 120) [rp, gp, bp] = [x, c, 0];
  else if (h < 180) [rp, gp, bp] = [0, c, x];
  else if (h < 240) [rp, gp, bp] = [0, x, c];
  else if (h < 300) [rp, gp, bp] = [x, 0, c];
  else [rp, gp, bp] = [c, 0, x];
  return {
    r: Math.round((rp + m) * 255),
    g: Math.round((gp + m) * 255),
    b: Math.round((bp + m) * 255),
  };
}

function channelLuminance(channel: number): number {
  const c = channel / 255;
  return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
}

function relativeLuminance({ r, g, b }: Rgb): number {
  return (
    0.2126 * channelLuminance(r) +
    0.7152 * channelLuminance(g) +
    0.0722 * channelLuminance(b)
  );
}

function contrastRatio(a: Rgb, b: Rgb): number {
  const la = relativeLuminance(a);
  const lb = relativeLuminance(b);
  const light = Math.max(la, lb);
  const dark = Math.min(la, lb);
  return (light + 0.05) / (dark + 0.05);
}

function parseRgbString(raw: string): Rgb | null {
  const nums = raw.match(/-?\d+(\.\d+)?/g);
  if (!nums || nums.length < 3) return null;
  const [r, g, b] = nums.slice(0, 3).map(Number);
  if ([r, g, b].some((n) => Number.isNaN(n) || n < 0 || n > 255)) return null;
  return { r: Math.round(r), g: Math.round(g), b: Math.round(b) };
}

function parseHslString(raw: string): Hsl | null {
  const nums = raw.match(/-?\d+(\.\d+)?/g);
  if (!nums || nums.length < 3) return null;
  const [h, s, l] = nums.slice(0, 3).map(Number);
  if ([h, s, l].some((n) => Number.isNaN(n))) return null;
  if (h < 0 || h > 360 || s < 0 || s > 100 || l < 0 || l > 100) return null;
  return { h: Math.round(h) % 360, s: Math.round(s), l: Math.round(l) };
}

function formatRgb({ r, g, b }: Rgb): string {
  return `rgb(${r}, ${g}, ${b})`;
}

function formatHsl({ h, s, l }: Hsl): string {
  return `hsl(${h}, ${s}%, ${l}%)`;
}

const MODES: { id: Mode; label: string; hint: string }[] = [
  { id: "hex", label: "HEX", hint: "#4F46E5 or #4F4" },
  { id: "rgb", label: "RGB", hint: "79, 70, 229" },
  { id: "hsl", label: "HSL", hint: "243, 75%, 59%" },
];

export default function ColorHexInput() {
  const reduceMotion = useReducedMotion();
  const uid = useId();
  const [hex, setHex] = useState<string>("#4F46E5");
  const [mode, setMode] = useState<Mode>("hex");
  const [draft, setDraft] = useState<string>("#4F46E5");
  const [invalid, setInvalid] = useState<boolean>(false);
  const [status, setStatus] = useState<Status>("idle");
  const [announcement, setAnnouncement] = useState<string>("");
  const copyTimer = useRef<number | null>(null);
  const inputRef = useRef<HTMLInputElement | null>(null);
  const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);

  const rgb = useMemo<Rgb>(() => hexToRgb(hex), [hex]);
  const hsl = useMemo<Hsl>(() => rgbToHsl(rgb), [rgb]);

  const onWhite = useMemo(() => contrastRatio(rgb, { r: 255, g: 255, b: 255 }), [rgb]);
  const onBlack = useMemo(() => contrastRatio(rgb, { r: 0, g: 0, b: 0 }), [rgb]);
  const bestText = onWhite >= onBlack ? "#FFFFFF" : "#0B0B0F";
  const bestRatio = Math.max(onWhite, onBlack);

  const displayFor = useCallback(
    (m: Mode, source: string): string => {
      const asRgb = hexToRgb(source);
      if (m === "hex") return source;
      if (m === "rgb") return formatRgb(asRgb);
      return formatHsl(rgbToHsl(asRgb));
    },
    []
  );

  useEffect(() => {
    setDraft(displayFor(mode, hex));
    setInvalid(false);
  }, [mode, hex, displayFor]);

  useEffect(() => {
    return () => {
      if (copyTimer.current !== null) window.clearTimeout(copyTimer.current);
    };
  }, []);

  const commit = useCallback(
    (value: string) => {
      if (mode === "hex") {
        const next = normalizeHex(value);
        if (!next) {
          setInvalid(true);
          setAnnouncement("Invalid hex value. Use three or six hex digits.");
          return;
        }
        setHex(next);
        setAnnouncement(`Colour set to ${next}`);
        setInvalid(false);
        return;
      }
      if (mode === "rgb") {
        const parsed = parseRgbString(value);
        if (!parsed) {
          setInvalid(true);
          setAnnouncement("Invalid RGB value. Each channel must be 0 to 255.");
          return;
        }
        const next = rgbToHex(parsed);
        setHex(next);
        setAnnouncement(`Colour set to ${formatRgb(parsed)}`);
        setInvalid(false);
        return;
      }
      const parsed = parseHslString(value);
      if (!parsed) {
        setInvalid(true);
        setAnnouncement("Invalid HSL value. Use hue 0-360 and percentages 0-100.");
        return;
      }
      const next = rgbToHex(hslToRgb(parsed));
      setHex(next);
      setAnnouncement(`Colour set to ${formatHsl(parsed)}`);
      setInvalid(false);
    },
    [mode]
  );

  const handleCopy = useCallback(async () => {
    const text = displayFor(mode, hex);
    try {
      await navigator.clipboard.writeText(text);
    } catch {
      setAnnouncement("Clipboard unavailable. Select the field and copy manually.");
      return;
    }
    setStatus("copied");
    setAnnouncement(`${text} copied to clipboard`);
    if (copyTimer.current !== null) window.clearTimeout(copyTimer.current);
    copyTimer.current = window.setTimeout(() => setStatus("idle"), 1600);
  }, [displayFor, hex, mode]);

  const nudge = useCallback(
    (delta: number) => {
      const next = rgbToHex(hslToRgb({ ...hsl, l: clamp(hsl.l + delta, 0, 100) }));
      setHex(next);
      setAnnouncement(`Lightness ${clamp(hsl.l + delta, 0, 100)} percent, ${next}`);
    },
    [hsl]
  );

  const onTabKeyDown = useCallback(
    (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
      let nextIndex: number | null = null;
      if (event.key === "ArrowRight") nextIndex = (index + 1) % MODES.length;
      else if (event.key === "ArrowLeft") nextIndex = (index - 1 + MODES.length) % MODES.length;
      else if (event.key === "Home") nextIndex = 0;
      else if (event.key === "End") nextIndex = MODES.length - 1;
      if (nextIndex === null) return;
      event.preventDefault();
      setMode(MODES[nextIndex].id);
      tabRefs.current[nextIndex]?.focus();
    },
    []
  );

  const activeHint = MODES.find((m) => m.id === mode)?.hint ?? "";

  return (
    <section className="relative w-full bg-slate-50 px-5 py-20 text-slate-900 sm:px-8 sm:py-24 dark:bg-slate-950 dark:text-slate-100">
      <style>{`
        @keyframes chxi-swatch-pop {
          0% { transform: scale(0.9); opacity: 0; }
          60% { transform: scale(1.04); opacity: 1; }
          100% { transform: scale(1); opacity: 1; }
        }
        @keyframes chxi-shake {
          0%, 100% { transform: translateX(0); }
          20% { transform: translateX(-4px); }
          40% { transform: translateX(4px); }
          60% { transform: translateX(-3px); }
          80% { transform: translateX(2px); }
        }
        .chxi-pop { animation: chxi-swatch-pop 320ms cubic-bezier(0.22, 1, 0.36, 1) both; }
        .chxi-shake { animation: chxi-shake 300ms ease-in-out both; }
        @media (prefers-reduced-motion: reduce) {
          .chxi-pop, .chxi-shake { animation: none !important; }
        }
      `}</style>

      <div className="mx-auto max-w-3xl">
        <header className="mb-10">
          <p className="text-xs font-semibold uppercase tracking-[0.18em] text-indigo-600 dark:text-indigo-400">
            Colour utilities
          </p>
          <h2 className="mt-3 text-3xl font-semibold tracking-tight sm:text-4xl">
            Hex, RGB and HSL in one field
          </h2>
          <p className="mt-3 max-w-xl text-sm leading-relaxed text-slate-600 dark:text-slate-400">
            Paste any of the three formats. The field parses it, converts across all
            notations, and grades the contrast so you know whether text will survive on top.
          </p>
        </header>

        <div className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
          <motion.div
            key={hex}
            initial={reduceMotion ? false : { opacity: 0.7 }}
            animate={{ opacity: 1 }}
            transition={{ duration: reduceMotion ? 0 : 0.28, ease: "easeOut" }}
            className="flex flex-col gap-4 px-6 py-10 sm:flex-row sm:items-end sm:justify-between sm:px-8"
            style={{ backgroundColor: hex }}
          >
            <div>
              <p
                className="text-2xl font-semibold tracking-tight sm:text-3xl"
                style={{ color: bestText }}
              >
                {hex}
              </p>
              <p
                className="mt-1 text-xs font-medium tracking-wide opacity-80"
                style={{ color: bestText }}
              >
                {formatRgb(rgb)} · {formatHsl(hsl)}
              </p>
            </div>
            <div
              className="inline-flex items-center gap-2 self-start rounded-full px-3 py-1.5 text-xs font-semibold sm:self-auto"
              style={{ backgroundColor: bestText, color: hex }}
            >
              <span aria-hidden="true">◑</span>
              {bestRatio.toFixed(2)}:1 vs {bestText === "#FFFFFF" ? "white" : "black"}
            </div>
          </motion.div>

          <div className="border-t border-slate-200 p-6 sm:p-8 dark:border-slate-800">
            <div
              role="tablist"
              aria-label="Colour notation"
              className="inline-flex rounded-lg border border-slate-200 bg-slate-100 p-1 dark:border-slate-800 dark:bg-slate-950"
            >
              {MODES.map((m, i) => {
                const selected = m.id === mode;
                return (
                  <button
                    key={m.id}
                    ref={(node) => {
                      tabRefs.current[i] = node;
                    }}
                    role="tab"
                    type="button"
                    id={`${uid}-tab-${m.id}`}
                    aria-selected={selected}
                    aria-controls={`${uid}-panel`}
                    tabIndex={selected ? 0 : -1}
                    onClick={() => setMode(m.id)}
                    onKeyDown={(e) => onTabKeyDown(e, i)}
                    className={`rounded-md px-4 py-1.5 text-xs font-semibold tracking-wide transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900 ${
                      selected
                        ? "bg-white text-slate-900 shadow-sm dark:bg-slate-800 dark:text-slate-50"
                        : "text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-200"
                    }`}
                  >
                    {m.label}
                  </button>
                );
              })}
            </div>

            <div
              role="tabpanel"
              id={`${uid}-panel`}
              aria-labelledby={`${uid}-tab-${mode}`}
              className="mt-5"
            >
              <label
                htmlFor={`${uid}-field`}
                className="block text-xs font-semibold uppercase tracking-[0.14em] text-slate-500 dark:text-slate-400"
              >
                {mode} value
              </label>
              <div className="mt-2 flex flex-col gap-3 sm:flex-row">
                <div className="relative flex-1">
                  <span
                    aria-hidden="true"
                    className="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 rounded-full border border-slate-900/10 dark:border-white/20"
                    style={{ backgroundColor: hex }}
                  />
                  <input
                    ref={inputRef}
                    id={`${uid}-field`}
                    type="text"
                    inputMode="text"
                    spellCheck={false}
                    autoComplete="off"
                    value={draft}
                    aria-invalid={invalid}
                    aria-describedby={`${uid}-hint`}
                    onChange={(e) => {
                      setDraft(e.target.value);
                      if (invalid) setInvalid(false);
                    }}
                    onBlur={(e) => commit(e.target.value)}
                    onKeyDown={(e) => {
                      if (e.key === "Enter") {
                        e.preventDefault();
                        commit(draft);
                      } else if (e.key === "Escape") {
                        e.preventDefault();
                        setDraft(displayFor(mode, hex));
                        setInvalid(false);
                      } else if (e.key === "ArrowUp" || e.key === "ArrowDown") {
                        e.preventDefault();
                        nudge(e.key === "ArrowUp" ? 4 : -4);
                      }
                    }}
                    className={`${
                      invalid && !reduceMotion ? "chxi-shake " : ""
                    }w-full rounded-lg border bg-white py-2.5 pl-10 pr-3 font-mono text-sm tracking-tight text-slate-900 shadow-sm transition-colors placeholder:text-slate-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-slate-950 dark:text-slate-100 dark:focus-visible:ring-offset-slate-900 ${
                      invalid
                        ? "border-rose-500 focus-visible:ring-rose-500 dark:border-rose-500"
                        : "border-slate-300 focus-visible:ring-indigo-500 dark:border-slate-700"
                    }`}
                    placeholder={activeHint}
                  />
                </div>
                <button
                  type="button"
                  onClick={handleCopy}
                  className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-300 bg-white px-4 py-2.5 text-sm font-semibold text-slate-800 shadow-sm transition-colors hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-700 dark:bg-slate-950 dark:text-slate-100 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-900"
                >
                  {status === "copied" ? (
                    <svg
                      viewBox="0 0 20 20"
                      className="h-4 w-4 text-emerald-600 dark:text-emerald-400"
                      fill="none"
                      stroke="currentColor"
                      strokeWidth="1.8"
                      strokeLinecap="round"
                      strokeLinejoin="round"
                      aria-hidden="true"
                    >
                      <path d="M4 10.5l4 4 8-9" />
                    </svg>
                  ) : (
                    <svg
                      viewBox="0 0 20 20"
                      className="h-4 w-4"
                      fill="none"
                      stroke="currentColor"
                      strokeWidth="1.6"
                      strokeLinecap="round"
                      strokeLinejoin="round"
                      aria-hidden="true"
                    >
                      <rect x="7" y="7" width="9" height="9" rx="2" />
                      <path d="M13 4.5H6A1.5 1.5 0 004.5 6v7" />
                    </svg>
                  )}
                  {status === "copied" ? "Copied" : "Copy"}
                </button>
              </div>

              <p
                id={`${uid}-hint`}
                className={`mt-2 text-xs ${
                  invalid
                    ? "font-medium text-rose-600 dark:text-rose-400"
                    : "text-slate-500 dark:text-slate-400"
                }`}
              >
                {invalid
                  ? `That is not a valid ${mode.toUpperCase()} value. Try ${activeHint}.`
                  : `Enter to apply, Escape to revert, Up and Down arrows shift lightness. Example: ${activeHint}`}
              </p>
            </div>

            <div className="mt-7">
              <p className="text-xs font-semibold uppercase tracking-[0.14em] text-slate-500 dark:text-slate-400">
                Presets
              </p>
              <ul className="mt-3 flex flex-wrap gap-2">
                {SWATCHES.map((swatch) => {
                  const active = swatch.hex === hex;
                  return (
                    <li key={swatch.hex}>
                      <button
                        type="button"
                        onClick={() => {
                          setHex(swatch.hex);
                          setAnnouncement(`${swatch.name}, ${swatch.hex} applied`);
                        }}
                        aria-pressed={active}
                        title={`${swatch.name} — ${swatch.hex}`}
                        className={`${
                          active && !reduceMotion ? "chxi-pop " : ""
                        }h-9 w-9 rounded-lg border transition-transform hover:scale-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900 ${
                          active
                            ? "border-slate-900 dark:border-slate-100"
                            : "border-slate-300 dark:border-slate-700"
                        }`}
                        style={{ backgroundColor: swatch.hex }}
                      >
                        <span className="sr-only">
                          {swatch.name} {swatch.hex}
                        </span>
                      </button>
                    </li>
                  );
                })}
              </ul>
            </div>

            <dl className="mt-7 grid grid-cols-2 gap-3 sm:grid-cols-4">
              {[
                { label: "Hex", value: hex },
                { label: "RGB", value: `${rgb.r} ${rgb.g} ${rgb.b}` },
                { label: "HSL", value: `${hsl.h} ${hsl.s}% ${hsl.l}%` },
                {
                  label: "AA body text",
                  value: bestRatio >= 4.5 ? "Passes" : "Fails",
                },
              ].map((cell) => (
                <div
                  key={cell.label}
                  className="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5 dark:border-slate-800 dark:bg-slate-950"
                >
                  <dt className="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-500 dark:text-slate-400">
                    {cell.label}
                  </dt>
                  <dd className="mt-1 font-mono text-xs text-slate-900 dark:text-slate-100">
                    {cell.value}
                  </dd>
                </div>
              ))}
            </dl>
          </div>
        </div>

        <p aria-live="polite" role="status" className="sr-only">
          {announcement}
        </p>
      </div>
    </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 →