GrColorPicker
Picks a color for theme and branding settings: hue, saturation, lightness and opacity sliders, a hex field and a palette.
Machine-translated from the Russian original, not yet reviewed. Read the original
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 |
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.
Why sliders and not a square
The familiar 2D saturation/value area is a widget of its own, with its own gestures
and a keyboard along two axes, and accessibility in it has to be built from
scratch. The channels here are ordinary GrSlider elements, that is, a real
role="slider" with a full keyboard, aria-valuetext and aria-label from the
locale. The price is one gesture more; the gain is a working keyboard and a working
screen reader.
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 eyedropper —
EyeDropperis not available in every browser and requires a permission of its own; - 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.
Playground 11
Loading…
<GrColorPicker />Install
npm i @feugene/granularityImport
import { GrColorPicker } from '@feugene/granularity/components/GrColorPicker'API
Props
| Prop | Type | default | Description |
|---|---|---|---|
open | boolean | undefined | undefined | The controlled state of the panel (`v-model:open`). |
disabled | boolean | undefined | false | — |
readonly | boolean | undefined | false | Read-only: the colour is visible, the panel opens, but the value does not change. |
invalid | boolean | undefined | false | — |
required | boolean | undefined | false | — |
size | "xs" | "sm" | "md" | "lg" | undefined | undefined | — |
ariaLabel | string | undefined | undefined | — |
name | string | undefined | undefined | The name for a native form: the value leaves in a hidden field. |
placement | "bottom-start" | "bottom-end" | "top-start" | "top-end" | undefined | "bottom-start" | The side the panel opens from. |
alpha | boolean | undefined | false | A fourth slider and the eight-digit form of hex. |
presets | string[] | undefined | [] | A palette for quick picking. If it is empty, the block is not rendered. |
modelValuerequired | string | — | The colour in hex: `#RRGGBB`, and with `alpha` — `#RRGGBBAA`. Garbage does not crash the component. |
Events
| Event | Type | Description |
|---|---|---|
update:modelValue | [value: string] | — |
change | [value: string] | — |
update:open | [value: boolean] | — |
focus | [event: FocusEvent] | — |
blur | [event: FocusEvent] | — |
Methods / Expose
| Methods / Expose | Type | Description |
|---|---|---|
focus | () => void | — |
blur | () => void | — |
Examples 2
Brand and overlay colors
#3b82f6#0f172acc<script setup lang="ts">
import { ref } from 'vue'
import { GrColorPicker } from '@feugene/granularity'
const brand = ref('#3b82f6')
const overlay = ref('#0f172acc')
const presets = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#0ea5e9', '#64748b', '#0f172a']
</script>
<template>
<div class="grid gap-4 sm:grid-cols-[minmax(0,18rem)_minmax(0,1fr)]">
<div class="grid gap-3">
<div class="grid gap-1.5">
<span class="text-sm text-[var(--gr-muted-fg)]">Brand color</span>
<GrColorPicker v-model="brand" :presets="presets" aria-label="Brand color" />
</div>
<div class="grid gap-1.5">
<span class="text-sm text-[var(--gr-muted-fg)]">Overlay color</span>
<GrColorPicker v-model="overlay" alpha :presets="presets" aria-label="Overlay color" />
</div>
</div>
<div class="grid content-center gap-3 rounded-2xl border border-[var(--gr-brd)] bg-[var(--gr-card)] p-5">
<span class="text-sm text-[var(--gr-muted-fg)]">Preview</span>
<div class="h-10 rounded-[var(--gr-radius-md)] border border-[var(--gr-brd)]" :style="{ background: brand }" />
<code class="text-xs text-[var(--gr-muted-fg)]">{{ brand }}</code>
<div class="h-10 rounded-[var(--gr-radius-md)] border border-[var(--gr-brd)]" :style="{ background: overlay }" />
<code class="text-xs text-[var(--gr-muted-fg)]">{{ overlay }}</code>
</div>
</div>
</template>Inside a form field
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { GrButton, GrColorPicker, GrForm, GrFormField, GrInput, type GrFormRules } from '@feugene/granularity'
const model = reactive({ name: '', accent: '#22c55e' })
const rules: GrFormRules = {
name: [{ required: true }],
accent: [{ required: true, pattern: /^#[0-9a-f]{6}$/i, message: 'Only a six-digit hex is allowed' }],
}
const saved = ref('')
</script>
<template>
<GrForm :model="model" :rules="rules" class="grid max-w-sm gap-4" @submit="saved = model.accent">
<GrFormField name="name" label="Theme name">
<GrInput v-model="model.name" placeholder="Midnight" />
</GrFormField>
<GrFormField name="accent" label="Accent" hint="Goes to the --gr-primary token">
<GrColorPicker v-model="model.accent" name="accent" />
</GrFormField>
<GrButton type="submit" class="w-fit">
Save theme
</GrButton>
<p v-if="saved" class="text-sm text-[var(--gr-success-text)]">
Saved: {{ saved }}
</p>
</GrForm>
</template>Accessibility
- APG pattern
dialog + slider