Inputs
Colour field
Picks a colour from a floating panel with a shade area, hue and opacity strips, a hex input and presets.
- Built on
Popover APICSS anchor positioningPointer capture@starting-stylein hsl longer hue- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 5.3 kB
- Dependencies
- React
Try its options
Loading its options.
Code
import { ColorField } from "@/components/equip/color-field";
export default function Demo() {
return (
<ColorField
label="Brand colour"
defaultValue="#ff6a2b"
presets={["#ff6a2b", "#2347f5", "#0f7b4f", "#875300", "#b91c3a", "#1b1813"]}
/>
);
}Your versionchange its options in Look, This part
Examples
With opacity
The value gains two alpha digits only when the colour is not fully opaque.
Code
import { ColorField } from "@/components/equip/color-field";
export default function Example() {
return <ColorField label="Overlay tint" defaultValue="#1b181380" withAlpha name="overlay" />;
}Controlled, with a live preview
Code
"use client";
import { useState } from "react";
import { ColorField } from "@/components/equip/color-field";
export default function Example() {
const [label, setLabel] = useState("#0f7b4f");
return (
<div style={{ display: "flex", alignItems: "end", gap: 16 }}>
<ColorField
label="Label colour"
value={label}
onValueChange={setLabel}
presets={["#0f7b4f", "#2347f5", "#b91c3a", "#875300"]}
/>
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
height: 22,
padding: "0 8px",
borderRadius: 5,
border: `1px solid ${label}`,
color: "var(--eq-ink)",
fontSize: 12,
fontWeight: 650,
}}
>
<span aria-hidden="true" style={{ width: 8, height: 8, borderRadius: "50%", background: label }} />
needs-review
</span>
</div>
);
}Disabled
Code
import { ColorField } from "@/components/equip/color-field";
export default function Example() {
return <ColorField label="Theme colour (locked by your plan)" defaultValue="#2347f5" disabled />;
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
label | ReactNode | none | Visible label; the swatch button is named by it and the current hex. |
value | string | none | Controlled hex colour. Pair it with onValueChange. |
defaultValue | string | "#000000" | Uncontrolled starting hex colour. |
onValueChange | (value: string) => void | none | Called with a lowercase hex string whenever the colour changes. |
presets | string[] | none | Hex colours offered as one-press swatches in the panel. |
withAlpha | boolean | false | Adds an opacity strip; the value gains two alpha digits when it is not fully opaque. |
name | string | none | Submits a hidden input holding the hex value. |
disabled | boolean | false | Disables the swatch button. |
...rest | ComponentProps<"div"> | none | Every native div attribute, including id, className, style and ref, on the wrapper. |
Anatomy
| root | .eq-color-field | Carries the current colour and the anchor name as inline custom properties. |
|---|---|---|
| label | .eq-color-field-label | |
| trigger | .eq-color-field-trigger | The swatch button that opens the panel. |
| swatch | .eq-color-field-swatch | |
| value | .eq-color-field-value | The hex in the trigger, in monospace. |
| panel | .eq-color-field-panel | A popover dialog anchored under the trigger. |
| area | .eq-color-field-area | Two-dimensional slider: saturation across, brightness up. |
| strip | .eq-color-field-strip | Hue or opacity slider, told apart by data-strip. |
| thumb | .eq-color-field-thumb | |
| hex | .eq-color-field-hex | Text input; a valid hex applies as you type. |
| presets | .eq-color-field-presets | |
| preset | .eq-color-field-preset | Toggle button, pressed when it matches the colour. |
Keyboard
| EnterSpace | Opens the panel from the swatch button and moves focus to the shade area. |
|---|---|
| Escape | Closes the panel and returns focus to the swatch button. |
| ArrowLeftArrowRight | In the area, changes saturation by 1%; on a strip, changes hue by 1 degree or opacity by 1%. |
| ArrowUpArrowDown | In the area, changes brightness by 1%; on a strip, the same as left and right. |
| Shift+Arrow | Moves by 10 instead of 1. |
| PageUpPageDown | Changes brightness, hue or opacity by 10. |
| HomeEnd | Sets saturation, hue or opacity to its lowest or highest. |
Notes
- No native colour dialog is used, so the picker looks and behaves the same everywhere and stays keyboard reachable.
- The shade area is one slider with a two-axis valuetext, such as Saturation 80%, brightness 60%.
- Colours are held as hue, saturation and brightness, so the hue survives a trip through grey or black.
- Thumbs spring to a clicked point, then follow the pointer exactly while dragged.
- Clicking outside the panel or pressing Escape closes it, as any auto popover does.
- All colour values are inline custom properties set by the part; its CSS holds no literal colours.
- CSS anchor positioning is not in every engine; without it the panel is placed under the swatch once as it opens and does not follow scrolling. Hue interpolation in hsl longer hue needs a 2023 or later engine; older ones show a plain strip.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/color-field.tsx338 lines
"use client";
import { useId, useRef, useState } from "react";
import type { ComponentProps, CSSProperties, KeyboardEvent, PointerEvent, ReactNode, ToggleEvent } from "react";
import "./color-field.css";
type Hsva = { h: number; s: number; v: number; a: number };
type Drag = "area" | "hue" | "alpha";
export type ColorFieldProps = Omit<ComponentProps<"div">, "defaultValue" | "onChange" | "children"> & {
label: ReactNode;
/** Hex colour, such as "#ff6a2b", or "#ff6a2b80" with alpha. */
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
/** Hex colours offered as one-press swatches. */
presets?: string[];
/** Adds an opacity strip; the value gains two alpha digits when it is not fully opaque. */
withAlpha?: boolean;
/** Submits a hidden input holding the hex value. */
name?: string;
disabled?: boolean;
};
const HUE_STRIP = "linear-gradient(to right in hsl longer hue, hsl(0 100% 50%), hsl(360 100% 50%))";
const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n));
const byte = (n: number) => Math.round(clamp(n, 0, 1) * 255).toString(16).padStart(2, "0");
function parseHex(input: string): [number, number, number, number] | null {
const raw = input.trim().replace(/^#/, "");
if (!/^([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(raw)) return null;
const full = raw.length <= 4 ? [...raw].map((c) => c + c).join("") : raw;
const at = (i: number) => parseInt(full.slice(i, i + 2), 16) / 255;
return [at(0), at(2), at(4), full.length === 8 ? at(6) : 1];
}
/** Keeps the previous hue and saturation where a colour has none (greys and black), so thumbs do not jump. */
function toHsva(hex: string, prev: Hsva): Hsva | null {
const rgba = parseHex(hex);
if (!rgba) return null;
const [r, g, b, a] = rgba;
const max = Math.max(r, g, b);
const d = max - Math.min(r, g, b);
let h = prev.h;
if (d > 0) {
if (max === r) h = ((g - b) / d + 6) % 6;
else if (max === g) h = (b - r) / d + 2;
else h = (r - g) / d + 4;
h *= 60;
}
return { h, s: max === 0 ? prev.s : d / max, v: max, a };
}
function toRgb({ h, s, v }: Hsva): [number, number, number] {
const f = (n: number) => {
const k = (n + h / 60) % 6;
return v - v * s * Math.max(0, Math.min(k, 4 - k, 1));
};
return [f(5), f(3), f(1)];
}
function toHex(color: Hsva, withAlpha: boolean) {
const hex = `#${toRgb(color).map(byte).join("")}`;
return withAlpha && color.a < 1 ? hex + byte(color.a) : hex;
}
const START: Hsva = { h: 0, s: 0, v: 0, a: 1 };
export function ColorField({
label,
value,
defaultValue = "#000000",
onValueChange,
presets,
withAlpha = false,
name,
disabled = false,
className,
style,
...rest
}: ColorFieldProps) {
const id = useId();
const anchor = `--eq-color-field-${id.replace(/[^a-zA-Z0-9_-]/g, "")}`;
const triggerRef = useRef<HTMLButtonElement>(null);
const areaRef = useRef<HTMLDivElement>(null);
const [color, setColor] = useState(() => toHsva(value ?? defaultValue, START) ?? START);
const [seen, setSeen] = useState(value);
const [drag, setDrag] = useState<Drag | null>(null);
const [draft, setDraft] = useState<string | null>(null);
const hex = toHex(color, withAlpha);
if (value !== seen) {
setSeen(value);
const next = value === undefined ? null : toHsva(value, color);
if (next && toHex(next, withAlpha) !== hex) setColor(next);
}
const opaque = toHex({ ...color, a: 1 }, false);
const hueColor = `hsl(${Math.round(color.h)} 100% 50%)`;
function change(next: Hsva) {
setColor(next);
const nextHex = toHex(next, withAlpha);
if (nextHex !== hex) onValueChange?.(nextHex);
}
function pick(target: string) {
const next = toHsva(target, color);
if (next) change(withAlpha ? next : { ...next, a: 1 });
}
function pointAt(event: PointerEvent<HTMLDivElement>) {
const box = event.currentTarget.getBoundingClientRect();
return {
x: clamp((event.clientX - box.left) / box.width, 0, 1),
y: clamp((event.clientY - box.top) / box.height, 0, 1),
};
}
function moveTo(kind: Drag, x: number, y: number) {
if (kind === "area") change({ ...color, s: x, v: 1 - y });
else if (kind === "hue") change({ ...color, h: x * 360 });
else change({ ...color, a: x });
}
function pointerHandlers(kind: Drag) {
return {
onPointerDown(event: PointerEvent<HTMLDivElement>) {
if (event.button !== 0) return;
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
event.currentTarget.focus();
const { x, y } = pointAt(event);
moveTo(kind, x, y);
},
onPointerMove(event: PointerEvent<HTMLDivElement>) {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return;
// The first click springs the thumb over; from here on it follows the pointer exactly.
setDrag(kind);
const { x, y } = pointAt(event);
moveTo(kind, x, y);
},
onPointerUp: () => setDrag(null),
onPointerCancel: () => setDrag(null),
};
}
function keyStep(event: KeyboardEvent<HTMLDivElement>, kind: Drag) {
const step = event.shiftKey ? 10 : 1;
const up = ["ArrowRight", "ArrowUp", "PageUp"].includes(event.key);
const down = ["ArrowLeft", "ArrowDown", "PageDown"].includes(event.key);
const by = (event.key.startsWith("Page") ? 10 : step) * (up ? 1 : down ? -1 : 0);
if (kind === "area") {
const horizontal = event.key === "ArrowLeft" || event.key === "ArrowRight";
const s = horizontal ? color.s + by / 100 : color.s;
const v = !horizontal ? color.v + by / 100 : color.v;
if (event.key === "Home") return change({ ...color, s: 0 });
if (event.key === "End") return change({ ...color, s: 1 });
if (!up && !down) return false;
return change({ ...color, s: clamp(s, 0, 1), v: clamp(v, 0, 1) });
}
const [now, top] = kind === "hue" ? [color.h, 360] : [color.a * 100, 100];
let next: number;
if (event.key === "Home") next = 0;
else if (event.key === "End") next = top;
else if (up || down) next = clamp(Math.round(now) + by, 0, top);
else return false;
if (kind === "hue") change({ ...color, h: next });
else change({ ...color, a: next / 100 });
}
function handleKeyDown(kind: Drag) {
return (event: KeyboardEvent<HTMLDivElement>) => {
if (keyStep(event, kind) === false) return;
event.preventDefault();
};
}
function handleToggle(event: ToggleEvent<HTMLDivElement>) {
if (event.newState !== "open") return setDraft(null);
const panel = event.currentTarget;
// Without anchor positioning, place the panel under the swatch once, as it opens.
if (!CSS.supports("anchor-name: --a")) {
const box = triggerRef.current?.getBoundingClientRect();
if (box) {
panel.style.top = `${box.bottom + 4}px`;
panel.style.left = `${Math.max(8, Math.min(box.left, window.innerWidth - panel.offsetWidth - 8))}px`;
}
}
areaRef.current?.focus();
}
const percent = (n: number) => `${Math.round(n * 100)}%`;
const vars = {
...style,
"--_anchor": anchor,
"--_color": hex,
"--_opaque": opaque,
} as CSSProperties;
return (
<div
{...rest}
className={["eq-color-field", className].filter(Boolean).join(" ")}
style={vars}
data-disabled={disabled || undefined}
>
<label className="eq-color-field-label" id={`${id}-label`} htmlFor={`${id}-trigger`}>
{label}
</label>
<button
ref={triggerRef}
type="button"
id={`${id}-trigger`}
className="eq-color-field-trigger"
popoverTarget={`${id}-panel`}
aria-labelledby={`${id}-label ${id}-value`}
aria-haspopup="dialog"
disabled={disabled}
>
<span className="eq-color-field-swatch" aria-hidden="true" />
<span className="eq-color-field-value" id={`${id}-value`}>
{hex}
</span>
</button>
<div
id={`${id}-panel`}
className="eq-color-field-panel"
popover="auto"
role="dialog"
aria-labelledby={`${id}-label`}
onToggle={handleToggle}
>
<div
ref={areaRef}
className="eq-color-field-area"
role="slider"
tabIndex={0}
aria-label="Saturation and brightness"
aria-roledescription="2D slider"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(color.s * 100)}
aria-valuetext={`Saturation ${percent(color.s)}, brightness ${percent(color.v)}`}
data-dragging={drag === "area" || undefined}
style={{
backgroundImage: `linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, ${hueColor})`,
}}
onKeyDown={handleKeyDown("area")}
{...pointerHandlers("area")}
>
<span
className="eq-color-field-thumb"
style={{ left: `${color.s * 100}%`, top: `${(1 - color.v) * 100}%` }}
/>
</div>
<div
className="eq-color-field-strip"
role="slider"
tabIndex={0}
aria-label="Hue"
aria-valuemin={0}
aria-valuemax={360}
aria-valuenow={Math.round(color.h)}
aria-valuetext={`${Math.round(color.h)} degrees`}
data-strip="hue"
data-dragging={drag === "hue" || undefined}
style={{ backgroundImage: HUE_STRIP }}
onKeyDown={handleKeyDown("hue")}
{...pointerHandlers("hue")}
>
<span
className="eq-color-field-thumb"
style={{ left: `${(color.h / 360) * 100}%`, "--_color": hueColor } as CSSProperties}
/>
</div>
{withAlpha && (
<div
className="eq-color-field-strip"
role="slider"
tabIndex={0}
aria-label="Opacity"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(color.a * 100)}
aria-valuetext={percent(color.a)}
data-strip="alpha"
data-dragging={drag === "alpha" || undefined}
onKeyDown={handleKeyDown("alpha")}
{...pointerHandlers("alpha")}
>
<span className="eq-color-field-thumb" style={{ left: `${color.a * 100}%` }} />
</div>
)}
<input
className="eq-color-field-hex"
aria-label="Hex"
spellCheck={false}
autoComplete="off"
value={draft ?? hex}
aria-invalid={draft !== null && parseHex(draft) === null ? true : undefined}
onChange={(event) => {
const text = event.currentTarget.value;
setDraft(text);
if (parseHex(text)) pick(text);
}}
onBlur={() => setDraft(null)}
onKeyDown={(event) => {
if (event.key !== "Enter") return;
event.preventDefault();
setDraft(null);
}}
/>
{presets && presets.length > 0 && (
<div className="eq-color-field-presets" role="group" aria-label="Presets">
{presets.map((preset) => {
const parsed = toHsva(preset, color);
const presetHex = parsed ? toHex(withAlpha ? parsed : { ...parsed, a: 1 }, withAlpha) : preset;
return (
<button
key={preset}
type="button"
className="eq-color-field-preset"
aria-label={presetHex}
aria-pressed={presetHex === hex}
style={{ "--_color": presetHex } as CSSProperties}
onClick={() => pick(preset)}
/>
);
})}
</div>
)}
</div>
{name && <input type="hidden" name={name} value={hex} disabled={disabled} />}
</div>
);
}components/equip/color-field.css242 lines
@layer equip {
.eq-color-field {
/* Shows through wherever the colour is translucent. */
--_checker: repeating-conic-gradient(var(--eq-tint-strong) 0 25%, transparent 0 50%) 0 0 / 8px 8px;
display: grid;
justify-items: start;
gap: var(--eq-space-1);
min-width: 0;
}
.eq-color-field-label {
color: var(--eq-ink);
font: var(--eq-weight-strong) var(--eq-text-s) / 1.3 var(--eq-font);
}
.eq-color-field-trigger {
anchor-name: var(--_anchor);
display: inline-flex;
align-items: center;
gap: var(--eq-space-2);
height: var(--eq-h-m);
padding-inline: var(--eq-space-1) var(--eq-space-3);
border: 1px solid var(--eq-line-strong);
border-radius: var(--eq-radius-m);
background: var(--eq-raised);
color: var(--eq-ink);
cursor: pointer;
transition:
border-color var(--eq-dur) var(--eq-ease),
scale var(--eq-dur) var(--eq-ease);
}
@media (hover: hover) {
.eq-color-field-trigger:hover:not(:disabled) {
border-color: var(--eq-ink-3);
}
}
.eq-color-field-trigger:active:not(:disabled) {
scale: var(--eq-press);
}
.eq-color-field-trigger:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
.eq-color-field-swatch {
flex: none;
width: 24px;
height: 24px;
border-radius: var(--eq-radius-s);
background: linear-gradient(var(--_color), var(--_color)), var(--_checker);
box-shadow: inset 0 0 0 1px var(--eq-line);
transition: background-color var(--eq-dur) var(--eq-ease);
}
.eq-color-field-value {
font: var(--eq-weight) var(--eq-text-m) / 1 var(--eq-font-mono);
font-variation-settings: var(--eq-font-mono-settings);
font-variant-numeric: tabular-nums;
text-transform: lowercase;
}
.eq-color-field-panel {
position-anchor: var(--_anchor);
position-area: block-end span-inline-end;
position-try-fallbacks: flip-block, flip-inline;
inset: auto;
width: 232px;
box-sizing: border-box;
margin: var(--eq-space-1) 0 0;
padding: var(--eq-space-4);
border: 1px solid var(--eq-line-strong);
border-radius: var(--eq-radius-l);
background: var(--eq-raised);
color: var(--eq-ink);
box-shadow: var(--eq-shadow);
-webkit-backdrop-filter: var(--eq-blur);
backdrop-filter: var(--eq-blur);
opacity: 0;
scale: 0.96;
translate: 0 4px;
transform-origin: top left;
transition:
opacity var(--eq-dur) var(--eq-ease),
scale var(--eq-dur) var(--eq-ease),
translate var(--eq-dur) var(--eq-ease),
display var(--eq-dur) allow-discrete,
overlay var(--eq-dur) allow-discrete;
}
.eq-color-field-panel:popover-open {
display: grid;
gap: var(--eq-space-3);
opacity: 1;
scale: 1;
translate: 0 0;
transition:
opacity var(--eq-dur) var(--eq-ease),
scale var(--eq-dur-move) var(--eq-spring),
translate var(--eq-dur-move) var(--eq-spring),
display var(--eq-dur-move) allow-discrete,
overlay var(--eq-dur-move) allow-discrete;
}
@starting-style {
.eq-color-field-panel:popover-open {
opacity: 0;
scale: 0.96;
translate: 0 4px;
}
}
.eq-color-field-area,
.eq-color-field-strip {
position: relative;
border-radius: var(--eq-radius-s);
box-shadow: inset 0 0 0 1px var(--eq-line);
cursor: crosshair;
touch-action: none;
user-select: none;
}
.eq-color-field-area {
height: 132px;
}
.eq-color-field-strip {
height: 12px;
margin-inline: 6px;
cursor: pointer;
}
/* The strip is drawn 12px tall; this widens what a finger can grab to 24px. */
.eq-color-field-strip::before {
content: "";
position: absolute;
inset: -6px 0;
}
.eq-color-field-strip[data-strip="alpha"] {
background: linear-gradient(to right, transparent, var(--_opaque)), var(--_checker);
}
.eq-color-field-area:focus-visible,
.eq-color-field-strip:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
.eq-color-field-thumb {
position: absolute;
top: 50%;
box-sizing: border-box;
width: 16px;
height: 16px;
translate: -50% -50%;
border: 2px solid var(--eq-solid-ink);
border-radius: var(--eq-radius-full);
background: var(--_color);
box-shadow: 0 0 0 1px var(--eq-solid);
pointer-events: none;
transition:
left var(--eq-dur-move) var(--eq-spring),
top var(--eq-dur-move) var(--eq-spring),
scale var(--eq-dur-move) var(--eq-spring);
}
/* While dragging, the thumb follows the pointer exactly and grows a little under it. */
:is(.eq-color-field-area, .eq-color-field-strip)[data-dragging] .eq-color-field-thumb {
scale: 1.15;
transition: scale var(--eq-dur-move) var(--eq-spring);
}
.eq-color-field-hex {
height: var(--eq-h-m);
min-width: 0;
box-sizing: border-box;
padding-inline: var(--eq-space-3);
border: 1px solid var(--eq-line-strong);
border-radius: var(--eq-radius-m);
background: var(--eq-raised);
color: var(--eq-ink);
font: var(--eq-weight) var(--eq-text-m) / 1 var(--eq-font-mono);
font-variation-settings: var(--eq-font-mono-settings);
transition: border-color var(--eq-dur) var(--eq-ease);
}
.eq-color-field-hex:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: -1px;
}
.eq-color-field-hex[aria-invalid="true"] {
border-color: var(--eq-danger);
}
.eq-color-field-presets {
display: flex;
flex-wrap: wrap;
gap: var(--eq-space-1);
}
.eq-color-field-preset {
width: 24px;
height: 24px;
padding: 0;
border: 0;
border-radius: var(--eq-radius-s);
background: linear-gradient(var(--_color), var(--_color)), var(--_checker);
box-shadow: inset 0 0 0 1px var(--eq-line);
cursor: pointer;
transition:
box-shadow var(--eq-dur) var(--eq-ease),
scale var(--eq-dur) var(--eq-ease);
}
.eq-color-field-preset[aria-pressed="true"] {
box-shadow:
inset 0 0 0 2px var(--eq-solid),
inset 0 0 0 4px var(--eq-solid-ink);
}
.eq-color-field-preset:active {
scale: var(--eq-press);
}
.eq-color-field-preset:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
.eq-color-field[data-disabled] {
opacity: 0.45;
}
.eq-color-field-trigger:disabled {
cursor: not-allowed;
}
}