Skip to content

Add a color picker component - #42774

Open
mdo wants to merge 5 commits into
v6-devfrom
6.1/color-picker
Open

Add a color picker component#42774
mdo wants to merge 5 commits into
v6-devfrom
6.1/color-picker

Conversation

@mdo

@mdo mdo commented Jul 30, 2026

Copy link
Copy Markdown
Member

Adds a color picker that decorates a real <input> instead of replacing it, so the platform keeps ownership of the form semantics.

  • New ColorPicker plugin: attaches to a text input, composes Menu for the panel, and paints a color chip inside the field. name, value, required, disabled, readonly, labels, form.reset(), FormData, and constraint validation all keep working as they normally do
  • New js/src/util/color.ts: OKLCH-first parsing, conversion, formatting, gamut clamping, and CSS custom property resolution, with no new dependency. contrastColor() mirrors the Sass color-contrast() logic so JS and CSS agree on a foreground
  • New scss/forms/_color-picker.scss: tokenized styles for the field chip, panel, chroma and lightness plane, hue and alpha sliders, and the preset swatch grid
  • --range-track-bg-image hook on the range-track() mixin so a consumer can replace the track fill with its own ramp instead of reimplementing the thumb and track. The color picker's hue and alpha sliders are plain .form-range elements through it
  • Four supported arrangements: a bare input, an opt-in .color-picker wrapper with a focusable preview button, a custom trigger via data-bs-toggle="color-picker" plus data-bs-target, and input[type="color"] as a degraded mode
  • Swatch presets from the theme colors, the full palette, a <datalist>, or a custom array
  • Uses the EyeDropper API where the browser supports it
  • Docs page, sidebar entry, and the eyedropper icon in the symbol sprite

Output is switchable between hex, RGB, HSL, and OKLCH, and the picker infers the initial format from the value already in the field.

Native input and change follow the range contract: input streams during a drag, change fires once the interaction settles.

Notes for reviewers:

  • The panel is inserted next to the field rather than in body, which keeps tab order and inherits the surrounding color mode. container is the escape hatch for a clipping ancestor
  • input[type="color"] forces hex and disables alpha, since the alpha and colorspace attributes are still Safari-only
  • The swatch and preview backgrounds are composed on the elements carrying the per-swatch color, not in the token map. var() inside a custom property is substituted on the element that declares it, so composing on an ancestor flattens the color layer to transparent before it inherits down
  • The --menu-* panel overrides live in the components layer with a specificity bump over .menu. That layer outranks forms, and this file is emitted before _menu.scss within it, so both are needed
  • dist/ is intentionally not included

Verified across light and dark modes: 122 new unit tests, plus area drag, hue and alpha sliders, format switching, swatch selection, keyboard navigation, Escape and outside-click dismissal, and the wrapper trigger.

mdo added 5 commits July 30, 2026 12:41
The color picker needs to move between the formats people type and the OKLCH
model the v6 palette is built on, without taking on a dependency. Parsing,
gamut clamping, and CSS custom property resolution live here so the component
only has to deal with colors.

contrastColor() mirrors the Sass color-contrast() logic, including the minimum
contrast ratio, so JS and CSS pick the same foreground for a given swatch.
The range track hardcoded its fill gradient, so anything wanting a different
ramp had to reimplement the thumb and track from scratch. --range-track-bg-image
overrides just the image and falls back to the existing fill, which is what lets
the color picker paint a hue wheel and an alpha gradient through .form-range.
Tokenized styles for the field chip, the panel, the chroma and lightness plane,
the hue and alpha sliders, and the preset swatch grid.

The inline chip is a background layer rather than generated content, because
::before and ::after do not render on form controls. It reuses the same recipe
.form-control uses for the select caret, so the field is styled before JS runs.

The swatch and preview backgrounds are composed on the elements that carry the
per-swatch color. var() inside a custom property is substituted on the element
that declares it, so composing them on an ancestor would resolve the color layer
to transparent once and inherit it down already flattened.

The panel reshaping lives in the components layer with a specificity bump over
.menu. That layer outranks forms, and this file is emitted before _menu.scss
within it, so the --menu-* overrides need both to take effect.
ColorPicker decorates a real input rather than replacing it, so name, value,
required, disabled, readonly, labels, form.reset(), FormData, and constraint
validation all come from the platform instead of being reimplemented. It matches
how Datepicker, OtpInput, Strength, and Chips already work.

Menu supplies the panel, which means Floating UI handles placement and the
existing auto-close behavior applies. The panel is inserted next to the field by
default so it keeps tab order and inherits the surrounding color mode, with
container as the escape hatch for clipping ancestors.

Native input and change events follow the range contract: input streams during a
drag, change fires once the interaction settles. The commit guard covers the
dispatches rather than just the assignment, otherwise the component's own input
listener re-parses its rounded output and quantizes the color mid-drag.

input[type="color"] is supported as a degraded mode. The alpha and colorspace
attributes are still Safari-only, so it forces hex, disables alpha, and
suppresses the native OS picker.
Adds the docs page and sidebar entry, the eyedropper icon to the symbol sprite,
and the util/color description the auto-generated Dependencies table needs.
@mdo
mdo requested review from a team as code owners July 30, 2026 18:43
Comment thread js/src/util/color.ts

// A transparent default lets us detect a rejected `fillStyle` assignment
context.clearRect(0, 0, 1, 1)
context.fillStyle = '#000'
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants