Forms

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.

#3b82f6

Basic 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

#3b82f6

Fixed 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.

Solid colors only

<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.

KeyAction
Left / RightOn the saturation handle: saturation down or up by 1%
Up / DownOn the saturation handle: brightness up or down by 1%
Shift + ArrowOn the saturation handle: the same moves in 10% steps
Page Up / Page DownOn the saturation handle: brightness by 10%, whether or not shift is held
Home / EndOn the saturation handle: saturation straight to 0% or 100%
Left / RightOn the hue slider: 1 degree per press. On the alpha slider: 1% per press
EnterIn the text field: commits the typed color and leaves the field
TabMoves 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.

SlotDescription
rootOuter container. Carries the fixed width for the size
areaSaturation and brightness square
areaThumbDraggable handle inside the area
controlsRow holding the eyedropper, the preview and the sliders
eyeDropperEyedropper button
eyeDropperIconIcon inside the eyedropper button
previewSwatch showing the current color over a checkerboard
slidersColumn holding the hue and alpha sliders
sliderA single slider root
trackGradient track of a slider
thumbDraggable thumb of a slider
inputsRow holding the format button and the text field
formatButtonButton that cycles hex, rgb and hsl
inputEditable text field
swatchesWrapper around the preset swatches
swatchA single preset swatch button

Props

Every other prop is forwarded to the root element.

PropTypeDefault
valuestring'#000000'
format'hex' | 'rgb' | 'hsl''hex'
alphabooleanfalse
onValueChange(value: string) => void-
onValueCommit(value: string) => void-
swatchesstring[]-
previewbooleantrue
inputbooleantrue
formatSelectbooleanfalse
eyeDropperbooleantrue
eyeDropperIconstring'lucide:pipette'
disabledbooleanfalse
colorprimary | secondary | tertiary | success | warning | error | info | surface'primary'
size'xs' | 'sm' | 'md' | 'lg' | 'xl''md'
namestring-
idstring-
areaLabelstring'Saturation and brightness'
hueLabelstring'Hue'
alphaLabelstring'Alpha'
refHTMLElement | nullnull
classstring-
uiRecord<Slot, Class>-