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

needs-review
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

PropTypeDefaultWhat it does
labelReactNodenoneVisible label; the swatch button is named by it and the current hex.
valuestringnoneControlled hex colour. Pair it with onValueChange.
defaultValuestring"#000000"Uncontrolled starting hex colour.
onValueChange(value: string) => voidnoneCalled with a lowercase hex string whenever the colour changes.
presetsstring[]noneHex colours offered as one-press swatches in the panel.
withAlphabooleanfalseAdds an opacity strip; the value gains two alpha digits when it is not fully opaque.
namestringnoneSubmits a hidden input holding the hex value.
disabledbooleanfalseDisables the swatch button.
...restComponentProps<"div">noneEvery native div attribute, including id, className, style and ref, on the wrapper.

Anatomy

root.eq-color-fieldCarries the current colour and the anchor name as inline custom properties.
label.eq-color-field-label
trigger.eq-color-field-triggerThe swatch button that opens the panel.
swatch.eq-color-field-swatch
value.eq-color-field-valueThe hex in the trigger, in monospace.
panel.eq-color-field-panelA popover dialog anchored under the trigger.
area.eq-color-field-areaTwo-dimensional slider: saturation across, brightness up.
strip.eq-color-field-stripHue or opacity slider, told apart by data-strip.
thumb.eq-color-field-thumb
hex.eq-color-field-hexText input; a valid hex applies as you type.
presets.eq-color-field-presets
preset.eq-color-field-presetToggle button, pressed when it matches the colour.

Keyboard

EnterSpaceOpens the panel from the swatch button and moves focus to the shade area.
EscapeCloses the panel and returns focus to the swatch button.
ArrowLeftArrowRightIn the area, changes saturation by 1%; on a strip, changes hue by 1 degree or opacity by 1%.
ArrowUpArrowDownIn the area, changes brightness by 1%; on a strip, the same as left and right.
Shift+ArrowMoves by 10 instead of 1.
PageUpPageDownChanges brightness, hue or opacity by 10.
HomeEndSets 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;
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index