Elements

ColorPicker

A color picker holds one color, and the parts inside it show and change it: a ColorArea of saturation and brightness, ColorSliders of any channel of RGB, HSL, HSB or OKLCH and of the alpha, ColorFields that take the color as text or one channel as a number, ColorSwatches of preset colors and a ColorEyeDropper that picks a color anywhere on the screen. The app lays the parts out with its own elements in between (a row per channel with its letter, the space picked with Tabs, the picker in a Popover), and styles each of them: the parts draw the colors of the channels, and every size, radius, border and shadow is the app’s.

el-color-picker.tsx
el-color-picker.tsxLive
import { mount, signal, Text, View } from "nsh";
import { type ColorChannel, ColorArea, ColorEyeDropper, ColorField, ColorPicker, ColorSlider, type ColorSpace, ColorSwatch } from "nsh/base";
import { desktop, field, Icon, Log, Segmented, Split, ui } from "./palette";

// `ColorPicker` holds the color; its parts, laid out and styled here, read
// and change it: the saturation and brightness area, the hue and alpha
// sliders, a row per channel of the space picked (its slider and its
// number), the color typed in any CSS form, the browser's eyedropper where
// it has one, and a palette of swatches.
const thumb = "rounded-full border-2 border-white";
const thumbStyle = { boxShadow: "0 0 0 1px rgba(0,0,0,0.25), 0 1px 3px rgba(0,0,0,0.3)" };
const spaces = ["rgb", "hsl", "hsb", "oklch"] as const;
const channels: Record<ColorSpace, [ColorChannel, string][]> = {
  rgb: [["red", "R"], ["green", "G"], ["blue", "B"]],
  hsl: [["hue", "H"], ["saturation", "S"], ["lightness", "L"]],
  hsb: [["hue", "H"], ["saturation", "S"], ["brightness", "B"]],
  oklch: [["lightness", "L"], ["chroma", "C"], ["hue", "H"]],
};
const palette = ["#ef4444", "#f97316", "#eab308", "#22c55e", "#06b6d4", "#3b82f6", "#8b5cf6", "#ec4899", "#0f172a80"];

/** A track `h` points high, half a thumb free at each end for the thumb. */
const track = (h: number) => ({
  class: `h-[${h + 8}px] justify-center px-[${(h + 6) / 2}px]`,
  trackClass: `h-[${h}px] rounded-full`,
  thumbClass: `w-[${h + 6}px] h-[${h + 6}px] ${thumb}`,
  thumbStyle,
  fillThumb: true,
});

function App() {
  const color = signal("#3b82f6ff");
  const text = signal("#3b82f6");
  const space = signal<ColorSpace>("oklch");
  const events = signal<string[]>([]);
  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>color picker</Text>
      <Text style={{ color: ui().muted }}>
        {desktop ? "Drag the area or a slider, type a color, or pick a swatch" : "Drag the area or a slider, or tap a swatch"}
      </Text>
      <Split
        gap={14}
        inspector={
<Log title="onChangeEnd({ value, formatted })" lines={events()} minHeight={92} />
        }
      >
        <View style={{ ...ui().card, borderRadius: 16, padding: 16 }}>
          <ColorPicker
            value={color()}
            onChange={({ value, formatted }) => {
              color.set(value);
              text.set(formatted);
            }}
            onChangeEnd={({ formatted }) => events.set([formatted, ...events()].slice(0, 5))}
            style={{ gap: 12 }}
          >
            <ColorArea
              accessibilityLabel="Saturation and brightness"
              style={{ height: desktop ? 160 : 140 }}
              panelClass="rounded-lg"
              thumbClass={`w-[18px] h-[18px] ${thumb}`}
              thumbStyle={thumbStyle}
              fillThumb
            />
            <View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
              <ColorSwatch accessibilityLabel="Current color" style={{ width: 36, height: 36, borderRadius: 18 }} checkerboardColor="#d4d4d8" />
              <View style={{ flex: 1, gap: 8 }}>
                <ColorSlider colorSpace="hsb" channel="hue" accessibilityLabel="Hue" {...track(12)} />
                <ColorSlider channel="alpha" accessibilityLabel="Alpha" checkerboardColor="#d4d4d8" {...track(12)} />
              </View>
            </View>
            <Text style={{ fontSize: 13, color: ui().muted, fontVariant: ["tabular-nums"] }}>{text()}</Text>
            <Segmented options={spaces} value={space()} onChange={(next) => space.set(next)} />
            {() =>
              channels[space()].map(([channel, letter]) => (
                <View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
                  <Text style={{ width: 14, fontSize: 13, color: ui().muted }}>{letter}</Text>
                  <ColorSlider colorSpace={space()} channel={channel} accessibilityLabel={`${space()} ${channel}`} style={{ flex: 1 }} {...track(10)} />
                  <ColorField
                    colorSpace={space()}
                    channel={channel}
                    accessibilityLabel={`${space()} ${channel} value`}
                    {...field({ style: { width: 64, textAlign: "right" } })}
                  />
                </View>
              ))
            }
            <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
              <ColorField
                accessibilityLabel="Color"
                placeholder="#rrggbb, rgb(), hsl(), oklch() or a name"
                {...field({ style: { flex: 1 } })}
              />
              <ColorEyeDropper
                accessibilityLabel="Pick a color from the screen"
                class="disabled:hidden w-9 h-9 items-center justify-center rounded-md border border-border active:opacity-70"
              >
                <Icon name="pipette" size={16} tint={ui().text} />
              </ColorEyeDropper>
            </View>
            <View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8 }}>
              {palette.map((c) => (
                <ColorSwatch
                  color={c}
                  style={{ width: 24, height: 24, borderRadius: 6 }}
                  selectedStyle={{ borderWidth: 2, borderColor: ui().text }}
                  checkerboardColor="#d4d4d8"
                  checkerboardSize={6}
                />
              ))}
            </View>
          </ColorPicker>
        </View>
      </Split>
    </View>
  );
}

mount(App);
use nsh_base::{
    ColorChannel, ColorPickerEvent, ColorSpace, color_area, color_field, color_picker,
    color_slider, color_swatch, theme,
};
use nsh_core::prelude::*;

fn primary(color: Signal<Hsla>) -> impl IntoElement {
    // A channel of OKLCH: its letter, its slider, its number.
    let channel = move |channel: ColorChannel, letter: &'static str| {
        view()
            .flex_row()
            .items_center()
            .gap_2()
            .child(text(letter).w(px(14.0)).text_sm().text_color(move || theme().muted_foreground))
            .child(
                color_slider()
                    .color_space(ColorSpace::Oklch)
                    .channel(channel)
                    .accessibility_label(letter)
                    .flex_1()
                    .h(px(18.0))
                    .justify_center()
                    .px(px(8.0))
                    .track_style(|s| s.h(px(10.0)).rounded_full())
                    .thumb_style(|s| s.size(px(16.0)).rounded_full().border_2().border_color(white()))
                    .fill_thumb(true),
            )
            .child(
                color_field()
                    .color_space(ColorSpace::Oklch)
                    .channel(channel)
                    .accessibility_label(letter)
                    .w(px(64.0))
                    .h(px(28.0))
                    .px_2()
                    .rounded_md()
                    .border_1()
                    .border_color(move || theme().border)
                    .text_sm(),
            )
    };
    color_picker()
        .value(move || Some(color.get()))
        .on_change(move |e: &ColorPickerEvent| {
            if let ColorPickerEvent::Change(value, _) = e {
                color.set(*value);
            }
        })
        .gap_3()
        .child(
            color_area()
                .accessibility_label("Saturation and brightness")
                .h(px(160.0))
                .panel_style(|s| s.rounded_lg())
                .thumb_style(|s| s.size(px(18.0)).rounded_full().border_2().border_color(white()))
                .fill_thumb(true),
        )
        .child(channel(ColorChannel::Lightness, "L"))
        .child(channel(ColorChannel::Chroma, "C"))
        .child(channel(ColorChannel::Hue, "H"))
        .child(color_swatch().size(px(28.0)).rounded_md())
}

ColorPicker, ColorArea, ColorSlider, ColorField, ColorSwatch and ColorEyeDropper come from nsh/base, the nsh-base crate in Rust. In Rust, color_picker() and its parts’ functions make them with a setter per prop (.channel(..), .thumb_style(..)), a listener method per event (.on_change(..)) and the typed style methods; the color is an Hsla, the spaces, channels and formats are ColorSpace, ColorChannel and ColorFormat.

#Value

The color is a CSS color: hex (#3b82f6, #3b82f680 with its alpha), rgb(), hsl(), hsb(), oklch() or a CSS name; in Rust an Hsla. The app owns it with value and sets the one onChange reports, as for every control of nsh/base; or it leaves the color to the picker with defaultValue and hears the changes all the same.

  • onChange({ value, formatted }) reports each color the user asks for: at each step of a drag on the area or a slider, for each key, a field’s number as it is typed, a swatch pressed. value is the color, formatted the same color written as format says ("hex", "rgb", "hsl", "hsb" or "oklch"), ready to save or to set as a token.
  • onChangeEnd({ value, formatted }) reports the color the change ended at, once: when the pointer lifts, after each key, when a field is left or submitted, when a swatch is pressed.
  • The picker keeps the color in each space as the user set it. A gray keeps its hue and saturation, so the hue slider stays where the user left it; a color edited in OKLCH keeps its lightness, chroma and hue as typed, also past what sRGB shows (the color reported is the nearest one sRGB shows, and formatted in "oklch" keeps the channels).
  • A value the app sets that the picker did not report replaces the color in every space.

#Spaces and channels

colorSpaceChannels (channel)Ranges
"rgb""red", "green", "blue"0 to 255
"hsl""hue", "saturation", "lightness"hue 0 to 360, the others 0 to 100
"hsb""hue", "saturation", "brightness"hue 0 to 360, the others 0 to 100
"oklch""lightness", "chroma", "hue"lightness 0 to 100 (by 0.1), chroma 0 to 0.4 (by 0.001), hue 0 to 360 (by 0.1)
any"alpha"0 to 100

A slider’s track draws its channel’s colors with the other channels held, so it shows where each value leads. A channel its space does not have is the space’s first (a warning says so).

#Props

#ColorPicker

PropRustTypeDefaultDescription
value.value(..)stringnoneThe color (controlled). The app sets the one onChange reports.
defaultValue.default_value(..)string"#000000"The color at first while value is not set; the picker then keeps its own.
format.format(..)"hex" | "rgb" | "hsl" | "hsb" | "oklch""hex"How formatted writes the color.
disabled.disabled(..)booleanfalseNo changes from any part; disabled: classes apply to the picker and its parts.
children.child(..)JSX.ElementnoneThe parts and the app’s elements between them.
class, stylestyle methodsstring, StyleProp<ViewStyle>noneThe picker itself: the column (or row) around its parts.

#ColorArea

PropRustTypeDefaultDescription
panelClass, panelStyle.panel_style(|s| ..)string, StyleProp<ViewStyle>noneThe panel, filling the area: the hue’s colors, saturation from left to right and brightness from bottom to top, clipped to its corners.
thumbClass, thumbStyle.thumb_style(|s| ..)string, StyleProp<ViewStyle>noneThe thumb, centered on the color.
fillThumb.fill_thumb(..)booleanfalseThe thumb’s background is the color.
disabled.disabled(..)booleanfalseNo pointer, keyboard or screen reader changes.
accessibilityLabel.accessibility_label(..)stringnoneWhat screen readers say for the thumb.
class, stylestyle methodsstring, StyleProp<ViewStyle>noneThe area: its size is the app’s.

#ColorSlider

PropRustTypeDefaultDescription
colorSpace.color_space(..)"rgb" | "hsl" | "hsb" | "oklch""hsb"The space of the channel.
channel.channel(..)ColorChannel"hue"The channel it changes: one of the space’s, or "alpha".
axis.axis(..)"horizontal" | "vertical""horizontal"The track’s direction: the smallest value at the left, or at the bottom. Read when the slider is made.
trackClass, trackStyle.track_style(|s| ..)string, StyleProp<ViewStyle>noneThe track: the channel’s colors along it, clipped to its corners; the thumb is placed on it.
thumbClass, thumbStyle.thumb_style(|s| ..)string, StyleProp<ViewStyle>noneThe thumb, centered on the value.
fillThumb.fill_thumb(..)booleanfalseThe thumb’s background is the color (with its alpha on an alpha slider).
checkerboardColor.checkerboard_color(..)stringnoneThe color of a checkerboard under the track, which shows through an alpha slider’s colors.
checkerboardSize.checkerboard_size(..)number4The side of the checkerboard’s squares.
disabled.disabled(..)booleanfalseNo pointer, keyboard or screen reader changes.
accessibilityLabel.accessibility_label(..)stringnoneWhat screen readers say for the thumb.
class, stylestyle methodsstring, StyleProp<ViewStyle>noneThe slider: the band around the track that takes presses and drags.

#ColorField

PropRustTypeDefaultDescription
channel.channel(..)ColorChannelnoneThe channel it takes as a number. Without it, the field takes the whole color as text.
colorSpace.color_space(..)ColorSpace"hsb"The space of channel.
format.format(..)ColorFormat"hex"How the field writes the whole color. It reads any CSS form.
placeholder.placeholder(..)stringnoneShown while the field is empty.
disabled.disabled(..)booleanfalseNo typing.
accessibilityLabel.accessibility_label(..)stringnoneWhat screen readers say for the field.
class, stylestyle methodsstring, StyleProp<TextStyle>noneThe field, a text input.

A channel’s field changes the color as a number is typed, held to the channel’s range; the whole color’s field changes it when the field is left or submitted, if the text reads as a color. Leaving a field writes its value again in its form.

#ColorSwatch

PropRustTypeDefaultDescription
color.color(..)stringnoneThe color it shows; a press picks it in the picker around it. Without it, the picker’s own color: a preview.
selectedStyle.selected_style(..)StyleProp<ViewStyle>noneThe style while it is the picker’s color, as selected: classes.
checkerboardColor.checkerboard_color(..)stringnoneA checkerboard under a translucent color.
checkerboardSize.checkerboard_size(..)number4The side of its squares.
disabled.disabled(..)booleanfalseNo presses.
accessibilityLabel.accessibility_label(..)stringthe color in hexWhat screen readers say.
children.child(..)JSX.ElementnoneContent over the color: a check on the selected swatch (group-selected:).
class, stylestyle methodsstring, StyleProp<ViewStyle>noneThe swatch: its size, shape and border; its corners clip the color.

#ColorEyeDropper

PropRustTypeDefaultDescription
disabled.disabled(..)booleanfalseNo presses. It is disabled too where the platform has no eyedropper.
accessibilityLabel.accessibility_label(..)stringnoneWhat screen readers say.
children.child(..)JSX.ElementnoneIts content: the app’s icon or label.
class, stylestyle methodsstring, StyleProp<ViewStyle>noneThe button.

A press shows the system’s eyedropper: AppKit’s color sampler on macOS, the browser’s EyeDropper on the web (Chrome and Edge). The color picked becomes the picker’s (onChange, then onChangeEnd); Escape picks none. Where the platform has no eyedropper the button is disabled, and disabled:hidden hides it there.

#Events

EventOfRustPayloadWhen
onChangeColorPicker.on_change(|e: &ColorPickerEvent| ..)({ value: string, formatted: string })The user changed the color.
onChangeEndColorPicker.on_change_end(|e: &ColorPickerEvent| ..)({ value: string, formatted: string })The change ended. Once per change.
onPressColorSwatch.on_press(|color: &Hsla| ..)({ color: string })The swatch was pressed; it works alone too, as a palette’s chip.
onPickColorEyeDropper.on_pick(|color: &Hsla| ..)({ color: string })The user picked a color on the screen; it works alone too.

In Rust, ColorPickerEvent::Change(Hsla, SharedString) and ColorPickerEvent::ChangeEnd(Hsla, SharedString) carry the color and its formatted text.

#Parts

PartInsideStyled with
ColorAreathe pickerclass, style
The area’s panelthe area, filling itpanelClass, panelStyle
The area’s thumbthe area, centered on the colorthumbClass, thumbStyle
ColorSliderthe pickerclass, style
The slider’s trackthe slidertrackClass, trackStyle
The slider’s thumbthe slider, centered on the value on the trackthumbClass, thumbStyle
ColorFieldthe pickerclass, style
ColorSwatchthe picker, or aloneclass, style, selectedStyle
ColorEyeDropperthe picker, or aloneclass, style

A slider’s thumb is centered on the track’s ends at the channel’s ends. Padding on the slider of half the thumb’s width (h-6 justify-center px-2.5 around an h-3 track) keeps the whole thumb inside the slider, where it takes presses at both ends. States the parts show: a thumb’s active: while it is dragged and focus: with the keyboard focus, a swatch’s selected:, and every part’s disabled:.

#Keyboard

On macOS and the web each thumb is a stop of the keyboard focus (Tab):

KeyOn a sliderOn the area
Right, UpOne step upRight: more saturation; Up: more brightness
Left, DownOne step downLeft: less saturation; Down: less brightness
Shift with an arrowTen stepsTen steps
Page Up, Page DownTen stepsTen steps of brightness
Home, EndThe channel’s ends

A swatch takes Space and Return. Each key reports onChange and then onChangeEnd; arrows repeat while held.

#Accessibility

  • The area’s and each slider’s thumb are adjustable elements ("adjustable", a slider in the browser) with their label, value and range; screen readers’ increment and decrement step them.
  • Each field is a text field with its label; each swatch a button, selected while it is the picker’s color, named by its color in hex unless accessibilityLabel names it. The eyedropper is a button with the app’s label.
  • While disabled, screen readers say the parts are dimmed, and they take no actions.

See Accessibility for labels, roles and actions in general.

#Platform notes

  • Every platform draws what the app styles; the tracks’ and the panel’s colors are gradients drawn by the platform. Presses and drags are followed in the core, for a mouse, a pen or a finger, and a scroll view around the picker keeps its scrolling to drags that start elsewhere.
  • ColorEyeDropper picks with AppKit’s color sampler on macOS and the browser’s EyeDropper on the web; the color arrives in sRGB.