GrColorPicker
Picks a color for theme and branding settings: hue, saturation, lightness and opacity sliders, a hex field and a palette.
Overview
When to take it
- the colour is set by the user — the theme of a brand, the label of a project, the colour of a category;
- an exact code is needed — a hex field beside the channels: a colour is more often brought from a mock-up than picked by eye;
- there is a house set —
presetsshows the palette people choose from in nine cases out of ten; - transparency is needed —
alphaadds a channel and changes the format of the value.
When to take something else
| Need | Take |
|---|---|
| A choice among several fixed colours | GrRadioGroup / GrSegmented |
| The colour is a component tone from the theme palette | the tone prop of the component in question |
| The value is an arbitrary string | GrInput |
Variants every tone and view, rendered by the real component
view
slidersarea
Sizes
xssmmdlg
States
normaldisabledreadonlyinvalid
The grid is live: hover and press it. Hover, focus and active states are not drawn here — they belong to the browser, not to a prop.
Playground controls come from the API, not from hand
Loading…
0 changed· state is in the URL
<GrColorPicker>Label</GrColorPicker>Examples 3 each with a live demo and its code
A brand colour that must stay readable
The value is a hex string — the form CSS, tokens and the server all read.
view="area"gives the saturation × lightness square,presetsthe brand palette,eyedroppera pipette where the browser has one, andnamesends the value with a native form. White text needs 4.5:1, so a colour that fails isinvalidand the line under it says why, while the preview shows where the colour lands.BrandingShown on invoices, payment pages and emails White text 5.5:1 — readableAcme BillingInvoice INV-0418Amount due€1,284.50Label colours in a list
A small
pillpicker fits a list row, andplacement="bottom-end"opens the panel inwards from the right edge. The system label isreadonly: the panel opens and shows the colour, but the value does not change.LabelsBilling web · project board - Bug · systemRead only
- Design
- Backend
- Waiting on customer
- Bug · system
An overlay with transparency
alphaadds the fourth slider and makes the value#RRGGBBAA, which CSS reads as is — the photo shows through the overlay and the headline stays readable. Presets carry their own transparency.Sign-in pageOverlay over the background photo Welcome back#0f172abf
API generated from the package · cannot drift
Props
| Prop | Type | default | Description |
|---|---|---|---|
placement | "bottom-start" | "bottom-end" | "top-start" | "top-end" | undefined | "bottom-start" | The side the panel opens from. |
disabled | boolean | undefined | false | The field is unavailable: the trigger is disabled, the panel does not open. Also switched on by `disabled` of `GrFormField`. |
openv-model | boolean | undefined | undefined | The controlled state of the panel (`v-model:open`). |
readonly | boolean | undefined | false | Read-only: the colour is visible, the panel opens, but the value does not change. |
invalid | boolean | undefined | false | The error state: a border in the error colour and `aria-invalid`. Also switched on by an error of `GrFormField`. |
required | boolean | undefined | false | A required field. `aria-required` is not valid on a trigger button, so the requirement goes into its description for a screen reader. |
size | "xs" | "sm" | "md" | "lg" | undefined | undefined | The height of the trigger and the scale of the panel on the control scale. Not set — from `GrConfigProvider`, otherwise `md`. |
ariaLabel | string | undefined | undefined | The accessible name of the trigger outside `GrFormField` — when there is no visible label nearby. |
name | string | undefined | undefined | The name for a native form: the value leaves in a hidden field. |
shape | "box" | "pill" | undefined | undefined | The shape of the border. `box` — the rounding of the controls scale; `pill` — a pill. |
alpha | boolean | undefined | false | A fourth slider and the eight-digit form of hex. |
view | "sliders" | "area" | undefined | undefined | How the saturation and lightness are set: with two sliders or with a square. The hue and the opacity stay sliders in both views. |
eyedropper | boolean | undefined | undefined | The eyedropper button. Drawn only where the browser provides `EyeDropper` — today that is Chromium. |
presets | string[] | undefined | [] | A palette for quick picking. If it is empty, the block is not rendered. |
modelValuerequiredv-model | string | — | The colour in hex: `#RRGGBB`, and with `alpha` — `#RRGGBBAA`. Garbage does not crash the component. |
Events
| Event | Signature | Description |
|---|---|---|
update:modelValue | (event: "update:modelValue", value: string): void | — |
change | (event: "change", value: string): void | — |
update:open | (event: "update:open", value: boolean): void | — |
focus | (event: "focus", event: FocusEvent): void | — |
blur | (event: "blur", event: FocusEvent): void | — |
Methods
| Methods | Signature | Description |
|---|---|---|
focus | () => void | Focus on the trigger button from code. |
blur | () => void | Remove focus from the trigger button. |
Types
Every exported type of the subpath, grouped by the file it lives in. Names, generics and bodies come from the package’s declaration files and cannot drift.
import type { GrColorPickerConfigurableProps, GrColorPickerProps, GrColorPickerEmits } from '@feugene/granularity/components/GrColorPicker'Filedist/types/components/GrColorPicker/config.d.ts
const grColorPickerConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrColorPicker">;Filedist/types/components/GrColorPicker/defaults.d.ts
interface GrColorPickerConfigurableProps {
size: GrColorPickerSize;
shape: GrControlShape;
view: GrColorPickerView;
eyedropper: boolean;
}Filedist/types/components/GrColorPicker/GrColorPicker.vue.d.ts
interface GrColorPickerProps {
modelValue: string;
alpha?: boolean;
view?: GrColorPickerView;
eyedropper?: boolean;
presets?: string[];
size?: GrColorPickerSize;
shape?: GrControlShape;
open?: boolean;
placement?: 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end';
name?: string;
disabled?: boolean;
readonly?: boolean;
invalid?: boolean;
required?: boolean;
ariaLabel?: string;
}interface GrColorPickerEmits {
(e: 'update:modelValue', value: string): void;
(e: 'change', value: string): void;
(e: 'update:open', value: boolean): void;
(e: 'focus', event: FocusEvent): void;
(e: 'blur', event: FocusEvent): void;
}Filedist/types/components/GrColorPicker/grColorPickerStyles.d.ts
type GrColorPickerSize = GrComponentSize;Filedist/types/components/GrColorPicker/color.d.ts
type GrHsla = {
h: number;
s: number;
l: number;
a: number;
};Filedist/types/components/GrColorPicker/safelist.d.ts
const grColorPickerSafelist: string[];Filedist/types/components/GrColorPicker/index.d.ts
type GrColorPickerInstance = ComponentExposed<typeof GrColorPickerComponent>;Accessibility
APG pattern
dialog + slider
Keys
EscTab↑ / ↓
Full contract of the package: docs/keyboard.md ↗
Usage from the library prose
The model is a hex string
modelValue is #RRGGBB, and with alpha it is #RRGGBBAA. That is the form in
which a colour lies in the theme tokens, arrives from the backend and is understood
by CSS: the consumer has to convert neither on the way in nor on the way out.
An invalid value does not crash the component and emits nothing: the panel shows
#000000 and the model stays as it is. The component has no right to rewrite
someone else’s data silently.
Internally the colour lives in HSLA and separately from the model, because hex is a lossy projection: black, white and any grey have no hue. Were the component to keep its state in hex alone, the hue slider would jump to 0° every time the user takes the saturation down to zero.
The channels are stored as fractions in the process. The integer HSL grid is coarser
than the hex grid, and snapping to it changes the colour: of twelve typical brand
values eleven would come back different — #10b981 as #10b77f. Integers are
needed by the scales, and the scales get them: a slider reads the rounding of its
channel, while the stored value stays exact. That is why a selected preset, a pasted
hex and a colour taken with the eyedropper come back exactly the same.
Two views of the panel
view decides what sets the saturation and the lightness. The hue and the
transparency are sliders in both views.
view | What is in the panel |
|---|---|
sliders (the default) | a ladder of channels: H, S, L and A — each a GrSlider |
area | a saturation × lightness square instead of S and L |
The default stayed as it was on purpose: the view changes the look of panels already
standing in applications, and that decision belongs to the application rather than
to the package. It is also switched globally —
<GrConfigProvider :component-defaults="{ GrColorPicker: { view: 'area' } }">.
The square is voiced, not only drawn
The usual trouble with a 2D area is that it is a mouse thing: it has neither values
a screen reader would read nor keys. Here inside the square there are two real
input[type=range], one per axis: they are visually hidden but stay in the tab
order with their names and values, and the focus is shown by the area itself through
focus-within. A screen reader sees two sliders — “Saturation 90 %” and
“Lightness 66 %” — rather than a nameless picture.
Hence the two Tab stops per square: there are two axes, as there are two thumbs in
a GrSlider range. The keys — ../keyboard.md.
The fill of the square is exact, not approximate
The square is assembled from three layers: a saturation scale at 50 % lightness and a pair of veils, white on top and black at the bottom. This is not a device “by eye”: lightening and darkening in HSL are linear interpolation towards white and towards black, exactly what blending with alpha does. The colour under the handle matches the hex to the last digit, and that is verified by a pixel in a live browser.
The axes are saturation and lightness rather than the saturation and value familiar from HSV. Converting to HSV would give a prettier fill but would bring a second lossy projection: at zero value the HSV saturation is undefined, and black would lose it the way grey loses its hue.
The eyedropper
eyedropper adds a button beside the hex field. It is drawn only where the
browser provides EyeDropper — today that is Chromium; elsewhere it is absent
altogether rather than present and not working. A refusal by the user (Esc during
the pick) changes nothing and reports nothing: it is not an error.
What arrives from the screen is an already blended colour, with no transparency of its own — the current one stays as it was.
The panel is non-modal
Tab out of the panel moves the focus further along the page rather than trapping
it inside: nothing behind the page is blocked, and there is nothing to trap the user
for. Esc closes the panel and returns the focus to the trigger — that is done by
the shared layer stack through GrPopover.
The opening can be controlled from the outside: v-model:open, and the position with
placement.
The presets
presets is an array of hex strings. Invalid ones are filtered out, and the selected
preset gets aria-pressed. If it is empty, there is no presets block at all.
Theme tokens are not substituted into presets: --gr-primary lives as a CSS
variable and is not resolved into hex at build time. The palette is assembled by the
application — from its own config or from getComputedStyle.
The form
The whole form-control contract: disabled, readonly, invalid, required,
ariaLabel, the update:modelValue/change/focus/blur emits, the exported
focus()/blur(). The name comes from GrFormField (<label for> points at the
trigger) or from ariaLabel.
name gives the current value to a native form through a hidden field — there must
be no interactive elements inside the widget.
There is no clearable on purpose: a colour has no empty state. If “not set” is
needed, that is undefined in the model at the level of the application, not a
state of the control.
Limits
- there is no history of recent colours — that is the state of the application;
- there are no gradients — the component is about a single colour.
Props in this example