Skip to content

Components: Preserve ColorPicker hue when saturation picker reaches black/white#75157

Closed
JosVelasco wants to merge 5 commits intoWordPress:trunkfrom
JosVelasco:fix/colorpicker-hue-resets-at-black
Closed

Components: Preserve ColorPicker hue when saturation picker reaches black/white#75157
JosVelasco wants to merge 5 commits intoWordPress:trunkfrom
JosVelasco:fix/colorpicker-hue-resets-at-black

Conversation

@JosVelasco
Copy link
Contributor

@JosVelasco JosVelasco commented Feb 3, 2026

What?

Fixes the hue slider jumping to red when dragging the saturation picker to black, white, or gray areas.

Why?

When dragging to color-space extremes like black (v=0), white (s=0, v=100), or gray (s=0), the hue becomes mathematically undefined. The previous implementation used RgbStringColorPicker, which serialized the color as an RGB string (e.g. "rgb(0,0,0)"). Since RGB carries zero hue information for achromatic colors, every re-render fed h: 0 back into react-colorful, causing the hue slider to snap to red.

This is a well-known problem in color science and has been solved by every major color picker — Figma, Photoshop, react-colorful's own examples, MyPaint (mypaint/mypaint#838), and react-color's oldHue pattern (casesandberg/react-color#29).

How?

Switches from RgbStringColorPicker to HsvColorPicker/HsvaColorPicker — HSVA is react-colorful's native internal model. Because the saturation panel works directly in HSVA, there is no intermediate conversion that could lose precision or reset the hue. This also eliminates the thumb jitter that an HSL-based approach would introduce (HSL→hex→HSL round-trips lose floating-point precision, causing the thumb to shift on re-render).

A useRef preserves the last meaningful hue for the one remaining edge case: when the parent re-renders with a hex-derived achromatic color (e.g. #000000hsv(0, 0%, 0%)), the ref substitutes the last known chromatic hue instead of 0.

A second pair of refs (lastHsvRef / lastHexRef) caches the most recent HSV↔hex pair. When the parent re-renders with the same hex we last emitted, the cached HSV is returned directly — bypassing the lossy hex→HSV conversion and preventing precision drift during dragging.

This is the standard approach — HSVA as the internal model, where each UI control only mutates its own channel. The hue is structurally isolated from saturation/value changes. The alternative (caching oldHue) is what react-color uses but is more fragile and harder to maintain.

Changes: ~50 lines in a single file (picker.tsx), no API changes. Added unit tests for hue preservation and a changelog entry.

Testing Instructions

  1. Create a new post/page
  2. Add a Cover block with a solid color overlay
  3. Open the custom color picker
  4. Enter #3366FF (blue) in the hex input
  5. Drag the saturation picker to the bottom-left corner (black)
  6. Continue dragging up along the left edge toward white
  7. Drag back out to a colored area
  8. Verify: The hue slider stays blue throughout and the color returns as blue

Also test with:

  • #38CC38 (green) to confirm hue preservation works for other hues
  • Paragraph block text color
  • Button block background color
  • Typing achromatic values directly in hex input (#000000, #FFFFFF, #999999) then dragging the saturation picker
  • Storybook: drag the saturation thumb smoothly — no jitter or jumping to (0,0,0)
    • Dragging outside the picker area during a drag gesture (pointer capture test)

Before / After

https://github.com/user-attachments/assets/b9c89a9d-bb9b-40a0-bdc8-7ba1b2ffdd9a
https://github.com/user-attachments/assets/137c5f7e-1f60-470a-bef5-e6be5c5f5ddb

Fixes #47504

AI assistance: Yes
Tool(s): Claude (Anthropic)
Used for: Researching how other editors solve this problem (Figma, Photoshop, react-color, react-colorful, MyPaint), identifying HSVA as the standard solution, debugging the HSL jitter issue, and implementation. Final code was reviewed, tested, and validated by me.

@JosVelasco JosVelasco requested a review from ajitbohra as a code owner February 3, 2026 00:54
@github-actions
Copy link

github-actions bot commented Feb 3, 2026

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

Unlinked Accounts

The following contributors have not linked their GitHub and WordPress.org accounts: @yllip.

Contributors, please read how to link your accounts to ensure your work is properly credited in WordPress releases.

If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message.

Unlinked contributors: yllip.

Co-authored-by: JosVelasco <josvelasco@git.wordpress.org>
Co-authored-by: ciampo <mciampini@git.wordpress.org>
Co-authored-by: stokesman <presstoke@git.wordpress.org>
Co-authored-by: Mamaduka <mamaduka@git.wordpress.org>
Co-authored-by: t-hamano <wildworks@git.wordpress.org>
Co-authored-by: jeflopodev <jeflopodev@git.wordpress.org>
Co-authored-by: hanneslsm <hanneslsm@git.wordpress.org>

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@github-actions github-actions bot added the [Package] Components /packages/components label Feb 3, 2026
@JosVelasco
Copy link
Contributor Author

I've found some issues with other blocks, like the heading block. I'll convert this to draft so I can keep working.

@JosVelasco JosVelasco marked this pull request as draft February 3, 2026 02:05
@JosVelasco JosVelasco force-pushed the fix/colorpicker-hue-resets-at-black branch 2 times, most recently from df82bb1 to e91ab3f Compare February 4, 2026 23:45
@JosVelasco JosVelasco marked this pull request as ready for review February 5, 2026 00:33
@JosVelasco
Copy link
Contributor Author

Could you take a look at this proposal? @t-hamano

I think this issue is unrelated:
#75223

@JosVelasco JosVelasco force-pushed the fix/colorpicker-hue-resets-at-black branch from e91ab3f to 34b260e Compare February 7, 2026 17:04
@JosVelasco
Copy link
Contributor Author

Updated the approach: instead of caching oldHue, I switched from RgbStringColorPicker to HslColorPicker so hue is passed as a discrete channel and never lost during RGB serialization. A useRef handles the remaining edge case for achromatic colors from hex round-tripping. This is the same pattern used by Figma, Photoshop, and React-Colorful's own examples. Single file change, no API changes. Ready for review! @t-hamano

@Mamaduka Mamaduka added the [Type] Bug An existing feature does not function as intended label Feb 9, 2026
@Mamaduka Mamaduka requested review from a team and stokesman February 9, 2026 09:02
@Mamaduka
Copy link
Member

Mamaduka commented Feb 9, 2026

Thanks, @JosVelasco!

Do you mind adding the changelog entry as requested by CI?

I also wonder if it's related to #75243.

Copy link
Contributor

@ciampo ciampo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for working on this. Apart from the feedback left in the comments, after a first look at the Storybook examples (where the component is tested in isolation), I noticed that the thumb shows the selected color:

  • generally jitters while dragging it on the picker
  • occasionally jumps to the bottom left corner (0, 0, 0)
Kapture.2026-02-09.at.13.28.34.mp4

I assume this is caused by the RGB => HSL conversion, since I cannot reproduce on trunk.

…lack/white

Switch the internal color picker from RgbStringColorPicker to HslColorPicker
so that hue is passed as a discrete channel rather than being discarded during
RGB string serialization. A ref preserves the last meaningful hue for achromatic
edge cases (black, white, gray) where hex round-tripping reports hue as 0.

This is the standard approach used by professional color pickers (Figma,
Photoshop, react-colorful) where HSLA serves as the internal model and
each UI control only mutates its own channel.
@JosVelasco JosVelasco force-pushed the fix/colorpicker-hue-resets-at-black branch from 34b260e to 673fe8b Compare February 9, 2026 23:08
@stokesman
Copy link
Contributor

Thank you for the PR @JosVelasco. I’d love to see this issue fixed. As noted by Aki #47504 (comment), there are some conditions that avoid the issue and that has me curious if there’s some other way to resolve this.

I tinkered around a bit and found one that seems to work – use the non-string picker components from react-colorful. It would seem to make more sense given the incoming color value is already an object. I wondered how that came about and found #35670 where the picker components were changed. I’ll need to test more and make sure the issues that PR addressed do not resurface but I thought it worth a mention.

@JosVelasco
Copy link
Contributor Author

Thanks for looking into this and for the pointer to #35670! The latest revision actually landed in the same place — I switched from RgbStringColorPicker to HsvColorPicker/HsvaColorPicker. Using HSVA (react-colorful's native internal model) seems to eliminate both the hue reset and the thumb jitter that an HSL-based approach introduced. Since the saturation panel works directly in HSVA, there's no intermediate conversion that could lose precision or reset the hue.
I also added unit tests for hue preservation and a changelog entry. I relied heavily on AI assistance for this, so I'd really appreciate a thorough review! @stokesman

Copy link
Contributor

@ciampo ciampo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can confirm that the new version of the code doesn't cause the jitter anymore. Also, unit test are overall good, and I tested that they would fail with the code from trunk.

I left some additional feedback, but we're getting there

JosVelasco and others added 2 commits February 10, 2026 11:00
Co-authored-by: Marco Ciampini <marco.ciampo@gmail.com>
Copy link
Contributor

@stokesman stokesman left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In my testing this works well and the approach makes sense.

With regards to the unit tests, I want to note that they don’t cover the particular interaction that is first highlighted in #47504. Granted, that seems impossible without e2e tests. Still, the added testing of the hex input makes sense. Though I’d rather reduce it down to a single test:

Combine hue preservation tests into one
diff --git a/packages/components/src/color-picker/test/index.tsx b/packages/components/src/color-picker/test/index.tsx
index fbcd75b8b7..3e5e8bf587 100644
--- a/packages/components/src/color-picker/test/index.tsx
+++ b/packages/components/src/color-picker/test/index.tsx
@@ -421,7 +421,7 @@ describe( 'ColorPicker', () => {
 	} );
 
 	describe( 'Hue preservation', () => {
-		it( 'should preserve the picker hue when the color becomes black', async () => {
+		it( 'should preserve the picker hue when the color becomes achromatic', async () => {
 			const user = userEvent.setup();
 
 			render(
@@ -439,73 +439,17 @@ describe( 'ColorPicker', () => {
 			// Blue (#3366ff) has a hue of 225°.
 			expect( pickerHueSlider ).toHaveValue( 225 );
 
-			// Change color to black via hex input.
 			const hexInput = screen.getByRole( 'textbox' );
-			await user.clear( hexInput );
-			await user.type( hexInput, '000000' );
-
-			// The picker hue should still be 225° (blue), not 0° (red).
-			await waitFor( () => {
-				expect( pickerHueSlider ).toHaveValue( 225 );
-			} );
-		} );
-
-		it( 'should preserve the picker hue when the color becomes white', async () => {
-			const user = userEvent.setup();
-
-			render(
-				<ControlledColorPicker
-					onChange={ jest.fn() }
-					enableAlpha={ false }
-					initialColor="#38cc38"
-				/>
-			);
-
-			const pickerHueSlider = screen.getByRole( 'slider', {
-				name: 'Hue',
-			} );
-
-			// Green (#38cc38) has a hue of 120°.
-			expect( pickerHueSlider ).toHaveValue( 120 );
-
-			// Change color to white via hex input.
-			const hexInput = screen.getByRole( 'textbox' );
-			await user.clear( hexInput );
-			await user.type( hexInput, 'ffffff' );
-
-			// The picker hue should still be 120° (green), not 0° (red).
-			await waitFor( () => {
-				expect( pickerHueSlider ).toHaveValue( 120 );
-			} );
-		} );
-
-		it( 'should preserve the picker hue when the color becomes gray', async () => {
-			const user = userEvent.setup();
-
-			render(
-				<ControlledColorPicker
-					onChange={ jest.fn() }
-					enableAlpha={ false }
-					initialColor="#0000ff"
-				/>
-			);
-
-			const pickerHueSlider = screen.getByRole( 'slider', {
-				name: 'Hue',
-			} );
-
-			// Pure blue (#0000ff) has a hue of 240°.
-			expect( pickerHueSlider ).toHaveValue( 240 );
-
-			// Change color to gray via hex input.
-			const hexInput = screen.getByRole( 'textbox' );
-			await user.clear( hexInput );
-			await user.type( hexInput, '999' );
-
-			// The picker hue should still be 240° (blue), not 0° (red).
-			await waitFor( () => {
-				expect( pickerHueSlider ).toHaveValue( 240 );
-			} );
+			for await ( const achromaticColor of [ '000', 'fff', '999' ] ) {
+				// Change color via hex input.
+				await user.clear( hexInput );
+				await user.type( hexInput, achromaticColor );
+
+				// The picker hue should still be 225° (blue), not 0° (red).
+				await waitFor( () => {
+					expect( pickerHueSlider ).toHaveValue( 225 );
+				} );
+			}
 		} );
 	} );
 } );

I’d defer to Marco or others opinion on that though.

Finally I’ll mention that it’d be nice if the remaining similar issue with the HSL inputs could be fixed by integrating with the newly added logic in some way. Yet I suppose that’d be fine to tackle separately. Here’s a quick screen recording of what I’m referring to:

hsl-hue-loss.mp4

Copy link
Contributor

@ciampo ciampo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM 🚀

Let's unify the newly added unit tests into one test, as suggested by @stokesman, and then we can merge this PR.

@JosVelasco , would you also be up for looking into a follow-up PR to fix the related issue that @stokesman pointed out?

@github-actions
Copy link

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

Unlinked Accounts

The following contributors have not linked their GitHub and WordPress.org accounts: @yllip.

Contributors, please read how to link your accounts to ensure your work is properly credited in WordPress releases.

If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message.

Unlinked contributors: yllip.

Co-authored-by: JosVelasco <josvelasco@git.wordpress.org>
Co-authored-by: ciampo <mciampini@git.wordpress.org>
Co-authored-by: stokesman <presstoke@git.wordpress.org>
Co-authored-by: Mamaduka <mamaduka@git.wordpress.org>
Co-authored-by: t-hamano <wildworks@git.wordpress.org>
Co-authored-by: jeflopodev <jeflopodev@git.wordpress.org>
Co-authored-by: hanneslsm <hanneslsm@git.wordpress.org>

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@JosVelasco
Copy link
Contributor Author

Consolidated the tests into a single test, as suggested — thanks, @stokesman!
And yes, @ciampo, happy to look into the HSL inputs issue as a follow-up PR.

@JosVelasco
Copy link
Contributor Author

To fix the HSL inputs issue, I think we'd need a full refactor, so this PR could wait. This is what I have so far, but I haven't started/tested yet:

component.tsx: Keep internal HSLA state as the single source of truth (authoritative H/S).
picker.tsx: Switch to HslStringColorPicker / HslaStringColorPicker.
hsl-input.tsx: Remove internal state entirely — parent manages everything.
color-input.tsx: Route hsla to HSL input, Colord to RGB/hex.
types.ts: Update interfaces to match the new data flow.

@stokesman @ciampo

@JosVelasco
Copy link
Contributor Author

Here is the new PR draft: #75493

@JosVelasco
Copy link
Contributor Author

Closing in favour of #75493, which takes a more comprehensive approach by lifting HSLA state to the parent component and preserving both hue and saturation (not just hue) through achromatic colors.

@JosVelasco JosVelasco closed this Feb 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Package] Components /packages/components [Type] Bug An existing feature does not function as intended

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The hue jumps to red when leaving the colour picker area

4 participants