Fazekit

Code

"use client";
import * as React from "react";
import { AnimatePresence, motion, useAnimationControls, useReducedMotion } from "motion/react";
import { Check, Copy, Pipette } from "lucide-react";
import { cn } from "@/lib/utils";

/* ----------------------------------------------------------------------------
 * Colour math
 * ------------------------------------------------------------------------- */

export type HSVA = { h: number; s: number; v: number; a: number }; // h 0-360, s/v/a 0-1
export type RGBA = { r: number; g: number; b: number; a: number }; // 0-255, a 0-1

const clamp = (n: number, lo = 0, hi = 1) => Math.min(hi, Math.max(lo, n));
const round = (n: number, d = 0) => Math.round(n * 10 ** d) / 10 ** d;

export function hsvToRgb({ h, s, v, a }: HSVA): RGBA {
  const f = (n: number) => {
    const k = (n + h / 60) % 6;
    return v - v * s * Math.max(0, Math.min(k, 4 - k, 1));
  };
  return { r: Math.round(f(5) * 255), g: Math.round(f(3) * 255), b: Math.round(f(1) * 255), a };
}

export function rgbToHsv({ r, g, b, a }: RGBA): HSVA {
  const R = r / 255;
  const G = g / 255;
  const B = b / 255;
  const max = Math.max(R, G, B);
  const d = max - Math.min(R, G, B);
  let h = 0;
  if (d) {
    if (max === R) h = ((G - B) / d) % 6;
    else if (max === G) h = (B - R) / d + 2;
    else h = (R - G) / d + 4;
    h *= 60;
    if (h < 0) h += 360;
  }
  return { h, s: max ? d / max : 0, v: max, a };
}

const hex2 = (n: number) => Math.round(n).toString(16).padStart(2, "0");
export function rgbToHex({ r, g, b, a }: RGBA, withAlpha = true) {
  return `#${hex2(r)}${hex2(g)}${hex2(b)}${withAlpha && a < 1 ? hex2(a * 255) : ""}`.toUpperCase();
}

function rgbToHsl({ r, g, b, a }: RGBA) {
  const R = r / 255;
  const G = g / 255;
  const B = b / 255;
  const max = Math.max(R, G, B);
  const min = Math.min(R, G, B);
  const l = (max + min) / 2;
  const d = max - min;
  const s = d ? d / (1 - Math.abs(2 * l - 1)) : 0;
  const { h } = rgbToHsv({ r, g, b, a });
  return { h, s, l, a };
}

function hslToRgb(h: number, s: number, l: number, a: number): RGBA {
  const k = (n: number) => (n + h / 30) % 12;
  const q = s * Math.min(l, 1 - l);
  const f = (n: number) => l - q * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1));
  return { r: Math.round(f(0) * 255), g: Math.round(f(8) * 255), b: Math.round(f(4) * 255), a };
}

/** Parses #rgb, #rgba, #rrggbb, #rrggbbaa, rgb()/rgba(), hsl()/hsla(). */
export function parseColor(input: string): RGBA | null {
  const s = input.trim().toLowerCase();
  const hex = s.match(/^#?([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/);
  if (hex) {
    let h = hex[1];
    if (h.length <= 4) h = h.split("").map((c) => c + c).join("");
    const n = (i: number) => parseInt(h.slice(i, i + 2), 16);
    return { r: n(0), g: n(2), b: n(4), a: h.length === 8 ? round(n(6) / 255, 2) : 1 };
  }
  const nums = (str: string) => str.split(/[\s,/]+/).filter(Boolean);
  const pct = (x: string, max: number) => (x.endsWith("%") ? (parseFloat(x) / 100) * max : parseFloat(x));
  const rgb = s.match(/^rgba?\((.+)\)$/);
  if (rgb) {
    const p = nums(rgb[1]);
    if (p.length < 3) return null;
    const [r, g, b] = p.slice(0, 3).map((x) => clamp(pct(x, 255), 0, 255));
    const a = p[3] !== undefined ? clamp(pct(p[3], 1)) : 1;
    return [r, g, b, a].some(Number.isNaN) ? null : { r: Math.round(r), g: Math.round(g), b: Math.round(b), a };
  }
  const hsl = s.match(/^hsla?\((.+)\)$/);
  if (hsl) {
    const p = nums(hsl[1]);
    if (p.length < 3) return null;
    const h = ((parseFloat(p[0]) % 360) + 360) % 360;
    const sat = clamp(parseFloat(p[1]) / 100);
    const l = clamp(parseFloat(p[2]) / 100);
    const a = p[3] !== undefined ? clamp(pct(p[3], 1)) : 1;
    return [h, sat, l, a].some(Number.isNaN) ? null : hslToRgb(h, sat, l, a);
  }
  return null;
}

function luminance({ r, g, b }: RGBA) {
  const c = (x: number) => {
    const v = x / 255;
    return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
  };
  return 0.2126 * c(r) + 0.7152 * c(g) + 0.0722 * c(b);
}
const contrast = (a: RGBA, b: RGBA) => {
  const [x, y] = [luminance(a), luminance(b)].sort((m, n) => n - m);
  return (x + 0.05) / (y + 0.05);
};

export function formatColor(rgba: RGBA, format: "hex" | "rgb" | "hsl") {
  if (format === "hex") return rgbToHex(rgba);
  if (format === "rgb") return rgba.a < 1 ? `rgba(${rgba.r}, ${rgba.g}, ${rgba.b}, ${round(rgba.a, 2)})` : `rgb(${rgba.r}, ${rgba.g}, ${rgba.b})`;
  const { h, s, l, a } = rgbToHsl(rgba);
  const core = `${Math.round(h)}, ${Math.round(s * 100)}%, ${Math.round(l * 100)}%`;
  return a < 1 ? `hsla(${core}, ${round(a, 2)})` : `hsl(${core})`;
}

/* ----------------------------------------------------------------------------
 * Hooks
 * ------------------------------------------------------------------------- */

type EyeDropperCtor = new () => { open: () => Promise<{ sRGBHex: string }> };
const noopSubscribe = () => () => {};
function useEyeDropper(): EyeDropperCtor | null {
  const supported = React.useSyncExternalStore(
    noopSubscribe,
    () => "EyeDropper" in window,
    () => false,
  );
  return supported ? (window as unknown as { EyeDropper: EyeDropperCtor }).EyeDropper : null;
}

/** Pointer drag on an element; reports 0-1 coordinates. */
function useDrag(onMove: (x: number, y: number) => void) {
  const ref = React.useRef<HTMLDivElement>(null);
  const [dragging, setDragging] = React.useState(false);
  const handlers = {
    onPointerDown: (e: React.PointerEvent<HTMLDivElement>) => {
      if (e.button !== 0) return;
      e.preventDefault();
      const el = ref.current;
      if (!el) return;
      el.setPointerCapture(e.pointerId);
      el.focus({ preventScroll: true });
      setDragging(true);
      const r = el.getBoundingClientRect();
      onMove(clamp((e.clientX - r.left) / r.width), clamp((e.clientY - r.top) / r.height));
    },
    onPointerMove: (e: React.PointerEvent<HTMLDivElement>) => {
      if (!dragging || !ref.current) return;
      const r = ref.current.getBoundingClientRect();
      onMove(clamp((e.clientX - r.left) / r.width), clamp((e.clientY - r.top) / r.height));
    },
    onPointerUp: () => setDragging(false),
    onPointerCancel: () => setDragging(false),
  };
  return { ref, dragging, handlers };
}

/* ----------------------------------------------------------------------------
 * Component
 * ------------------------------------------------------------------------- */

export type ColorValue = { hex: string; rgba: RGBA; css: string };

export interface ColorPickerProps {
  defaultValue?: string;
  onChange?: (color: ColorValue) => void;
  swatches?: string[];
  showAlpha?: boolean;
  defaultFormat?: "hex" | "rgb" | "hsl";
  label?: string;
  className?: string;
}

const DEFAULT_SWATCHES = ["#0F172A", "#EF4444", "#F97316", "#EAB308", "#22C55E", "#14B8A6", "#0EA5E9", "#6366F1", "#A855F7", "#EC4899", "#F43F5E80", "#FFFFFF"];

const CHECKER =
  "repeating-conic-gradient(color-mix(in oklab, currentColor 18%, transparent) 0 25%, transparent 0 50%) 0 0 / 10px 10px";

export function ColorPicker({
  defaultValue = "#6366F1",
  onChange,
  swatches = DEFAULT_SWATCHES,
  showAlpha = true,
  defaultFormat = "hex",
  label = "Colour",
  className,
}: ColorPickerProps) {
  const uid = React.useId();
  const reduce = useReducedMotion();
  const EyeDropper = useEyeDropper();
  const [hsva, setHsva] = React.useState<HSVA>(() => rgbToHsv(parseColor(defaultValue) ?? { r: 99, g: 102, b: 241, a: 1 }));
  const [initial] = React.useState(hsva);
  const [format, setFormat] = React.useState(defaultFormat);
  const [draft, setDraft] = React.useState<string | null>(null);
  const shake = useAnimationControls();
  const [copied, setCopied] = React.useState(false);

  const rgba = hsvToRgb(hsva);
  const hex = rgbToHex(rgba);
  const css = formatColor(rgba, format);
  const opaque = rgbToHex(rgba, false);
  const hueColor = `hsl(${hsva.h} 100% 50%)`;

  const set = (next: HSVA) => {
    setHsva(next);
    setDraft(null);
    const c = hsvToRgb(next);
    onChange?.({ hex: rgbToHex(c), rgba: c, css: formatColor(c, format) });
  };

  const sv = useDrag((x, y) => set({ ...hsva, s: x, v: 1 - y }));
  const hue = useDrag((x) => set({ ...hsva, h: x * 360 }));
  const alpha = useDrag((x) => set({ ...hsva, a: round(x, 2) }));

  const keyStep = (e: React.KeyboardEvent) => (e.shiftKey ? 10 : 1);
  const onSvKey = (e: React.KeyboardEvent) => {
    const d = keyStep(e) / 100;
    const m: Record<string, Partial<HSVA>> = {
      ArrowLeft: { s: clamp(hsva.s - d) },
      ArrowRight: { s: clamp(hsva.s + d) },
      ArrowUp: { v: clamp(hsva.v + d) },
      ArrowDown: { v: clamp(hsva.v - d) },
      Home: { s: 0 },
      End: { s: 1 },
      PageUp: { v: 1 },
      PageDown: { v: 0 },
    };
    if (!m[e.key]) return;
    e.preventDefault();
    set({ ...hsva, ...m[e.key] });
  };
  const sliderKey = (value: number, max: number, step: number, apply: (n: number) => void) => (e: React.KeyboardEvent) => {
    const d = step * (e.shiftKey ? 10 : 1);
    const m: Record<string, number> = {
      ArrowLeft: value - d,
      ArrowDown: value - d,
      ArrowRight: value + d,
      ArrowUp: value + d,
      Home: 0,
      End: max,
      PageUp: value + step * 10,
      PageDown: value - step * 10,
    };
    if (m[e.key] === undefined) return;
    e.preventDefault();
    apply(Math.min(max, Math.max(0, m[e.key])));
  };

  const commitText = (revertInvalid = false) => {
    if (draft === null) return;
    const parsed = parseColor(draft);
    if (!parsed) {
      if (revertInvalid) setDraft(null);
      else if (!reduce) void shake.start({ x: [0, -6, 6, -4, 4, 0], transition: { duration: 0.35 } });
      return;
    }
    const next = rgbToHsv(parsed);
    // keep hue when the colour is grey so the square doesn't jump
    set(next.s === 0 || next.v === 0 ? { ...next, h: hsva.h } : next);
  };

  const pickFromScreen = async () => {
    if (!EyeDropper) return;
    try {
      const res = await new EyeDropper().open();
      const p = parseColor(res.sRGBHex);
      if (p) set({ ...rgbToHsv(p), a: hsva.a });
    } catch {
      /* cancelled */
    }
  };

  const copy = async () => {
    try {
      await navigator.clipboard.writeText(css);
      setCopied(true);
      window.setTimeout(() => setCopied(false), 1400);
    } catch {
      /* clipboard blocked */
    }
  };

  const white = { r: 255, g: 255, b: 255, a: 1 };
  const black = { r: 0, g: 0, b: 0, a: 1 };
  const cw = contrast(rgba, white);
  const cb = contrast(rgba, black);
  const best = cw >= cb ? { ratio: cw, on: "white" } : { ratio: cb, on: "black" };
  const grade = best.ratio >= 7 ? "AAA" : best.ratio >= 4.5 ? "AA" : best.ratio >= 3 ? "AA Large" : "Fail";
  const initialRgb = hsvToRgb(initial);

  const thumb = (dragging: boolean) =>
    cn(
      "pointer-events-none absolute size-5 -translate-x-1/2 -translate-y-1/2 rounded-full border-[3px] border-white shadow-[0_0_0_1px_rgba(0,0,0,.25),0_2px_6px_rgba(0,0,0,.35)] transition-transform duration-150",
      dragging && "scale-125",
    );
  const track =
    "relative h-3.5 cursor-pointer touch-none rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card";

  return (
    <div
      role="group"
      aria-labelledby={`${uid}-label`}
      className={cn("w-full max-w-[600px] rounded-2xl border bg-card p-4 text-card-foreground shadow-sm sm:p-5", className)}
    >
      <div className="mb-4 flex items-center justify-between">
        <h3 id={`${uid}-label`} className="text-sm font-semibold">
          {label}
        </h3>
        <span className="font-mono text-xs text-muted-foreground">{hex}</span>
      </div>
      <div className="grid gap-5 sm:grid-cols-[minmax(0,1fr)_220px]">
        {/* left: square + sliders */}
        <div className="flex flex-col gap-3.5">
          <div
            ref={sv.ref}
            {...sv.handlers}
            role="slider"
            tabIndex={0}
            aria-label="Saturation and brightness"
            aria-valuemin={0}
            aria-valuemax={100}
            aria-valuenow={Math.round(hsva.s * 100)}
            aria-valuetext={`Saturation ${Math.round(hsva.s * 100)}%, brightness ${Math.round(hsva.v * 100)}%`}
            onKeyDown={onSvKey}
            className="relative aspect-[4/3] w-full cursor-crosshair touch-none rounded-xl outline-none ring-offset-2 ring-offset-card focus-visible:ring-2 focus-visible:ring-ring sm:aspect-auto sm:h-52"
            style={{ background: `linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, transparent), ${hueColor}` }}
          >
            <span className="pointer-events-none absolute inset-0 rounded-xl ring-1 ring-inset ring-black/10" />
            <span className={thumb(sv.dragging)} style={{ left: `${hsva.s * 100}%`, top: `${(1 - hsva.v) * 100}%`, background: opaque }} />
          </div>

          <div
            ref={hue.ref}
            {...hue.handlers}
            role="slider"
            tabIndex={0}
            aria-label="Hue"
            aria-valuemin={0}
            aria-valuemax={360}
            aria-valuenow={Math.round(hsva.h)}
            aria-valuetext={`${Math.round(hsva.h)} degrees`}
            onKeyDown={sliderKey(hsva.h, 360, 1, (h) => set({ ...hsva, h }))}
            className={track}
            style={{ background: "linear-gradient(to right, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00)" }}
          >
            <span className={cn(thumb(hue.dragging), "top-1/2")} style={{ left: `${(hsva.h / 360) * 100}%`, background: hueColor }} />
          </div>

          {showAlpha && (
            <div
              ref={alpha.ref}
              {...alpha.handlers}
              role="slider"
              tabIndex={0}
              aria-label="Opacity"
              aria-valuemin={0}
              aria-valuemax={100}
              aria-valuenow={Math.round(hsva.a * 100)}
              aria-valuetext={`${Math.round(hsva.a * 100)}%`}
              onKeyDown={sliderKey(Math.round(hsva.a * 100), 100, 1, (a) => set({ ...hsva, a: a / 100 }))}
              className={cn(track, "text-foreground")}
              style={{ background: CHECKER }}
            >
              <span className="pointer-events-none absolute inset-0 rounded-full" style={{ background: `linear-gradient(to right, transparent, ${opaque})` }} />
              <span className={cn(thumb(alpha.dragging), "top-1/2")} style={{ left: `${hsva.a * 100}%`, background: css }} />
            </div>
          )}
        </div>

        {/* right: preview, inputs, swatches */}
        <div className="flex min-w-0 flex-col gap-3.5">
          <div className="flex items-center gap-3">
            <div className="relative h-12 flex-1 overflow-hidden rounded-xl text-foreground" style={{ background: CHECKER }} aria-hidden>
              <div className="absolute inset-y-0 left-0 w-1/3" style={{ background: formatColor(initialRgb, "rgb") }} title="Original" />
              <motion.div
                className="absolute inset-y-0 left-1/3 right-0 grid place-items-center text-[11px] font-semibold"
                animate={{ backgroundColor: formatColor(rgba, "rgb") }}
                transition={{ duration: reduce ? 0 : 0.15 }}
                style={{ color: best.on }}
              >
                Aa
              </motion.div>
              <span className="pointer-events-none absolute inset-0 rounded-xl ring-1 ring-inset ring-black/10 dark:ring-white/10" />
            </div>
            {EyeDropper && (
              <button
                type="button"
                onClick={pickFromScreen}
                aria-label="Pick a colour from the screen"
                className="grid size-12 shrink-0 place-items-center rounded-xl border bg-background text-muted-foreground outline-none transition hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/40"
              >
                <Pipette className="size-4" />
              </button>
            )}
          </div>

          <div>
            <div role="radiogroup" aria-label="Format" className="mb-2 flex rounded-lg bg-muted p-0.5 text-xs">
              {(["hex", "rgb", "hsl"] as const).map((f) => (
                <button
                  key={f}
                  type="button"
                  role="radio"
                  aria-checked={format === f}
                  onClick={() => {
                    setFormat(f);
                    setDraft(null);
                  }}
                  className="relative flex-1 rounded-md py-1 font-medium uppercase text-muted-foreground outline-none transition focus-visible:ring-2 focus-visible:ring-ring/40 aria-checked:text-foreground"
                >
                  {format === f && (
                    <motion.span layoutId={`${uid}-fmt`} className="absolute inset-0 rounded-md bg-background shadow-sm" transition={{ type: "spring", stiffness: 500, damping: 36 }} />
                  )}
                  <span className="relative">{f}</span>
                </button>
              ))}
            </div>
            <motion.div animate={shake} className="relative">
              <label htmlFor={`${uid}-text`} className="sr-only">
                {format.toUpperCase()} value
              </label>
              <input
                id={`${uid}-text`}
                value={draft ?? css}
                onChange={(e) => setDraft(e.target.value)}
                onBlur={() => commitText(true)}
                onKeyDown={(e) => {
                  if (e.key === "Enter") commitText();
                  if (e.key === "Escape") setDraft(null);
                }}
                spellCheck={false}
                aria-invalid={draft !== null && !parseColor(draft)}
                className="h-9 w-full rounded-lg border bg-background pl-2.5 pr-9 font-mono text-[13px] outline-none transition focus:border-ring focus:ring-4 focus:ring-ring/15 aria-[invalid=true]:border-destructive aria-[invalid=true]:ring-destructive/15"
              />
              <button
                type="button"
                onClick={copy}
                aria-label={copied ? "Copied" : "Copy value"}
                className="absolute right-1 top-1 grid size-7 place-items-center rounded-md text-muted-foreground outline-none transition hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/40"
              >
                <AnimatePresence mode="wait" initial={false}>
                  <motion.span key={copied ? "y" : "n"} initial={{ scale: 0.5, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} exit={{ scale: 0.5, opacity: 0 }} transition={{ duration: 0.12 }}>
                    {copied ? <Check className="size-3.5 text-emerald-500" /> : <Copy className="size-3.5" />}
                  </motion.span>
                </AnimatePresence>
              </button>
            </motion.div>
          </div>

          <div>
            <p className="mb-2 text-xs font-medium text-muted-foreground">Swatches</p>
            <div className="grid grid-cols-6 gap-2">
              {swatches.map((sw) => {
                const p = parseColor(sw);
                if (!p) return null;
                const on = rgbToHex(p) === hex;
                return (
                  <motion.button
                    key={sw}
                    type="button"
                    whileHover={reduce ? undefined : { scale: 1.12 }}
                    whileTap={reduce ? undefined : { scale: 0.92 }}
                    aria-label={`Use ${rgbToHex(p)}`}
                    aria-pressed={on}
                    onClick={() => set(rgbToHsv(p))}
                    className="relative aspect-square rounded-lg text-foreground outline-none ring-offset-2 ring-offset-card focus-visible:ring-2 focus-visible:ring-ring"
                    style={{ background: CHECKER }}
                  >
                    <span className="absolute inset-0 rounded-lg ring-1 ring-inset ring-black/10 dark:ring-white/10" style={{ background: formatColor(p, "rgb") }} />
                    {on && (
                      <motion.span layoutId={`${uid}-sw`} className="absolute -inset-1 rounded-[10px] border-2 border-foreground" transition={{ type: "spring", stiffness: 500, damping: 34 }} />
                    )}
                  </motion.button>
                );
              })}
            </div>
          </div>

          <div className="flex items-center justify-between rounded-lg border bg-muted/40 px-2.5 py-2 text-xs">
            <span className="text-muted-foreground">
              Contrast on {best.on} <span className="font-medium tabular-nums text-foreground">{best.ratio.toFixed(2)}:1</span>
            </span>
            <span
              className={cn(
                "rounded-md px-1.5 py-0.5 font-semibold",
                grade === "Fail" ? "bg-destructive/15 text-destructive" : "bg-emerald-500/15 text-emerald-700 dark:text-emerald-400",
              )}
            >
              {grade}
            </span>
          </div>
        </div>
      </div>
    </div>
  );
}

More in Forms & Inputs

View all →