PlColorPicker
A colour, chosen by eye. A saturation square with a hue rail beside it. The arrangement every design tool has settled on, because it puts every colour of a hue within one movement of the pointer.
import { PlColorPicker } from 'plass-ui';
<PlColorPicker label="Project colour" value={color} onValueChange={setColor} />;import 'package:plass_ui/plass_ui.dart';
PlColorPicker(
label: const Text('Project colour'),
value: colour,
onValueChanged: (String next) => setState(() => colour = next),
);Props
| Prop | Type | Default | Description |
|---|---|---|---|
| variantshared | 'solid' | 'glass' | 'ghost' | 'glass' | What the trigger is made of. The same shell every other field in a form wears |
| sizeshared | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'md' | The trigger's height, the panel's width, and the size of the square and the rails |
| colorshared | 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info' | 'primary' | Semantic colour role. Arbitrary colour values are not accepted |
| densityshared | 'default' | 'compact' | 'default' | Padding only — never the height, never the type scale |
| elevationshared | 0 | 1 | 2 | 3 | 0 | Drop shadow depth. 0 means no shadow at all |
| value | string | — | The colour, as a CSS string. Pass it to drive the picker yourself |
| defaultValue | string | '#1a58d1' | Where an uncontrolled picker starts |
| onValueChange | (value: string) => void | — | Called with the new colour, written in format |
| format | 'hex' | 'rgb' | 'hsl' | 'hex' | Which notation the value is written in on the way out |
| alpha | boolean | false | Offers an opacity rail, and lets the value carry a fourth channel |
| swatches | readonly string[] | false | — | The ready-made colours under the panel. false draws none; an array replaces the built-in set |
| inline | boolean | false | Draws the panel in the page instead of in a popup, with no trigger |
| editable | boolean | true | The field under the panel that the value can be typed into |
| label | ReactNode | — | Label above the control |
| description | ReactNode | — | Helper text below it |
| error | ReactNode | — | Error message below. Its presence also turns the control invalid |
| invalid | boolean | — | The same state, without a message |
| required | boolean | false | Marks the label as required |
| disabled | boolean | false | Unusable and out of the tab order |
| readOnly | boolean | false | Shows the colour and forbids changing it |
| fullWidth | boolean | false | Stretches the trigger to its container |
| clearable | boolean | false | Offers the × that empties the control |
| name | string | — | Submits with a form under this name |
| open | boolean | — | Whether the popup is open |
| defaultOpen | boolean | false | Where an uncontrolled popup starts |
| onOpenChange | (open: boolean) => void | — | Called when the popup opens or closes |
| labels | Partial<PlColorPickerLabels> | — | Overrides for the accessible names of the parts that have no text on them |
| Prop | Type | Default | Description |
|---|---|---|---|
| variantshared | PlassVariant | PlassVariant.glass | What the trigger is made of. The same shell every other field in a form wears |
| sizeshared | PlassSize | PlassSize.md | The trigger's height, the panel's width, and the size of the square and the rails |
| colorshared | PlassColor | PlassColor.primary | Semantic colour role. Arbitrary colour values are not accepted |
| densityshared | PlassDensity | PlassDensity.standard | Padding only — never the height, never the type scale |
| elevationshared | int | 0 | Drop shadow depth. 0 means no shadow at all |
| value | String? | — | The colour, as a CSS string. null starts the picker on its own blue |
| onValueChanged | ValueChanged<String>? | — | Called with the new colour, written in format |
| format | PlColorFormat | PlColorFormat.hex | Which notation the value is written in on the way out |
| alpha | bool | false | Offers an opacity rail, and lets the value carry a fourth channel |
| swatches | List<String> | defaultSwatches | The ready-made colours under the panel. An empty list draws none |
| inline | bool | false | Draws the panel in the page instead of in a popup, with no trigger |
| editable | bool | true | The field under the panel that the value can be typed into |
| label | Widget? | — | Label above the control |
| description | Widget? | — | Helper text below it |
| error | Widget? | — | Error message below. Its presence also turns the control invalid |
| invalid | bool? | — | The same state, without a message |
| disabled | bool | false | Unusable and out of the tab order |
| readOnly | bool | false | Shows the colour and forbids changing it |
| fullWidth | bool | false | Stretches the trigger to its container |
| clearable | bool | false | Offers the × that empties the control |
| labels | PlColorPickerLabels | const PlColorPickerLabels() | Overrides for the accessible names of the parts that have no text on them |
Every native <div> attribute passes straight through to the wrapper. color is excluded because it is a Plass prop here (the family the control lights up in, not the colour it is holding), and defaultValue / onChange because the picker spells them as a value and an onValueChange.
What the shared axes mean across the library is in prop conventions.
HSV is the model, and it never leaves
The panel's state is a hue, a saturation and a value. The string is derived from it, and never the other way round.
That is not a preference. Through RGB, every shade of black is the same colour (#000000 has no hue to read back), so a picker that re-parsed its own output would snap the hue rail to red the moment the pointer reached the bottom of the square. Keeping the model is what keeps the rail still.
An incoming value re-seeds the model only when it means something different, and "different" is compared as a colour rather than as a string: #FF0000 and #ff0000 are the same colour written twice, and a string comparison would re-seed the model from a value it had just produced, on every render, forever.
Examples
inline
Draws the panel in the page with no trigger, for a sidebar or a settings pane where the colour is the thing being edited rather than one field among ten.
format
Which notation the value is written in on the way out: hex, rgb or hsl.
All three drop their alpha when the colour is opaque, a caller who never turned alpha on should never see rgba(…, 1) come out of a control they only used three channels of.
alpha
Adds a third rail and lets the value carry a fourth channel. The rail is drawn over a chequerboard, and the chequer is four linear stops at 45° rather than two conic gradients: a conic chequer has a seam down the middle of every tile at a fractional device pixel ratio.
swatches
The handful of colours a product actually uses, one click away. Pass an array to replace the built-in set, or false to draw none.
The built-in set is a plain spectrum plus the greys, and it is deliberately not the library's own six families: those are semantic roles, and a picker is asked for a colour rather than for a meaning.
A chosen swatch is ticked in black or white, decided by relative luminance. A fixed white tick disappears on yellow, and lightness alone puts it the wrong way round on green.
readOnly · disabled · error
error turns the control invalid, which re-points the whole colour family at danger, the edge, the ring and the message turn over together. invalid does the same without a message.
A readOnly picker shows its colour and takes nothing: the rails keep their values and lose their tab stops. A disabled one leaves the tab order.
There is no colour library under this
The conversions are internal/color.ts, HSV, RGB and HSL, one parser and one formatter, about a hundred lines of arithmetic with no trigonometry in it. That is the whole reason a component that computes colours ships without a dependency that does.
What it reads: hex in all four lengths, and rgb()/rgba()/hsl()/hsla() in both the comma and the space syntax. What it deliberately does not: named colours and color(). A picker has to be able to write every value it can read, and there is no honest way back from rebeccapurple to a point on the panel.
Differences from the React build
| React | Flutter | Why |
|---|---|---|
value / defaultValue | value, nullable | null is "the picker's own blue"; the caller owns the string from the first change onwards, as it does for every other field in this package. |
swatches: false | swatches: [] | An empty list is the same statement without a second type. |
open / defaultOpen / onOpenChange | — | The popup is the picker's own; there is no route guard shape here that needs to hold it. |
name, the hidden input | — | There is no native form submission to be part of. |
| a chequer of four linear gradients | a painter | A CustomPainter has no seam to avoid and no tiling to fight. |
labels as a partial | PlColorPickerLabels, a class with defaults | Dart names its optional fields; a partial of a record is not a thing it has. |
className, style | — | There is no class list and no style attribute to pass through. |
Accessibility
- The square and each rail are real
sliders witharia-valuenow, moved by the arrow keys, one step, or ten with Shift, which is the same pair every slider in the library uses. - The square reports both of its channels:
aria-valuenowis the saturation andaria-valuetextis"saturation%, brightness%", because one number cannot describe a point on a plane. - The hue rail wraps rather than stopping: a step back from red is 358°, not 0°. The wheel is a circle and the rail is a picture of one.
- A key the picker does not answer to is left alone, so Tab moves on rather than being swallowed by a gradient.
- Every swatch is a real
<button>named by its own colour, witharia-pressedon the chosen one. labelsrenames any of the parts that have no text on them. They are all named by default, in English.- A drag takes pointer capture on the element, so a pointer that leaves the panel mid-drag keeps changing the colour rather than dropping it.