ColorPicker
A color picker with a saturation area, hue and alpha sliders, preset swatches, an editable text field and an eyedropper. It reads hex, rgb and hsl on the way in, writes back in the notation you ask for, and is fully operable from the keyboard.
Playground
Experiment with different props in real-time.
#3b82f6Basic Usage
Bind value and the picker takes care of the rest. Any hex, rgb() or hsl() string is accepted on the way in, along with transparent. A string that cannot be parsed is ignored rather than throwing, and the current color
stays put.
#3b82f6<script lang="ts">
import { ColorPicker } from 'sv5ui';
let color = $state('#3b82f6');
</script>
<ColorPicker bind:value={color} />
<!-- Any of these are accepted on the way in, plus transparent.
An unparseable string is ignored and leaves the color untouched. -->
<ColorPicker value="#3b82f6" />
<ColorPicker value="#3b82f6cc" />
<ColorPicker value="rgb(59, 130, 246)" />
<ColorPicker value="hsl(217, 91%, 60%)" />
<ColorPicker value="transparent" />Format
format decides the notation written back to value. formatSelect adds a button that cycles hex, rgb and hsl, and format is
bindable so you can follow along.
Cycled by the user
#3b82f6Fixed by a prop
#8b5cf6<!-- format decides how the value is serialized back out -->
<ColorPicker bind:value={color} format="rgb" />
<ColorPicker bind:value={color} format="hsl" />
<!-- formatSelect adds a button that cycles hex -> rgb -> hsl.
Bind format to follow along. It requires input. -->
<script lang="ts">
let color = $state('#3b82f6');
let format = $state<ColorFormat>('hex');
</script>
<ColorPicker bind:value={color} bind:format formatSelect />
<!-- The incoming string is kept verbatim until the first edit, so a
value written in one notation is not rewritten in format until
the color actually changes. This starts as rgb(...) even though
format is hex, and becomes hex the moment you touch it. -->
<ColorPicker value="rgb(59, 130, 246)" format="hex" />The string you pass in is kept verbatim until the first edit, so a value written in one
notation is not silently rewritten. The picker below starts as rgb(59, 130, 246) even though its format is hex, and only becomes hex once the color actually changes.
rgb(59, 130, 246)<!-- format decides how the value is serialized back out -->
<ColorPicker bind:value={color} format="rgb" />
<ColorPicker bind:value={color} format="hsl" />
<!-- formatSelect adds a button that cycles hex -> rgb -> hsl.
Bind format to follow along. It requires input. -->
<script lang="ts">
let color = $state('#3b82f6');
let format = $state<ColorFormat>('hex');
</script>
<ColorPicker bind:value={color} bind:format formatSelect />
<!-- The incoming string is kept verbatim until the first edit, so a
value written in one notation is not rewritten in format until
the color actually changes. This starts as rgb(...) even though
format is hex, and becomes hex the moment you touch it. -->
<ColorPicker value="rgb(59, 130, 246)" format="hex" />Alpha
alpha adds the transparency slider and puts the channel into the value. It is only appended when it
means something, so a fully opaque color stays #3b82f6 rather than becoming #3b82f6ff. Without the prop the channel is dropped entirely, so an incoming translucent value reads
as opaque.
#3b82f680<!-- alpha adds the transparency slider and puts the channel
into the emitted value -->
<ColorPicker bind:value={color} alpha />
<!-- The channel is only appended when it means something: a fully
opaque color stays #3b82f6 rather than becoming #3b82f6ff.
a = 1 -> '#3b82f6'
a = 0.5 -> '#3b82f680'
The same applies to rgb() vs rgba() and hsl() vs hsla(). -->
<!-- Without alpha the channel is dropped entirely, so an incoming
translucent value is read as opaque. -->
<ColorPicker value="#3b82f680" />Swatches
swatches renders a row of presets below the picker. A swatch is outlined when it serializes to exactly
the current value, so with alpha on it
has to match the transparency too.
#10b981<script lang="ts">
const brand = [
'#0f172a', '#1e40af', '#3b82f6', '#0ea5e9',
'#10b981', '#eab308', '#ef4444'
];
// The swatch row wraps, so a palette wider than the picker
// spills onto a second line: seven fit at the default md size.
</script>
<ColorPicker bind:value={color} swatches={brand} />
<!-- A swatch shows as selected when it serializes to the current
value, so with alpha on it has to match transparency too.
Entries that do not parse are skipped rather than throwing. -->Controls
Everything below the saturation area is optional, down to the area and the hue slider alone.
The eyedropper is the one control you do not fully decide: it uses the browser EyeDropper API, support is checked on mount, and where the API is missing the button is absent rather than
disabled. That currently means Chrome and Edge have it, Firefox and Safari do not, and it needs
a secure context.
No preview
No input
Area and hue only
<!-- Everything below the saturation area is optional -->
<ColorPicker bind:value={color} preview={false} />
<ColorPicker bind:value={color} input={false} />
<ColorPicker bind:value={color} eyeDropper={false} />
<!-- Down to the area and the hue slider alone -->
<ColorPicker
bind:value={color}
preview={false}
input={false}
eyeDropper={false}
/>
<!-- The eyedropper uses the browser EyeDropper API. Support is
checked on mount, so the button is absent rather than disabled
in browsers without it, and it needs a secure context. -->
<ColorPicker bind:value={color} eyeDropperIcon="lucide:crosshair" />Events
onValueChange fires on every change, including each frame of a drag, so keep it cheap. onValueCommit fires once the interaction ends: on pointer release, on each keyboard step, and when a swatch,
the eyedropper or the text field sets the color. Drag inside the area below to watch the two counters
diverge.
onValueChange 0
onValueCommit 0
Last commit #3b82f6
<ColorPicker
bind:value={color}
onValueChange={(value) => {
// Every frame of a drag. Keep this cheap.
preview = value;
}}
onValueCommit={(value) => {
// Once per interaction: pointer release, each keyboard step,
// a swatch, the eyedropper, or the text field losing focus.
history.push(value);
save(value);
}}
/>Inside a Popover
The picker has a fixed width per size, so it drops into a popover without needing one. This is the shape most interfaces want: a swatch button that opens the picker, rather than the whole control sitting in the page.
<script lang="ts">
import { ColorPicker, Popover, Button } from 'sv5ui';
let color = $state('#3b82f6');
</script>
<!-- The default slot is the trigger, the content snippet is the panel.
The picker sizes itself, so the panel needs no width of its own. -->
<Popover>
<Button variant="outline" class="gap-2">
<span
class="size-4 rounded-full ring-1 ring-inset ring-outline-variant"
style="background-color: {color}"
></span>
<span class="font-mono text-sm">{color}</span>
</Button>
{#snippet content()}
<div class="p-3">
<ColorPicker bind:value={color} {swatches} alpha />
</div>
{/snippet}
</Popover>Form Integration
Inside a FormField the picker inherits the field name and size, and id lands on the saturation handle so the label points at a real control. An error flips color to error and sets aria-invalid. Submitting below with the alpha slider moved will fail validation, since the schema wants
a solid six digit hex.
<script lang="ts">
import { ColorPicker, Form, FormField, Button } from 'sv5ui';
import { z } from 'zod';
const schema = z.object({
brand: z.string().regex(/^#[0-9a-f]{6}$/i, 'Pick a solid hex color')
});
let state = $state({ brand: '#3b82f6' });
</script>
<Form {schema} bind:state>
<!-- FormField hands down name, size and error state. id lands on
the saturation handle, so the label points at it. -->
<FormField label="Brand color" name="brand" required>
<ColorPicker bind:value={state.brand} />
</FormField>
<Button type="submit" label="Save" />
</Form>
<!-- An error in the field flips color to error and sets aria-invalid.
onInput fires while dragging and onChange on commit, so
validate-on-blur and validate-on-change both behave. -->
<!-- For a plain POST with no Form wrapper, name renders a hidden
input carrying the serialized value. -->
<form method="POST">
<ColorPicker name="brand" value="#3b82f6" />
</form>Disabled
Dims the picker, drops pointer interaction on the area and the sliders, and disables every button and the text field.
<!-- Dims the picker, drops pointer interaction on the area and
the sliders, and disables every button and the text field. -->
<ColorPicker value="#3b82f6" disabled />Appearance
color drives the focus rings and the outline on the selected swatch, not the color being edited. size sets the width of the whole picker and the height of the saturation area, and ui reaches
each slot directly.
xs
md, success
lg, slot override
<!-- color drives the focus rings and the selected swatch outline,
not the swatch being edited -->
<ColorPicker bind:value={color} color="success" />
<!-- size sets the width of the whole picker and the height of the
saturation area: xs w-44, sm w-48, md w-56, lg w-64, xl w-72 -->
<ColorPicker bind:value={color} size="xs" />
<ColorPicker bind:value={color} size="xl" />
<!-- Slot classes. root carries the width, so override it there to
break out of the fixed size. -->
<ColorPicker
bind:value={color}
size="lg"
ui={{ area: 'rounded-xl', input: 'font-mono' }}
/>
<ColorPicker bind:value={color} ui={{ root: 'w-full', area: 'h-56' }} />Global Configuration
Set defaults once and every picker picks them up, including the eyedropper icon.
See Theming for the full configuration surface.
import { defineConfig } from 'sv5ui';
defineConfig({
colorPicker: {
defaultVariants: { color: 'tertiary', size: 'lg' },
slots: { area: 'rounded-xl' }
},
icons: {
// Used by the eyedropper button
eyeDropper: 'lucide:pipette'
}
});Keyboard Navigation
Every control is reachable and operable without a pointer.
| Key | Action |
|---|---|
Left / Right | On the saturation handle: saturation down or up by 1% |
Up / Down | On the saturation handle: brightness up or down by 1% |
Shift + Arrow | On the saturation handle: the same moves in 10% steps |
Page Up / Page Down | On the saturation handle: brightness by 10%, whether or not shift is held |
Home / End | On the saturation handle: saturation straight to 0% or 100% |
Left / Right | On the hue slider: 1 degree per press. On the alpha slider: 1% per press |
Enter | In the text field: commits the typed color and leaves the field |
Tab | Moves between the handle, the eyedropper, the sliders, the format button, the text field and the swatches |
UI Slots
Use the ui prop to override classes.
| Slot | Description |
|---|---|
root | Outer container. Carries the fixed width for the size |
area | Saturation and brightness square |
areaThumb | Draggable handle inside the area |
controls | Row holding the eyedropper, the preview and the sliders |
eyeDropper | Eyedropper button |
eyeDropperIcon | Icon inside the eyedropper button |
preview | Swatch showing the current color over a checkerboard |
sliders | Column holding the hue and alpha sliders |
slider | A single slider root |
track | Gradient track of a slider |
thumb | Draggable thumb of a slider |
inputs | Row holding the format button and the text field |
formatButton | Button that cycles hex, rgb and hsl |
input | Editable text field |
swatches | Wrapper around the preset swatches |
swatch | A single preset swatch button |
Props
Every other prop is forwarded to the root element.
| Prop | Type | Default |
|---|---|---|
value | string | '#000000' |
format | 'hex' | 'rgb' | 'hsl' | 'hex' |
alpha | boolean | false |
onValueChange | (value: string) => void | - |
onValueCommit | (value: string) => void | - |
swatches | string[] | - |
preview | boolean | true |
input | boolean | true |
formatSelect | boolean | false |
eyeDropper | boolean | true |
eyeDropperIcon | string | 'lucide:pipette' |
disabled | boolean | false |
color | primary | secondary | tertiary | success | warning | error | info | surface | 'primary' |
size | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'md' |
name | string | - |
id | string | - |
areaLabel | string | 'Saturation and brightness' |
hueLabel | string | 'Hue' |
alphaLabel | string | 'Alpha' |
ref | HTMLElement | null | null |
class | string | - |
ui | Record<Slot, Class> | - |