Color Picker

A composable color picker component with modular parts for building custom color selection interfaces.

Dependencies

Source Code

"use client";
 
import chroma from "chroma-js";
import { createContext, use, useMemo, useRef } from "react";
 
import { composeRefs } from "@/lib/compose-refs";
import { cn } from "@/lib/utils";
 
type HSVA = [number, number, number, number];
 
interface ColorPickerContextType {
  color: HSVA;
  onChange?: (color: HSVA) => void;
  disabled?: boolean;
}
 
const ColorPickerContext = createContext<ColorPickerContextType | null>(null);
 
const useColorPickerContext = () => {
  const context = use(ColorPickerContext);
 
  if (!context) {
    throw new Error("ColorPicker components must be used within a ColorPicker");
  }
 
  return context;
};
 
interface ColorPickerProps
  extends Omit<React.ComponentPropsWithRef<"div">, "color" | "onChange"> {
  color?: HSVA;
  onColorChange?: (color: HSVA) => void;
  disabled?: boolean;
}
 
const HANDLE_SIZE_RADIUS = 10;
 
const ColorPicker = ({
  children,
  color = [0, 1, 1, 1],
  onColorChange,
  disabled,
  className,
  ref,
  ...props
}: ColorPickerProps) => {
  const contextValue = useMemo(
    () => ({
      color,
      onChange: onColorChange,
      disabled,
    }),
    [color, onColorChange, disabled]
  );
 
  return (
    <ColorPickerContext value={contextValue}>
      <div
        ref={ref}
        className={cn(disabled && "pointer-events-none opacity-48", className)}
        {...props}
      >
        {children}
      </div>
    </ColorPickerContext>
  );
};
 
interface DraggableProps extends React.ComponentPropsWithRef<"div"> {
  onMove: (coords: [number, number]) => void;
  keyMap?: Partial<
    Record<"up" | "down" | "left" | "right", (modifierKey: boolean) => void>
  >;
  disabled?: boolean;
  "aria-label"?: string;
  "aria-valuetext"?: string;
  "aria-valuenow"?: number;
  "aria-valuemin"?: number;
  "aria-valuemax"?: number;
}
 
const Draggable = ({
  disabled,
  className,
  onMove,
  keyMap = {},
  children,
  "aria-label": ariaLabel,
  "aria-valuetext": ariaValueText,
  "aria-valuenow": ariaValueNow,
  "aria-valuemin": ariaValueMin = 0,
  "aria-valuemax": ariaValueMax = 100,
  ref,
  ...props
}: DraggableProps) => {
  const internalRef = useRef<HTMLDivElement>(null);
  const isSelecting = useRef(false);
 
  const handleStart = (
    e:
      | MouseEvent
      | TouchEvent
      | React.MouseEvent<HTMLDivElement>
      | React.TouchEvent<HTMLDivElement>
  ) => {
    if (disabled || !internalRef.current) return;
 
    const el = internalRef.current;
    if (!el.contains(e.target as Node)) return;
 
    if (e.cancelable) e.preventDefault();
 
    isSelecting.current = true;
 
    window.addEventListener("mousemove", handleMove, { passive: false });
    window.addEventListener("touchmove", handleMove, { passive: false });
    window.addEventListener("mouseup", handleStop);
    window.addEventListener("touchend", handleStop);
 
    onMove(getCoordinatesFromEvent(e));
  };
 
  const handleStop = () => {
    isSelecting.current = false;
 
    window.removeEventListener("mousemove", handleMove);
    window.removeEventListener("touchmove", handleMove);
    window.removeEventListener("mouseup", handleStop);
    window.removeEventListener("touchend", handleStop);
  };
 
  const handleMove = (e: MouseEvent | TouchEvent) => {
    if (!isSelecting.current) return;
 
    if (e.cancelable) e.preventDefault();
 
    onMove(getCoordinatesFromEvent(e));
  };
 
  const getCoordinatesFromEvent = (
    e:
      | MouseEvent
      | TouchEvent
      | React.MouseEvent<HTMLDivElement>
      | React.TouchEvent<HTMLDivElement>
  ) => {
    const rect = internalRef.current?.getBoundingClientRect();
 
    if (!rect) return [0, 0] as [number, number];
 
    const clientX = "touches" in e ? (e.touches[0]?.clientX ?? 0) : e.clientX;
    const clientY = "touches" in e ? (e.touches[0]?.clientY ?? 0) : e.clientY;
 
    const x = clientX - rect.left;
    const y = clientY - rect.top;
 
    return [
      Math.min(Math.max(x, 0), rect.width),
      Math.min(Math.max(y, 0), rect.height),
    ] as [number, number];
  };
 
  const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
    if (disabled) return;
 
    const key = e.key.toLowerCase();
    const modifierKey = e.shiftKey || e.metaKey;
 
    if (key === "arrowup" && keyMap?.up) {
      e.preventDefault();
      keyMap.up(modifierKey);
    } else if (key === "arrowdown" && keyMap?.down) {
      e.preventDefault();
      keyMap.down(modifierKey);
    } else if (key === "arrowleft" && keyMap?.left) {
      e.preventDefault();
      keyMap.left(modifierKey);
    } else if (key === "arrowright" && keyMap?.right) {
      e.preventDefault();
      keyMap.right(modifierKey);
    }
  };
 
  return (
    <div
      ref={composeRefs(internalRef, ref)}
      onMouseDown={handleStart}
      onTouchStart={handleStart}
      onKeyDown={handleKeyDown}
      className={cn(
        "group relative cursor-pointer touch-none rounded-xl",
        "focus-visible:ring-accent-element focus-visible:ring-4",
        className
      )}
      tabIndex={disabled ? -1 : 0}
      role="slider"
      aria-label={ariaLabel}
      aria-valuetext={ariaValueText}
      aria-valuenow={ariaValueNow}
      aria-valuemin={ariaValueMin}
      aria-valuemax={ariaValueMax}
      aria-disabled={disabled}
      {...props}
    >
      {children}
    </div>
  );
};
 
interface ColorPickerHandleProps {
  color: HSVA;
  left: number;
  top?: number;
}
 
const ColorPickerHandle = ({
  color,
  left,
  top = 0.5,
}: ColorPickerHandleProps) => {
  return (
    <div
      className={cn(
        "absolute h-[var(--size)] w-[var(--size)] -translate-x-1/2 -translate-y-1/2 rounded-full border-[3px] border-white bg-[var(--color)] shadow-lg",
        "transition-transform group-focus-visible:scale-110 group-focus-visible:outline-4 group-focus-visible:outline-[white]/40"
      )}
      style={{
        "--size": `${HANDLE_SIZE_RADIUS * 2}px`,
        "--color": chroma.hsv(color[0], color[1], color[2]).css(),
        left: `${left * 100}%`,
        top: `${top * 100}%`,
      }}
    />
  );
};
 
const ColorPickerArea = ({
  className,
  ref,
  ...props
}: React.ComponentPropsWithRef<"div">) => {
  const {
    color: [h, s, v, a],
    onChange,
    disabled,
  } = useColorPickerContext();
 
  const internalRef = useRef<HTMLDivElement>(null);
 
  const onMove = ([x, y]: [number, number]) => {
    const rect = internalRef.current?.getBoundingClientRect();
    if (!rect) return;
 
    const normalizedX = x / rect.width;
    const normalizedY = 1 - y / rect.height;
    onChange?.([h, normalizedX, normalizedY, a]);
  };
 
  const keyMap = {
    up: (modifierKey: boolean) =>
      onChange?.([h, s, Math.min(1, v + (modifierKey ? 0.1 : 0.02)), a]),
    down: (modifierKey: boolean) =>
      onChange?.([h, s, Math.max(0, v - (modifierKey ? 0.1 : 0.02)), a]),
    left: (modifierKey: boolean) =>
      onChange?.([h, Math.max(0, s - (modifierKey ? 0.1 : 0.02)), v, a]),
    right: (modifierKey: boolean) =>
      onChange?.([h, Math.min(1, s + (modifierKey ? 0.1 : 0.02)), v, a]),
  };
 
  const ariaValueText = `Saturation ${Math.round(s * 100)}%, Value ${Math.round(v * 100)}%`;
 
  return (
    <Draggable
      ref={composeRefs(internalRef, ref)}
      onMove={onMove}
      keyMap={keyMap}
      disabled={disabled}
      aria-label="Saturation and value"
      aria-valuetext={ariaValueText}
      aria-valuenow={Math.round(((s + v) / 2) * 100)}
      aria-valuemin={0}
      aria-valuemax={100}
      className={cn("aspect-square w-full", className)}
      style={{
        backgroundColor: chroma(h, 1, 1, "hsv").css(),
        backgroundImage: `linear-gradient(transparent, black),
        linear-gradient(to right, white, transparent)`,
      }}
      {...props}
    >
      <ColorPickerHandle color={[h, s, v, a]} top={1 - v} left={s} />
    </Draggable>
  );
};
 
const ColorPickerHue = ({
  className,
  ref,
  ...props
}: React.ComponentPropsWithRef<"div">) => {
  const {
    color: [h, s, v, a],
    onChange,
    disabled,
  } = useColorPickerContext();
 
  const internalRef = useRef<HTMLDivElement>(null);
 
  const onMove = ([x]: [number, number]) => {
    const rect = internalRef.current?.getBoundingClientRect();
    if (!rect) return;
 
    const normalizedX = x / rect.width;
    const newH = normalizedX * 360;
    onChange?.([newH, s, v, a]);
  };
 
  const keyMap = {
    left: (modifierKey: boolean) => {
      const newH = Math.max(0, h - (modifierKey ? 10 : 2));
      onChange?.([newH, s, v, a]);
    },
    right: (modifierKey: boolean) => {
      const newH = Math.min(360, h + (modifierKey ? 10 : 2));
      onChange?.([newH, s, v, a]);
    },
  };
 
  const ariaValueText = `Hue ${Math.round(h)}°`;
 
  return (
    <Draggable
      ref={composeRefs(internalRef, ref)}
      onMove={onMove}
      keyMap={keyMap}
      disabled={disabled}
      aria-label="Hue"
      aria-valuetext={ariaValueText}
      aria-valuenow={Math.round(h)}
      aria-valuemin={0}
      aria-valuemax={360}
      className={cn("h-5 w-full", className)}
      style={{
        background:
          "linear-gradient(90deg, hsl(0, 100%, 50%), hsl(30, 100%, 50%), hsl(60, 100%, 50%), hsl(90, 100%, 50%), hsl(120, 100%, 50%), hsl(150, 100%, 50%), hsl(180, 100%, 50%), hsl(210, 100%, 50%), hsl(240, 100%, 50%), hsl(270, 100%, 50%),hsl(300, 100%, 50%), hsl(330, 100%, 50%), hsl(360, 100%, 50%))",
      }}
      {...props}
    >
      <ColorPickerHandle color={[h, 1, 1, 1]} left={h / 360} />
    </Draggable>
  );
};
 
const ColorPickerSaturation = ({
  className,
  ref,
  ...props
}: React.ComponentPropsWithRef<"div">) => {
  const {
    color: [h, s, v, a],
    onChange,
    disabled,
  } = useColorPickerContext();
 
  const internalRef = useRef<HTMLDivElement>(null);
 
  const onMove = ([x]: [number, number]) => {
    const rect = internalRef.current?.getBoundingClientRect();
    if (!rect) return;
 
    const normalizedX = x / rect.width;
    onChange?.([h, normalizedX, v, a]);
  };
 
  const keyMap = {
    left: (modifierKey: boolean) => {
      const newS = Math.max(0, s - (modifierKey ? 0.1 : 0.02));
      onChange?.([h, newS, v, a]);
    },
    right: (modifierKey: boolean) => {
      const newS = Math.min(1, s + (modifierKey ? 0.1 : 0.02));
      onChange?.([h, newS, v, a]);
    },
  };
 
  const ariaValueText = `Saturation ${Math.round(s * 100)}%`;
 
  return (
    <Draggable
      ref={composeRefs(internalRef, ref)}
      onMove={onMove}
      keyMap={keyMap}
      disabled={disabled}
      aria-label="Saturation"
      aria-valuetext={ariaValueText}
      aria-valuenow={Math.round(s * 100)}
      aria-valuemin={0}
      aria-valuemax={100}
      className={cn("h-5 w-full", className)}
      style={{
        background: `linear-gradient(90deg, ${chroma.hsv(h, 0, v).css()}, ${chroma.hsv(h, 1, v).css()})`,
      }}
      {...props}
    >
      <ColorPickerHandle color={[h, s, v, a]} left={s} />
    </Draggable>
  );
};
 
const ColorPickerLightness = ({
  className,
  ref,
  ...props
}: React.ComponentPropsWithRef<"div">) => {
  const {
    color: [h, s, v, a],
    onChange,
    disabled,
  } = useColorPickerContext();
 
  const internalRef = useRef<HTMLDivElement>(null);
 
  const onMove = ([x]: [number, number]) => {
    const rect = internalRef.current?.getBoundingClientRect();
    if (!rect) return;
 
    const normalizedX = x / rect.width;
    onChange?.([h, s, normalizedX, a]);
  };
 
  const keyMap = {
    left: (modifierKey: boolean) => {
      const newV = Math.max(0, v - (modifierKey ? 0.1 : 0.02));
      onChange?.([h, s, newV, a]);
    },
    right: (modifierKey: boolean) => {
      const newV = Math.min(1, v + (modifierKey ? 0.1 : 0.02));
      onChange?.([h, s, newV, a]);
    },
  };
 
  const ariaValueText = `Lightness ${Math.round(v * 100)}%`;
 
  return (
    <Draggable
      ref={composeRefs(internalRef, ref)}
      onMove={onMove}
      keyMap={keyMap}
      disabled={disabled}
      aria-label="Lightness"
      aria-valuetext={ariaValueText}
      aria-valuenow={Math.round(v * 100)}
      aria-valuemin={0}
      aria-valuemax={100}
      className={cn("h-5 w-full", className)}
      style={{
        background: `linear-gradient(90deg, ${chroma.hsv(h, s, 0).css()}, ${chroma.hsv(h, s, 1).css()})`,
      }}
      {...props}
    >
      <ColorPickerHandle color={[h, s, v, a]} left={v} />
    </Draggable>
  );
};
 
const ColorPickerAlpha = ({
  className,
  ref,
  ...props
}: React.ComponentPropsWithRef<"div">) => {
  const {
    color: [h, s, v, a],
    onChange,
    disabled,
  } = useColorPickerContext();
 
  const internalRef = useRef<HTMLDivElement>(null);
 
  const onMove = ([x]: [number, number]) => {
    const rect = internalRef.current?.getBoundingClientRect();
    if (!rect) return;
 
    const normalizedX = x / rect.width;
    onChange?.([h, s, v, normalizedX]);
  };
 
  const keyMap = {
    left: (modifierKey: boolean) => {
      const newAlpha = Math.max(0, a - (modifierKey ? 0.1 : 0.02));
      onChange?.([h, s, v, newAlpha]);
    },
    right: (modifierKey: boolean) => {
      const newAlpha = Math.min(1, a + (modifierKey ? 0.1 : 0.02));
      onChange?.([h, s, v, newAlpha]);
    },
  };
 
  const ariaValueText = `Alpha ${Math.round(a * 100)}%`;
  const baseColor = chroma.hsv(h, s, v).css();
 
  return (
    <Draggable
      ref={composeRefs(internalRef, ref)}
      onMove={onMove}
      keyMap={keyMap}
      disabled={disabled}
      aria-label="Alpha"
      aria-valuetext={ariaValueText}
      aria-valuenow={Math.round(a * 100)}
      aria-valuemin={0}
      aria-valuemax={100}
      className={cn("relative h-5 w-full", className)}
      style={{
        backgroundImage: `
          linear-gradient(45deg, rgba(0,0,0,0.05) 25%, transparent 25%), 
          linear-gradient(-45deg, rgba(0,0,0,0.05) 25%, transparent 25%), 
          linear-gradient(45deg, transparent 75%, rgba(0,0,0,0.05) 75%), 
          linear-gradient(-45deg, transparent 75%, rgba(0,0,0,0.05) 75%),
          linear-gradient(90deg, transparent, ${baseColor})
        `,
        backgroundSize: "8px 8px, 8px 8px, 8px 8px, 8px 8px, 100% 100%",
        backgroundPosition: "0 0, 0 4px, 4px -4px, -4px 0px, 0 0",
      }}
      {...props}
    >
      <ColorPickerHandle color={[h, s, v, a]} left={a} />
    </Draggable>
  );
};
 
export {
  ColorPicker,
  ColorPickerAlpha,
  ColorPickerArea,
  ColorPickerHue,
  ColorPickerLightness,
  ColorPickerSaturation,
  type HSVA,
};

Features

  • Composable Architecture: Mix and match different color picker parts
  • Real-time Updates: Immediate color feedback as you interact with the picker
  • Keyboard Navigation: Full keyboard support with arrow keys for precise adjustments
  • Accessibility: ARIA-compliant with proper screen reader support
  • Touch Support: Works seamlessly on touch devices

Anatomy

<ColorPicker>
  <ColorPickerArea />
  <ColorPickerHue />
  <ColorPickerSaturation />
  <ColorPickerLightness />
  <ColorPickerAlpha />
</ColorPicker>

API Reference

ColorPicker

The root component that provides context for all color picker parts.

PropDefaultTypeDescription

color

[0, 1, 1, 1]

HSVA

Current color value in HSVA format [hue, saturation, value, alpha]. Hue is 0-360, others are 0-1.

onColorChange

-

(color: HSVA) => void

Callback function that receives the new color value when the user makes a selection.

disabled

false

boolean

When true, prevents user interaction with all color picker parts.

ColorPickerArea

A 2D saturation-value picker that displays as a square gradient.

ColorPickerHue

A horizontal hue slider that displays the full color spectrum.

ColorPickerSaturation

A horizontal saturation slider for the current hue.

ColorPickerLightness

A horizontal lightness/value slider for the current hue and saturation.

ColorPickerAlpha

A horizontal alpha/opacity slider with a checkered background pattern.

Examples

Basic Usage

With Initial Color

Disabled State

All Controls

Multiple formats

Best Practices

  1. Color Format:

    • Use HSVA format internally for consistent color manipulation
    • Convert to appropriate formats (HEX, RGB, HSL) for display or export
    • Consider alpha channel support when needed
  2. Layout and Composition:

    • Combine different picker parts based on your use case
    • Use Area + Hue for most common scenarios
    • Add Alpha slider when transparency is needed
    • Consider space constraints when choosing components
  3. Accessibility:

    • Ensure sufficient contrast between handles and backgrounds
    • Test keyboard navigation thoroughly
    • Provide clear labels for screen readers
    • Consider users with color vision deficiencies