Colour Hex Input
Original · freehex/rgb input with preview
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"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
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 →
Colour Swatch Picker
Originalswatch colour picker

Colour Gradient Picker
Originalgradient builder picker

Colour Hue Wheel
Originalhue wheel colour picker

Colour Palette
Originalpalette generator with copy

Colour Picker Panel
Originalfull colour picker panel with sliders

Colour Shades
Originalshade and tint scale generator

Colour Scheme
Originalcolour scheme preview swatches

Spotlight Hero
OriginalA centred hero with a soft radial spotlight, badge and dual call-to-action.

Split Hero
OriginalA two-column hero pairing a headline and CTAs with a product mock and social proof.

Gradient Spotlight Hero
OriginalA minimal centred hero with a soft gradient-mesh backdrop, announcement pill and dual call-to-action buttons.

App Preview Hero
OriginalA centred hero that pairs headline copy with a realistic product dashboard mock built entirely from markup, complete with browser chrome and a floating notification card.

Waitlist Capture Hero
OriginalA dark, focused hero with an inline email capture form and avatar social proof, ready for pre-launch waitlists.

