Skip to content

Color Picker

Swatches, a hue slider and a hex input.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/color-picker.json

Manual

  1. Install the dependencies

    npm install clsx lucide-react motion tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts and the motion tokens below. See Installation if you have not set them up yet.

    lib/motion.ts
    import type { Transition, Variants } from "motion/react";
    
    /** Durations in seconds. */
    export const duration = {
      instant: 0.1,
      quick: 0.18,
      base: 0.28,
      slow: 0.45,
    } as const;
    
    /** Cubic-bezier curves. */
    export const ease = {
      /** Entering, UI response. */
      out: [0.22, 1, 0.36, 1],
      /** Moving on screen. */
      inOut: [0.65, 0, 0.35, 1],
      /** Sheets and the mobile menu. */
      drawer: [0.32, 0.72, 0, 1],
    } as const;
    
    /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */
    export const ui = {
      press: { scale: 0.96, duration: 0.12 },
      icon: { type: "spring", duration: 0.3, bounce: 0 },
      popover: { duration: 0.18, ease: ease.out },
      modal: { duration: 0.24, ease: ease.out },
      exitFactor: 0.6,
    } as const;
    
    /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */
    export const kinetic = {
      heroLetter: { duration: 0.6, ease: ease.out },
      /** Delay per letter, eased so the wave slows toward the end. */
      heroStep: 0.035,
      reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 },
      /** Two afterimages: lag in seconds and opacity of each. */
      ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] },
    } as const;
    
    /** Spring presets. */
    export const spring = {
      snappy: { type: "spring", stiffness: 500, damping: 30 },
      smooth: { type: "spring", stiffness: 260, damping: 26 },
      bouncy: { type: "spring", stiffness: 300, damping: 15 },
    } as const satisfies Record<string, Transition>;
    
    /** Stagger between siblings, in seconds (0.03–0.06). */
    export const stagger = {
      tight: 0.03,
      base: 0.045,
      loose: 0.06,
    } as const;
    
    /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */
    export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.base } },
          exit: { opacity: 0, transition: { duration: duration.quick } },
        };
      }
      return {
        hidden: { opacity: 0, y, filter: "blur(4px)" },
        visible: {
          opacity: 1,
          y: 0,
          filter: "blur(0px)",
          transition: { duration: duration.base, ease: ease.out },
        },
        exit: {
          opacity: 0,
          y: -y / 2,
          filter: "blur(4px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
      };
    }
    
    /** Scale 0.96 → 1 for popovers, menus and dialogs. */
    export function overlayVariants(reduceMotion?: boolean | null): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.quick } },
          exit: { opacity: 0, transition: { duration: duration.instant } },
        };
      }
      return {
        hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" },
        visible: {
          opacity: 1,
          scale: 1,
          filter: "blur(0px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
        exit: {
          opacity: 0,
          scale: 0.96,
          filter: "blur(2px)",
          transition: { duration: duration.instant + 0.02, ease: ease.out },
        },
      };
    }
    
    /** Parent variants that stagger their children in. */
    export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants {
      return {
        hidden: {},
        visible: { transition: { staggerChildren: gap, delayChildren } },
        exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } },
      };
    }
  3. Copy the source

    components/ui/color-picker/color-picker.tsx
    "use client";
    
    import {
      useEffect,
      useId,
      useRef,
      useState,
      useSyncExternalStore,
      type ComponentProps,
      type KeyboardEvent,
      type PointerEvent,
    } from "react";
    import { LayoutGroup, motion, useReducedMotion, useSpring, useTransform } from "motion/react";
    import { Pipette } from "lucide-react";
    import { cn } from "@/lib/utils";
    import { spring } from "@/lib/motion";
    
    export interface ColorPickerProps
      extends Omit<ComponentProps<"div">, "defaultValue" | "onChange" | "onAnimationStart" | "onDrag" | "onDragStart" | "onDragEnd"> {
      /** Controlled color as a hex string, e.g. "#2563eb". */
      value?: string;
      /** Initial color when uncontrolled. @default "#2563eb" */
      defaultValue?: string;
      /** Called with a lowercase six-digit hex string. */
      onValueChange?: (hex: string) => void;
      /** Preset colors. @default a 12-color palette */
      swatches?: string[];
      /** Show the hue slider. @default true */
      showHue?: boolean;
      /** Show the hex field (and an eyedropper where the browser supports it). @default true */
      showInput?: boolean;
      /** Accessible name for the swatch group. @default "Preset colors" */
      label?: string;
    }
    
    const DEFAULT_SWATCHES = [
      "#ef4444", "#f97316", "#f59e0b", "#84cc16", "#22c55e", "#14b8a6",
      "#06b6d4", "#3b82f6", "#2563eb", "#8b5cf6", "#d946ef", "#09090b",
    ];
    
    // --- color math -------------------------------------------------------------
    
    export function normalizeHex(input: string): string | null {
      const hex = input.trim().replace(/^#/, "").toLowerCase();
      if (/^[0-9a-f]{3}$/.test(hex)) return `#${[...hex].map((c) => c + c).join("")}`;
      if (/^[0-9a-f]{6}$/.test(hex)) return `#${hex}`;
      return null;
    }
    
    function hexToHsl(hex: string) {
      const n = parseInt(hex.slice(1), 16);
      const [r, g, b] = [(n >> 16) & 255, (n >> 8) & 255, n & 255].map((v) => v / 255);
      const maxC = Math.max(r, g, b);
      const minC = Math.min(r, g, b);
      const l = (maxC + minC) / 2;
      const d = maxC - minC;
      if (d === 0) return { h: 0, s: 0, l };
      const s = d / (1 - Math.abs(2 * l - 1));
      const h = maxC === r ? ((g - b) / d) % 6 : maxC === g ? (b - r) / d + 2 : (r - g) / d + 4;
      return { h: Math.round(h * 60 + 360) % 360, s, l };
    }
    
    function hslToHex(h: number, s: number, l: number) {
      const k = (n: number) => (n + h / 30) % 12;
      const a = s * Math.min(l, 1 - l);
      const f = (n: number) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
      return `#${[f(0), f(8), f(4)].map((v) => Math.round(v * 255).toString(16).padStart(2, "0")).join("")}`;
    }
    
    const hasEyeDropper = () => "EyeDropper" in window;
    
    // --- component --------------------------------------------------------------
    
    export function ColorPicker({
      value,
      defaultValue = "#2563eb",
      onValueChange,
      swatches = DEFAULT_SWATCHES,
      showHue = true,
      showInput = true,
      label = "Preset colors",
      className,
      ...props
    }: ColorPickerProps) {
      const [internal, setInternal] = useState(() => normalizeHex(defaultValue) ?? "#2563eb");
      const color = normalizeHex(value ?? "") ?? internal;
      const hsl = hexToHsl(color);
      // Greys have no hue of their own, so the slider remembers the last one.
      const [lastHue, setLastHue] = useState(hsl.h);
      const hue = hsl.s >= 0.08 ? hsl.h : lastHue;
    
      const [draft, setDraft] = useState(color.slice(1));
      const [syncedColor, setSyncedColor] = useState(color);
      if (syncedColor !== color) {
        setSyncedColor(color);
        setDraft(color.slice(1));
      }
    
      const normalizedSwatches = swatches.map((s) => normalizeHex(s) ?? s);
      const reduceMotion = useReducedMotion();
      const eyeDropper = useSyncExternalStore(() => () => {}, hasEyeDropper, () => false);
      const groupId = useId();
      const swatchRefs = useRef<(HTMLButtonElement | null)[]>([]);
      const trackRef = useRef<HTMLDivElement>(null);
      const dragging = useRef(false);
    
      // Hue thumb: springs to a new hue from a swatch, follows the pointer exactly while dragging.
      const huePos = useSpring(hue / 3.6, { stiffness: 500, damping: 38 });
      const hueX = useTransform(huePos, (v) => `${v}%`);
      useEffect(() => {
        if (dragging.current || reduceMotion) huePos.jump(hue / 3.6);
        else huePos.set(hue / 3.6);
      }, [hue, reduceMotion, huePos]);
    
      function setColor(next: string) {
        const hex = normalizeHex(next);
        if (!hex || hex === color) return;
        const { h, s } = hexToHsl(hex);
        if (s >= 0.08) setLastHue(h);
        if (value === undefined) setInternal(hex);
        onValueChange?.(hex);
      }
    
      function setHue(nextHue: number) {
        const h = ((Math.round(nextHue) % 360) + 360) % 360;
        setLastHue(h);
        // Give greys and near-white/black some color so moving the hue is visible.
        const s = hsl.s < 0.08 ? 0.72 : hsl.s;
        const l = hsl.l < 0.12 || hsl.l > 0.9 ? 0.52 : hsl.l;
        setColor(hslToHex(h, s, l));
      }
    
      function hueFromPointer(event: PointerEvent<HTMLDivElement>) {
        const rect = trackRef.current!.getBoundingClientRect();
        return Math.min(Math.max((event.clientX - rect.left) / rect.width, 0), 1) * 359;
      }
    
      function onHueKey(event: KeyboardEvent<HTMLDivElement>) {
        const stepSize = event.shiftKey ? 10 : 1;
        const targets: Record<string, number> = {
          ArrowRight: hue + stepSize,
          ArrowUp: hue + stepSize,
          ArrowLeft: hue - stepSize,
          ArrowDown: hue - stepSize,
          PageUp: hue + 30,
          PageDown: hue - 30,
          Home: 0,
          End: 359,
        };
        if (!(event.key in targets)) return;
        event.preventDefault();
        setHue(Math.min(Math.max(targets[event.key], 0), 359));
      }
    
      const selectedIndex = normalizedSwatches.indexOf(color);
    
      function onSwatchKey(event: KeyboardEvent<HTMLDivElement>) {
        const current = Math.max(selectedIndex, 0);
        const columns = 6;
        const moves: Record<string, number> = {
          ArrowRight: current + 1,
          ArrowLeft: current - 1,
          ArrowDown: current + columns,
          ArrowUp: current - columns,
          Home: 0,
          End: normalizedSwatches.length - 1,
        };
        if (!(event.key in moves)) return;
        event.preventDefault();
        const next = (moves[event.key] + normalizedSwatches.length) % normalizedSwatches.length;
        setColor(normalizedSwatches[next]);
        swatchRefs.current[next]?.focus();
      }
    
      const draftValid = normalizeHex(draft) !== null;
    
      return (
        <div className={cn("w-full max-w-[17.5rem] space-y-4 rounded-xl border border-border bg-background p-4 shadow-md", className)} {...props}>
          <LayoutGroup id={groupId}>
            <div role="radiogroup" aria-label={label} onKeyDown={onSwatchKey} className="grid grid-cols-6 gap-2">
              {normalizedSwatches.map((swatch, index) => {
                const selected = index === selectedIndex;
                return (
                  <button
                    key={`${swatch}-${index}`}
                    ref={(node) => {
                      swatchRefs.current[index] = node;
                    }}
                    type="button"
                    role="radio"
                    aria-checked={selected}
                    aria-label={swatch}
                    tabIndex={selected || (selectedIndex === -1 && index === 0) ? 0 : -1}
                    onClick={() => setColor(swatch)}
                    className="relative aspect-square cursor-pointer rounded-full outline-none transition-transform duration-150 hover:scale-110 active:scale-95 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
                  >
                    {selected ? (
                      <motion.span
                        layoutId="swatch-ring"
                        aria-hidden="true"
                        className="absolute -inset-[3px] rounded-full border-2 border-foreground"
                        transition={reduceMotion ? { duration: 0 } : spring.snappy}
                      />
                    ) : null}
                    <span
                      aria-hidden="true"
                      className="absolute inset-0 rounded-full shadow-[inset_0_0_0_1px_rgb(0_0_0/0.1)]"
                      style={{ backgroundColor: swatch }}
                    />
                  </button>
                );
              })}
            </div>
          </LayoutGroup>
    
          {showHue ? (
            <div
              ref={trackRef}
              onPointerDown={(event) => {
                if (event.button !== 0) return;
                event.preventDefault();
                dragging.current = true;
                event.currentTarget.setPointerCapture(event.pointerId);
                event.currentTarget.querySelector<HTMLElement>("[role=slider]")?.focus({ preventScroll: true });
                setHue(hueFromPointer(event));
              }}
              onPointerMove={(event) => {
                if (dragging.current) setHue(hueFromPointer(event));
              }}
              onPointerUp={() => {
                dragging.current = false;
              }}
              onPointerCancel={() => {
                dragging.current = false;
              }}
              className="relative h-6 cursor-pointer touch-none"
            >
              <div
                aria-hidden="true"
                className="absolute inset-x-0 top-1/2 h-3 -translate-y-1/2 rounded-full shadow-[inset_0_0_0_1px_rgb(0_0_0/0.08)]"
                style={{
                  background: "linear-gradient(to right,#f00,#ff0 17%,#0f0 33%,#0ff 50%,#00f 67%,#f0f 83%,#f00)",
                }}
              />
              {/* Inset by the thumb radius so the thumb never leaves the track. */}
              <div className="pointer-events-none absolute inset-y-0 right-2.5 left-2.5">
                <motion.div className="absolute inset-y-0 left-0 w-full" style={{ x: hueX }}>
                  <div
                    role="slider"
                    tabIndex={0}
                    aria-label="Hue"
                    aria-valuemin={0}
                    aria-valuemax={359}
                    aria-valuenow={hue}
                    aria-valuetext={`${hue} degrees`}
                    onKeyDown={onHueKey}
                    className="pointer-events-auto absolute top-1/2 left-0 size-5 -translate-x-1/2 -translate-y-1/2 rounded-full border-[3px] border-white shadow-[0_0_0_1px_rgb(0_0_0/0.12),0_2px_6px_rgb(0_0_0/0.2)] outline-none focus-visible:ring-4 focus-visible:ring-ring/35"
                    style={{ backgroundColor: hslToHex(hue, 1, 0.5) }}
                  />
                </motion.div>
              </div>
            </div>
          ) : null}
    
          {showInput ? (
            <div className="flex items-center gap-2">
              <span
                aria-hidden="true"
                className="size-9 shrink-0 rounded-md shadow-[inset_0_0_0_1px_rgb(0_0_0/0.1)]"
                style={{ backgroundColor: color }}
              />
              <label
                className={cn(
                  "flex h-9 min-w-0 flex-1 items-center rounded-md border bg-background pl-2.5 font-mono text-sm shadow-xs transition-[border-color,box-shadow]",
                  "focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/20",
                  draftValid ? "border-input" : "border-destructive focus-within:border-destructive focus-within:ring-destructive/20",
                )}
              >
                <span className="text-muted-foreground" aria-hidden="true">
                  #
                </span>
                <span className="sr-only">Hex color</span>
                <input
                  value={draft}
                  maxLength={7}
                  spellCheck={false}
                  autoComplete="off"
                  aria-invalid={!draftValid || undefined}
                  onChange={(event) => {
                    const next = event.target.value.replace(/^#/, "").replace(/[^0-9a-f]/gi, "").slice(0, 6);
                    setDraft(next);
                    // Only full six-digit values apply while typing; "fff" would hijack "ff0066" mid-way.
                    if (next.length === 6) setColor(next);
                  }}
                  onKeyDown={(event) => {
                    if (event.key === "Enter" && draft.length === 3) setColor(draft);
                  }}
                  onBlur={() => {
                    const shorthand = draft.length === 3 ? normalizeHex(draft) : null;
                    if (shorthand) setColor(shorthand);
                    setDraft((shorthand ?? color).slice(1));
                  }}
                  className="h-full w-full min-w-0 bg-transparent px-1 text-foreground uppercase outline-none"
                />
              </label>
              {eyeDropper ? (
                <button
                  type="button"
                  aria-label="Pick a color from the screen"
                  onClick={async () => {
                    try {
                      const Dropper = (window as unknown as { EyeDropper: new () => { open(): Promise<{ sRGBHex: string }> } }).EyeDropper;
                      const result = await new Dropper().open();
                      setColor(result.sRGBHex);
                    } catch {
                      // The user cancelled.
                    }
                  }}
                  className="inline-flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-md border border-input bg-background text-muted-foreground shadow-xs outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
                >
                  <Pipette className="size-4" aria-hidden="true" />
                </button>
              ) : null}
            </div>
          ) : null}
        </div>
      );
    }

Usage

import { ColorPicker } from "@/components/ui/color-picker/color-picker";

export function AccentSetting() {
  const [accent, setAccent] = useState("#2563eb");
  return <ColorPicker value={accent} onValueChange={setAccent} />;
}

API reference

PropTypeDefaultDescription
valuestring–Controlled color as a hex string, e.g. "#2563eb".
defaultValuestring"#2563eb"Initial color when uncontrolled.
onValueChange(hex: string) => void–Called with a lowercase six-digit hex string.
swatchesstring[]a 12-color palettePreset colors.
showHuebooleantrueShow the hue slider.
showInputbooleantrueShow the hex field (and an eyedropper where the browser supports it).
labelstring"Preset colors"Accessible name for the swatch group.

Also accepts every native <div> attribute, including ref and className.

  • Values are normalized to lowercase six-digit hex, and #abc is accepted in the field.
  • Moving the hue keeps the current saturation and lightness; greys get a usable saturation so the change is visible.

Accessibility

  • Swatches form a radiogroup with one tab stop. Arrow keys move through the grid (six per row), and each swatch is labelled with its hex value.
  • The hue control is a slider with aria-valuetext in degrees; arrows step by 1 (Shift by 10), PageUp and PageDown by 30.
  • The hex field is labelled "Hex color" and marks partial or invalid input with aria-invalid; it resets to the current color on blur.
  • The eyedropper button only appears in browsers that support the EyeDropper API.
  • With reduced motion enabled, the selection ring and hue thumb jump instead of gliding.