ColorPicker
HSV colour picker with a saturation/value field, hue slider, swatches, and hex input.
Live demo
01Full 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<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
03What 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 itWhy 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:
- A solid background of the pure hue (
hsv(hue, 1, 1)), set via the--hue-colorcustom property. - A left-to-right whiteβtransparent gradient β moving right increases saturation.
- 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 + blurEach 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 Β· eyedropperProps 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.EyeDropperAPI directly (feature-detected, no polyfill).
File Structure
ColorPicker.svelte # The component
ColorPicker.test.ts # Unit tests
ColorPicker.md # This explainerAPI
04| 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 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. |