ColorPicker

HSV colour picker with a saturation/value field, hue slider, swatches, and hex input.

Live demo

01

Full picker

Plane, hue slider, hex input, eyedropper (where supported) and preset swatches.

Active: #3b82f6

Minimal Β· no swatches

Pass swatches={[]} to drop the preset row for a leaner control.

Active: #22c55e

Plane only Β· hex hidden

Hide the hex field with showHex={false} when the visual surface is enough.

Active: #ec4899

Implementation

02
ColorPicker.svelte
<script lang="ts">
  import ColorPicker from '$lib/components/ColorPicker.svelte';
  let colour = $state('#4f7cff');
</script>
​
<ColorPicker bind:value={colour} label="Brand colour" />

ColorPicker keeps HSV (hue, saturation, value) as its source of truth and derives the hex string on the way out, so dragging brightness or saturation to an extreme never loses the hue. The square plane sets saturation (left→right) and brightness (bottom→top), the vertical bar spins the hue, and both are role="slider" with arrow-key control. A live hex field, preset swatches, and a feature-detected native EyeDropper round it out. The chosen colour flows back through bind:value as a 6-digit hex.

Logic explainer

03

What Does It Do? (Plain English)

ColorPicker lets a user choose a colour visually. It shows a square saturation/brightness plane, a vertical hue slider down the side, a live preview swatch, an editable hex field, a row of preset swatches, and β€” where the browser supports it β€” a native "pick from screen" eyedropper button. The chosen colour flows out as a bindable 6-digit hex string.

Think of it like: the colour panel in a design app. Drag inside the square to set how vivid and how bright the colour is, slide the rainbow bar to spin the hue, or just type a hex code.


How It Works (Pseudo-Code)

ON mount:
  PARSE the incoming `value` hex β†’ RGB β†’ HSV
  STORE hue, sat, val as the source of truth

WHILE the user interacts:
  hex      = HSV β†’ RGB β†’ '#rrggbb'   (derived)
  value    = hex                     (effect pushes it up to the binding)

WHEN parent overwrites `value` with a hex we did not produce:
  PARSE it β†’ HSV and re-sync hue/sat/val

DRAG on the plane:
  sat = x position across the plane (0 β†’ 1)
  val = 1 - y position down the plane (top is brightest)

DRAG on the hue track:
  hue = y position down the track Γ— 360

TYPE in the hex field:
  hold a draft string; on Enter / blur, parse it.
  valid β†’ adopt it; invalid β†’ snap back to the live colour

CLICK a swatch / use the eyedropper:
  parse the picked hex β†’ HSV and adopt it

Why HSV Is the Source of Truth

Hex (and RGB) cannot remember a hue once a colour collapses to black or white. Pure black is #000000 whether the user arrived there from red, green or blue β€” the hue information is gone. If the picker stored only hex, dragging the brightness to zero and back would reset the hue to red every time.

By keeping hue, sat and val as the live state and only deriving hex from them, the hue slider stays put while you drag saturation or brightness to their extremes. Hex is computed on the way out, never the way in (except when re-syncing an external write).

hue/sat/val  ──derived──▢  rgb  ──derived──▢  hex  ──effect──▢  value (binding)
     β–²                                                              β”‚
     └──────────────  re-sync only on foreign external write  β”€β”€β”€β”€β”€β”€β”˜

The Saturation / Value Plane

The square is painted with three stacked layers, cheaply, in pure CSS:

  1. A solid background of the pure hue (hsv(hue, 1, 1)), set via the --hue-color custom property.
  2. A left-to-right white→transparent gradient — moving right increases saturation.
  3. A bottom-to-top black→transparent gradient — moving up increases brightness (value).

The handle is positioned with left: sat% and top: (1 - val)% because the top edge is the brightest. Pointer maths reads the plane's bounding rect and clamps the normalised offsets into [0, 1].


Keyboard Model

Both the plane and the hue track are role="slider" with tabindex="0", so they are reachable and operable without a mouse.

Plane focused:
  ArrowLeft / ArrowRight   sat βˆ“ 0.02   (Shift β†’ 0.1)
  ArrowUp   / ArrowDown    val Β± 0.02   (Shift β†’ 0.1)

Hue track focused:
  ArrowUp / ArrowLeft      hue βˆ’ 2      (Shift β†’ 10)
  ArrowDown / ArrowRight   hue + 2      (Shift β†’ 10)
  Home                     hue β†’ 0
  End                      hue β†’ 360

Hex input:
  Enter                    commit + blur

Each handled key calls preventDefault() so the page does not scroll while the slider moves.


State Flow Diagram

            β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
            β”‚  hue / sat / val      β”‚  ← source of truth
            β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                        β”‚ derived
                        β–Ό
                β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
        β”Œβ”€β”€β”€β”€β”€β”€β”€β”‚  rgb β†’ hex   β”‚
        β”‚       β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜
        β”‚              β”‚ $effect
        β”‚              β–Ό
        β”‚      β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
        β”‚      β”‚   value       β”‚ (bind:value out)
        β”‚      β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜
        β”‚             β”‚ foreign write?
        β”‚             β–Ό
        β”‚      β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
        └──────│ re-sync HSV   β”‚
               β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

   Inputs that mutate hue/sat/val:
     plane drag Β· plane arrows
     hue drag   Β· hue arrows / Home / End
     hex commit Β· swatch click Β· eyedropper

Props Reference

Prop Type Default Description
value string '#3b82f6' Bindable selected colour as a 6-digit hex string. Accepts #rgb or #rrggbb on input.
swatches string[] curated 12-colour palette Preset hex swatches shown in a row beneath the picker. Pass [] to hide the row.
showHex boolean true Show the editable hex text input.
showEyeDropper boolean true Offer the native EyeDropper button β€” only renders when the browser supports the API.
label string 'Colour picker' ARIA label for the overall picker group.

Edge Cases

Situation Behaviour
Invalid hex typed in the field On commit (Enter/blur) the draft snaps back to the live colour; HSV is untouched.
3-digit shorthand (#0af) Expanded to #00aaff before parsing.
value set to black/white externally Re-synced to HSV; hue defaults sensibly (0) when undetectable, but the live drag still preserves the last set hue.
Brightness or saturation dragged to 0 Hue is retained because HSV β€” not hex β€” is the source of truth.
EyeDropper unsupported The button is feature-detected and simply not rendered.
EyeDropper dismissed by the user Caught silently β€” no change to the colour.
swatches={[]} The swatch row is omitted entirely.
prefers-reduced-motion Handle position transitions are disabled.

Dependencies

  • Zero external dependencies. All HSV ⇄ RGB ⇄ hex conversion is inline pure functions.
  • The eyedropper uses the native window.EyeDropper API directly (feature-detected, no polyfill).

File Structure

ColorPicker.svelte    # The component
ColorPicker.test.ts   # Unit tests
ColorPicker.md        # This explainer

API

04
PropTypeDefaultDescription
valuestring'#3b82f6'Bindable selected colour as a 6-digit hex string. Accepts #rgb or #rrggbb on input.
swatchesstring[]curated 12-colour palettePreset hex swatches shown beneath the picker. Pass [] to hide the row.
showHexbooleantrueShow the editable hex text input.
showEyeDropperbooleantrueOffer the native EyeDropper button β€” only renders when the browser supports the API.
labelstring'Colour picker'ARIA label for the overall picker group.