Octocat

Color Picker

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

'use client';

import { useState } from 'react';

import {
  ColorPicker,
  ColorPickerArea,
  ColorPickerHue,
  type HSVA,
} from '@/components/color-picker';

export default function ColorPickerExample() {
  const [color, setColor] = useState<HSVA>([0, 1, 1, 1]);

  return (
    <ColorPicker className="w-48" color={color} onColorChange={setColor}>
      <ColorPickerArea />
      <ColorPickerHue className="mt-2" />
    </ColorPicker>
  );
}

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/classnames';

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-4 focus-visible:ring-accent-element',
        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.

Prop Default Type Description
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

'use client';

import { useState } from 'react';

import {
  ColorPicker,
  ColorPickerArea,
  ColorPickerHue,
  type HSVA,
} from '@/components/color-picker';

export default function ColorPickerExample() {
  const [color, setColor] = useState<HSVA>([0, 1, 1, 1]);

  return (
    <ColorPicker className="w-48" color={color} onColorChange={setColor}>
      <ColorPickerArea />
      <ColorPickerHue className="mt-2" />
    </ColorPicker>
  );
}

With Initial Color

'use client';

import { useState } from 'react';

import {
  ColorPicker,
  ColorPickerArea,
  ColorPickerHue,
  type HSVA,
} from '@/components/color-picker';

export default function ColorPickerWithInitialColor() {
  const [color, setColor] = useState<HSVA>([210, 0.8, 0.9, 1]);

  return (
    <ColorPicker className="w-48" color={color} onColorChange={setColor}>
      <ColorPickerArea />
      <ColorPickerHue className="mt-2" />
    </ColorPicker>
  );
}

Disabled State

import {
  ColorPicker,
  ColorPickerArea,
  ColorPickerHue,
  type HSVA,
} from '@/components/color-picker';

export default function ColorPickerDisabled() {
  const color: HSVA = [120, 0.7, 0.8, 1];

  return (
    <ColorPicker color={color} disabled>
      <ColorPickerArea className="size-48" />
      <ColorPickerHue className="mt-2 w-48" />
    </ColorPicker>
  );
}

All Controls

'use client';

import { useState } from 'react';

import {
  ColorPicker,
  ColorPickerAlpha,
  ColorPickerArea,
  ColorPickerHue,
  ColorPickerLightness,
  ColorPickerSaturation,
  type HSVA,
} from '@/components/color-picker';

export default function ColorPickerAllControls() {
  const [color, setColor] = useState<HSVA>([280, 0.6, 0.9, 0.8]);

  return (
    <ColorPicker
      className="flex w-40 flex-col gap-2"
      color={color}
      onColorChange={setColor}
    >
      <ColorPickerArea className="size-40" />
      <ColorPickerHue />
      <ColorPickerSaturation />
      <ColorPickerLightness />
      <ColorPickerAlpha />
    </ColorPicker>
  );
}

Multiple formats

HEX#FF0000RGBrgb(255 0 0)HSV0, 100%, 100%
'use client';

import chroma from 'chroma-js';
import { useState } from 'react';

import {
  ColorPicker,
  ColorPickerAlpha,
  ColorPickerArea,
  ColorPickerHue,
  ColorPickerSaturation,
  type HSVA,
} from '@/components/color-picker';

export default function ColorPickerMultipleFormatsExample() {
  const [color, setColor] = useState<HSVA>([0, 1, 1, 1]);

  return (
    <div className="flex justify-center">
      <div className="flex gap-4">
        <div className="flex flex-col gap-4">
          <ColorPicker className="w-36" color={color} onColorChange={setColor}>
            <ColorPickerArea />
            <ColorPickerHue className="mt-2" />
            <ColorPickerSaturation className="mt-2" />
            <ColorPickerAlpha className="mt-2" />
          </ColorPicker>
        </div>

        <div className="flex w-36 flex-col gap-3">
          <div
            className="h-8 w-full flex-1 rounded-xl border border-foreground/10 bg-[var(--bg-color,--alpha(var(--color-foreground)/10%))]"
            style={
              color
                ? {
                    '--bg-color': chroma
                      .hsv(color[0], color[1], color[2])
                      .css(),
                  }
                : {}
            }
          />

          <div className="grid grid-cols-[auto,1fr] gap-x-2 gap-y-1 font-mono text-xs">
            <span className="font-medium text-foreground-secondary">HEX</span>
            <code className="rounded bg-background-secondary px-1.5 py-0.5">
              {chroma.hsv(color[0], color[1], color[2]).hex().toUpperCase()}
            </code>

            <span className="font-medium text-foreground-secondary">RGB</span>
            <code className="rounded bg-background-secondary px-1.5 py-0.5">
              {chroma.hsv(color[0], color[1], color[2]).css()}
            </code>

            <span className="font-medium text-foreground-secondary">HSV</span>
            <code className="rounded bg-background-secondary px-1.5 py-0.5">
              {Math.round(color[0])}, {Math.round(color[1] * 100)}%,{' '}
              {Math.round(color[2] * 100)}%
            </code>
          </div>
        </div>
      </div>
    </div>
  );
}

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

Previous

Checkbox

Next

Date Picker