Elementos
ColorPicker
Un color picker guarda un color, y las partes que contiene lo muestran y lo cambian: un ColorArea de saturación y brillo, ColorSliders de cualquier canal de RGB, HSL, HSB u OKLCH y del alfa, ColorFields que toman el color como texto o un canal como número, ColorSwatches de colores predefinidos y un ColorEyeDropper que elige un color en cualquier punto de la pantalla. La app dispone las partes con sus propios elementos intercalados (una fila por canal con su letra, el espacio elegido con Tabs, el picker dentro de un Popover) y da estilo a cada una: las partes dibujan los colores de los canales, y cada tamaño, radio, borde y sombra es de la app.
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())
}import { mount, signal, Text, View } from "nsh";
import { type ColorChannel, ColorArea, ColorField, ColorPicker, ColorSlider, ColorSwatch } from "nsh/base";
const thumb = "rounded-full border-2 border-white";
function Channel(props: { channel: ColorChannel; letter: string }) {
return (
<View class="flex-row items-center gap-2">
<Text class="w-[14px] text-sm text-muted-foreground">{props.letter}</Text>
<ColorSlider
colorSpace="oklch"
channel={props.channel}
accessibilityLabel={props.letter}
class="flex-1 h-[18px] justify-center px-2"
trackClass="h-[10px] rounded-full"
thumbClass={`size-4 ${thumb}`}
fillThumb
/>
<ColorField
colorSpace="oklch"
channel={props.channel}
accessibilityLabel={props.letter}
class="w-16 h-7 px-2 rounded-md border border-border text-sm text-right"
/>
</View>
);
}
function App() {
const primary = signal("oklch(54.6% 0.215 262.9)");
return (
<ColorPicker value={primary()} format="oklch" onChange={({ formatted }) => primary.set(formatted)} class="gap-3">
<ColorArea accessibilityLabel="Saturation and brightness" class="h-40" panelClass="rounded-lg" thumbClass={`size-[18px] ${thumb}`} fillThumb />
<Channel channel="lightness" letter="L" />
<Channel channel="chroma" letter="C" />
<Channel channel="hue" letter="H" />
<View class="flex-row items-center gap-2">
<ColorSwatch class="size-7 rounded-md" />
<Text class="font-mono text-sm">{`--primary: ${primary()}`}</Text>
</View>
</ColorPicker>
);
}
mount(App);ColorPicker, ColorArea, ColorSlider, ColorField, ColorSwatch y ColorEyeDropper provienen de nsh/base, el crate nsh-base de Rust. En Rust, color_picker() y las funciones de sus partes los crean con un setter por prop (.channel(..), .thumb_style(..)), un método de escucha por evento (.on_change(..)) y los métodos de estilo tipados; el color es un Hsla, y los espacios, canales y formatos son ColorSpace, ColorChannel y ColorFormat.
#Valor
El color es un color CSS: hex (#3b82f6, #3b82f680 con su alfa), rgb(), hsl(), hsb(), oklch() o un nombre CSS; en Rust, un Hsla. La app lo posee con value y establece el que informa onChange, como en cada control de nsh/base; o bien deja el color en manos del picker con defaultValue y aun así recibe los cambios.
onChange({ value, formatted })informa de cada color que pide el usuario: en cada paso de un arrastre sobre el área o un slider, con cada tecla, con el número de un campo a medida que se escribe, al pulsar un swatch.valuees el color,formattedel mismo color escrito como indicaformat("hex","rgb","hsl","hsb"o"oklch"), listo para guardar o para usar como token.onChangeEnd({ value, formatted })informa una sola vez del color en el que terminó el cambio: cuando se levanta el puntero, después de cada tecla, cuando se sale de un campo o se envía, cuando se pulsa un swatch.- El picker conserva el color en cada espacio tal como lo fijó el usuario. Un gris conserva su matiz y su saturación, de modo que el slider de matiz se queda donde el usuario lo dejó; un color editado en OKLCH conserva su luminosidad, croma y matiz tal como se escribieron, también más allá de lo que muestra sRGB (el color informado es el más cercano que muestra sRGB, y
formatteden"oklch"conserva los canales). - Un
valueque establece la app y que el picker no informó reemplaza el color en todos los espacios.
#Espacios y canales
colorSpace |
Canales (channel) |
Rangos |
|---|---|---|
"rgb" |
"red", "green", "blue" |
0 a 255 |
"hsl" |
"hue", "saturation", "lightness" |
matiz 0 a 360, los demás 0 a 100 |
"hsb" |
"hue", "saturation", "brightness" |
matiz 0 a 360, los demás 0 a 100 |
"oklch" |
"lightness", "chroma", "hue" |
luminosidad 0 a 100 (de 0.1 en 0.1), croma 0 a 0.4 (de 0.001 en 0.001), matiz 0 a 360 (de 0.1 en 0.1) |
| cualquiera | "alpha" |
0 a 100 |
La pista de un slider dibuja los colores de su canal manteniendo fijos los demás canales, de modo que muestra adónde lleva cada valor. Un canal que su espacio no tiene se sustituye por el primero del espacio (una advertencia lo indica).
#Props
#ColorPicker
| Prop | Rust | Tipo | Predeterminado | Descripción |
|---|---|---|---|---|
value |
.value(..) |
string |
ninguno | El color (controlado). La app establece el que informa onChange. |
defaultValue |
.default_value(..) |
string |
"#000000" |
El color inicial mientras value no esté establecido; el picker conserva entonces el suyo. |
format |
.format(..) |
"hex" | "rgb" | "hsl" | "hsb" | "oklch" |
"hex" |
Cómo escribe el color formatted. |
disabled |
.disabled(..) |
boolean |
false |
Sin cambios desde ninguna parte; las clases disabled: se aplican al picker y a sus partes. |
children |
.child(..) |
JSX.Element |
ninguno | Las partes y los elementos de la app entre ellas. |
class, style |
métodos de estilo | string, StyleProp<ViewStyle> |
ninguno | El propio picker: la columna (o fila) que rodea sus partes. |
#ColorArea
| Prop | Rust | Tipo | Predeterminado | Descripción |
|---|---|---|---|---|
panelClass, panelStyle |
.panel_style(|s| ..) |
string, StyleProp<ViewStyle> |
ninguno | El panel, que llena el área: los colores del matiz, con la saturación de izquierda a derecha (de derecha a izquierda en un diseño de derecha a izquierda) y el brillo de abajo hacia arriba, recortado en sus esquinas. |
thumbClass, thumbStyle |
.thumb_style(|s| ..) |
string, StyleProp<ViewStyle> |
ninguno | El thumb, centrado en el color. |
fillThumb |
.fill_thumb(..) |
boolean |
false |
El fondo del thumb es el color. |
disabled |
.disabled(..) |
boolean |
false |
Sin cambios por puntero, teclado ni lector de pantalla. |
accessibilityLabel |
.accessibility_label(..) |
string |
ninguno | Lo que dicen los lectores de pantalla para el thumb. |
class, style |
métodos de estilo | string, StyleProp<ViewStyle> |
ninguno | El área: su tamaño lo define la app. |
#ColorSlider
| Prop | Rust | Tipo | Predeterminado | Descripción |
|---|---|---|---|---|
colorSpace |
.color_space(..) |
"rgb" | "hsl" | "hsb" | "oklch" |
"hsb" |
El espacio del canal. |
channel |
.channel(..) |
ColorChannel |
"hue" |
El canal que cambia: uno de los del espacio, o "alpha". |
axis |
.axis(..) |
"horizontal" | "vertical" |
"horizontal" |
La dirección de la pista: el valor más pequeño al inicio (la izquierda, la derecha en un diseño de derecha a izquierda), o abajo. Se lee cuando se crea el slider. |
trackClass, trackStyle |
.track_style(|s| ..) |
string, StyleProp<ViewStyle> |
ninguno | La pista: los colores del canal a lo largo de ella, recortados en sus esquinas; el thumb se coloca sobre ella. |
thumbClass, thumbStyle |
.thumb_style(|s| ..) |
string, StyleProp<ViewStyle> |
ninguno | El thumb, centrado en el valor. |
fillThumb |
.fill_thumb(..) |
boolean |
false |
El fondo del thumb es el color (con su alfa en un slider de alfa). |
checkerboardColor |
.checkerboard_color(..) |
string |
ninguno | El color de un tablero de ajedrez bajo la pista, que se transparenta a través de los colores de un slider de alfa. |
checkerboardSize |
.checkerboard_size(..) |
number |
4 |
El lado de los cuadros del tablero. |
disabled |
.disabled(..) |
boolean |
false |
Sin cambios por puntero, teclado ni lector de pantalla. |
accessibilityLabel |
.accessibility_label(..) |
string |
ninguno | Lo que dicen los lectores de pantalla para el thumb. |
class, style |
métodos de estilo | string, StyleProp<ViewStyle> |
ninguno | El slider: la banda alrededor de la pista que recibe las pulsaciones y los arrastres. |
#ColorField
| Prop | Rust | Tipo | Predeterminado | Descripción |
|---|---|---|---|---|
channel |
.channel(..) |
ColorChannel |
ninguno | El canal que toma como número. Sin ella, el campo toma el color completo como texto. |
colorSpace |
.color_space(..) |
ColorSpace |
"hsb" |
El espacio de channel. |
format |
.format(..) |
ColorFormat |
"hex" |
Cómo escribe el campo el color completo. Lee cualquier forma CSS. |
placeholder |
.placeholder(..) |
string |
ninguno | Se muestra mientras el campo está vacío. |
disabled |
.disabled(..) |
boolean |
false |
Sin escritura. |
accessibilityLabel |
.accessibility_label(..) |
string |
ninguno | Lo que dicen los lectores de pantalla para el campo. |
class, style |
métodos de estilo | string, StyleProp<TextStyle> |
ninguno | El campo, un input de texto. |
El campo de un canal cambia el color a medida que se escribe un número, limitado al rango del canal; el campo del color completo lo cambia cuando se sale del campo o se envía, si el texto se lee como un color. Al salir de un campo, su valor se vuelve a escribir en su formato.
#ColorSwatch
| Prop | Rust | Tipo | Predeterminado | Descripción |
|---|---|---|---|---|
color |
.color(..) |
string |
ninguno | El color que muestra; una pulsación lo elige en el picker que lo rodea. Sin ella, el color del propio picker: una vista previa. |
selectedStyle |
.selected_style(..) |
StyleProp<ViewStyle> |
ninguno | El estilo mientras es el color del picker, como clases selected:. |
checkerboardColor |
.checkerboard_color(..) |
string |
ninguno | Un tablero de ajedrez bajo un color translúcido. |
checkerboardSize |
.checkerboard_size(..) |
number |
4 |
El lado de sus cuadros. |
disabled |
.disabled(..) |
boolean |
false |
Sin pulsaciones. |
accessibilityLabel |
.accessibility_label(..) |
string |
el color en hex | Lo que dicen los lectores de pantalla. |
children |
.child(..) |
JSX.Element |
ninguno | Contenido sobre el color: una marca de verificación en el swatch seleccionado (group-selected:). |
class, style |
métodos de estilo | string, StyleProp<ViewStyle> |
ninguno | El swatch: su tamaño, forma y borde; sus esquinas recortan el color. |
#ColorEyeDropper
| Prop | Rust | Tipo | Predeterminado | Descripción |
|---|---|---|---|---|
disabled |
.disabled(..) |
boolean |
false |
Sin pulsaciones. También está deshabilitado donde la plataforma no tiene cuentagotas. |
accessibilityLabel |
.accessibility_label(..) |
string |
ninguno | Lo que dicen los lectores de pantalla. |
children |
.child(..) |
JSX.Element |
ninguno | Su contenido: el icono o la etiqueta de la app. |
class, style |
métodos de estilo | string, StyleProp<ViewStyle> |
ninguno | El botón. |
Una pulsación muestra el cuentagotas del sistema: el color sampler de AppKit en macOS, el EyeDropper del navegador en la web (Chrome y Edge). El color elegido pasa a ser el del picker (onChange, luego onChangeEnd); Escape no elige ninguno. Donde la plataforma no tiene cuentagotas el botón está deshabilitado, y disabled:hidden lo oculta allí.
#Eventos
| Evento | De | Rust | Carga | Cuándo |
|---|---|---|---|---|
onChange |
ColorPicker |
.on_change(|e: &ColorPickerEvent| ..) |
({ value: string, formatted: string }) |
El usuario cambió el color. |
onChangeEnd |
ColorPicker |
.on_change_end(|e: &ColorPickerEvent| ..) |
({ value: string, formatted: string }) |
El cambio terminó. Una vez por cambio. |
onPress |
ColorSwatch |
.on_press(|color: &Hsla| ..) |
({ color: string }) |
Se pulsó el swatch; funciona también solo, como chip de una paleta. |
onPick |
ColorEyeDropper |
.on_pick(|color: &Hsla| ..) |
({ color: string }) |
El usuario eligió un color en la pantalla; funciona también solo. |
En Rust, ColorPickerEvent::Change(Hsla, SharedString) y ColorPickerEvent::ChangeEnd(Hsla, SharedString) llevan el color y su texto formatted.
#Partes
| Parte | Dentro de | Estilizada con |
|---|---|---|
ColorArea |
el picker | class, style |
| El panel del área | el área, llenándola | panelClass, panelStyle |
| El thumb del área | el área, centrado en el color | thumbClass, thumbStyle |
ColorSlider |
el picker | class, style |
| La pista del slider | el slider | trackClass, trackStyle |
| El thumb del slider | el slider, centrado en el valor sobre la pista | thumbClass, thumbStyle |
ColorField |
el picker | class, style |
ColorSwatch |
el picker, o solo | class, style, selectedStyle |
ColorEyeDropper |
el picker, o solo | class, style |
El thumb de un slider se centra en los extremos de la pista cuando el canal está en sus extremos. Un padding en el slider de la mitad del ancho del thumb (h-6 justify-center px-2.5 alrededor de una pista h-3) mantiene todo el thumb dentro del slider, donde recibe las pulsaciones en ambos extremos. Estados que muestran las partes: el active: de un thumb mientras se arrastra y focus: con el foco del teclado, el selected: de un swatch y el disabled: de cada parte.
#Teclado
En macOS y en la web cada thumb es una parada del foco del teclado (Tab):
| Tecla | En un slider | En el área |
|---|---|---|
| Derecha, Arriba | Un paso hacia arriba | Derecha: más saturación; Arriba: más brillo |
| Izquierda, Abajo | Un paso hacia abajo | Izquierda: menos saturación; Abajo: menos brillo |
| Mayús con una flecha | Diez pasos | Diez pasos |
| Page Up, Page Down | Diez pasos | Diez pasos de brillo |
| Home, End | Los extremos del canal |
En un diseño de derecha a izquierda, un slider horizontal y el área corren desde la derecha, con el valor más pequeño (la menor saturación) en el extremo derecho: Izquierda es un paso hacia arriba y Derecha un paso hacia abajo, y una pulsación o un arrastre elige el valor que hay bajo el puntero tal como está dibujado. Un slider vertical mantiene Derecha y Arriba como un paso hacia arriba; Arriba y Abajo en el área mantienen el brillo.
Un swatch acepta Espacio y Retorno. Cada tecla informa onChange y luego onChangeEnd; las flechas se repiten mientras se mantienen pulsadas.
#Accesibilidad
- El thumb del área y el de cada slider son elementos ajustables (
"adjustable", un slider en el navegador) con su etiqueta, valor y rango; el incremento y el decremento de los lectores de pantalla los mueven paso a paso. - Cada campo es un campo de texto con su etiqueta; cada swatch, un botón, seleccionado mientras es el color del picker, nombrado por su color en hex a menos que
accessibilityLabelle dé nombre. El cuentagotas es un botón con la etiqueta de la app. - Mientras están deshabilitadas, los lectores de pantalla indican que las partes están atenuadas, y no aceptan acciones.
Consulta Accesibilidad para ver etiquetas, roles y acciones en general.
#Notas de plataforma
- Cada plataforma dibuja lo que la app estiliza; los colores de las pistas y del panel son degradados dibujados por la plataforma. Las pulsaciones y los arrastres se siguen en el núcleo, para un ratón, un lápiz o un dedo, y una scroll view alrededor del picker limita su desplazamiento a los arrastres que empiezan en otro lugar.
ColorEyeDropperelige con el color sampler de AppKit en macOS y con elEyeDropperdel navegador en la web; el color llega en sRGB.