Color Converter, Palette Extractor and Gradient Builder: What Each One Changes Without Telling You
Three pages, one shared habit: they take what you type or upload and change it, without a warning anywhere on screen. Some of those changes are fine — rounding a lightness to a whole percent is a reasonable choice for a colour picker. Some will quietly hand you the wrong colour, and the page will look exactly as if it had not.
This guide is about the specific places where each page diverges from what you would expect, because those are the places where people lose an afternoon: the HEX field that is really a number parser, the palette that changes between two runs of the same file, the gradient that silently swallows a colour.
Every number here comes from the pages' own scripts, or from driving those scripts: 200,000 random colours through the converter, 2,037,576 slider positions enumerated, the extractor's stride arithmetic worked out for common photo sizes.
The converter has three boxes and three parsers
The colour converter keeps three fields in sync — HEX, RGB, HSL — plus three sliders. Typing into any one of them updates the other two and the preview. What is not visible is that each field is parsed by a completely different mechanism, with different tolerances:
| Field | Parser | What it accepts | What it rejects |
|---|---|---|---|
| HEX | parseInt on the string after stripping # |
1–6 hex digits, 3-digit shorthand, trailing garbage | anything whose first character is not a hex digit |
| RGB | regex rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\) |
integers in rgb() with commas |
decimals, negatives, %, space-separated syntax |
| HSL | regex hsl\s*\(\s*(\d+)\s*,\s*(\d+)%\s*,\s*(\d+)%\s*\) |
integers, with % on both of the last two |
missing %, decimals, negatives |
And one thing all three share: when a parse fails, nothing happens. No message, no red border, no clearing of the preview. The field keeps your text, the other two keep their previous values, and the swatch stays the colour it was. The most common way to be misled by this page is to type something invalid and read the stale colour as if it were your colour.
The HEX box parses a number, not a colour
The HEX field does not check that you gave it a colour. It strips a leading #, expands a 3-digit shorthand, and calls parseInt(str, 16). parseInt stops at the first character it cannot use and never complains about the rest. That produces a table of results worth knowing before you paste anything in:
| You type | The page reads | Why |
|---|---|---|
#3b8 |
#33BB88 |
3-digit shorthand is doubled, as CSS intends |
3B82F6 |
#3B82F6 |
the # is optional |
3B82F6ZZ |
#3B82F6 |
parseInt stops at Z and ignores the tail |
3B82 |
#003B82 |
four digits is a shorter number, so it is right-aligned into 24 bits |
3B82F |
#03B82F |
same shift, five digits |
12 |
#000012 |
two digits is a valid number |
#FF0000ff |
#0000FF |
the worst one: 8-digit hex-with-alpha is read from the wrong end |
G00000 |
ignored | first character is not a hex digit → NaN → no update |
That #FF0000ff row is the one to remember. Eight-digit hex is standard output from design tools and from CSS colour pickers that carry alpha, and this page takes the low 24 bits of it: FF0000ff becomes blue, not red. Same for 80ff00de, which comes out as #FF00DE — the first two digits fall off the left rather than being read as alpha. If you are pasting from Figma, Chrome DevTools, or anything that emits #RRGGBBAA, strip the last two characters yourself.
The RGB and HSL boxes are strict where HEX is loose
The two functional boxes use regexes that require exact shape. rgb(255 0 0) — the space-separated syntax CSS Color 4 allows — does not match. rgb(255,0,0) does. rgb(100%, 0%, 0%) does not. rgb(255.0, 0, 0) does not, because \d+ then expects a comma and finds a dot. HSL requires the % signs on saturation and lightness; hsl(217, 91, 60) is silently ignored.
A hue above 360 is the exception that works: the conversion divides by 360 and the helper wraps values outside [0,1], so hsl(500, 50%, 50%) is accepted and normalised to the equivalent 140°. The boxes are then rewritten from the resulting RGB, so you see the wrapped value back.
Out of range, the three boxes disagree
The regexes accept any integer, including ones no channel can hold. The page then applies three different rules to the same input:
- HEX clamps: each channel goes through
Math.max(0, Math.min(255, Math.round(x))). - RGB does not: the field is written back with the numbers you gave.
- HSL does not either, and the formula has no guard for it.
So rgb(999, 0, 0) leaves the RGB box reading rgb(999, 0, 0), sets the HEX box to #FF0000, and sets the HSL box to hsl(0, -204%, 196%) — a negative saturation and a lightness of 196%. rgb(300, 300, 300) reports hsl(0, 0%, 118%). Those HSL strings are not valid CSS, and if you copy one into a stylesheet the browser will reject it. The preview colour is the clamped one; the HSL text is arithmetic on an input that should have been refused.
The HSL round trip does not close
This is the fact most likely to bite, because it looks like a bug in the page and is actually a rounding choice. RGB→HSL rounds all three outputs to integers: Math.round(h × 360), Math.round(s × 100), Math.round(l × 100). Going back, HSL→RGB multiplies out and rounds again. Two roundings in a row do not give you your original number.
Measured over 200,000 random 8-bit colours, pushed through the page's own two functions:
| Colours that changed after HEX → HSL → HEX | 89.4% |
| Largest single-channel error observed | 5 (of 255) |
| Worst case found | rgb(5, 222, 213) → hsl(178, 96%, 45%) → rgb(5, 225, 218) |
The page's own default colour does not survive it either: #3B82F6 displays as hsl(217, 91%, 60%), and copying that HSL back into the HEX field yields #3C83F6 — 59,130,246 becomes 60,131,246. Two colour values that the same page produced one second apart, and they are not the same colour.
The practical rule: treat the HSL field as a reading, not as a source. If a colour has to be reproduced exactly, store the HEX. If you are tuning a design system by eye and want "the same blue but lighter", HSL is the right tool — just re-read the HEX afterwards and use that as the committed value.
Pure greys are a separate branch: when all three channels match, the code returns h: 0, s: 0 and a rounded lightness, and the reverse path short-circuits on s === 0 by setting all three channels to Math.round(l × 255). Greys are the one family that does round-trip cleanly.
The sliders reach about an eighth of the colour space
H is a range input from 0 to 360, S and L from 0 to 100, all at the default step of 1. Enumerating every position the three sliders can hold — 361 × 101 × 101 — and running each through the page's own HSL→RGB function gives 2,037,576 distinct colours, which is 12.1% of the 16,777,216 colours an 8-bit HEX can express.
That is the arithmetic behind a very familiar frustration: you drag a slider onto the colour you want, and it is not quite the colour you wanted. Sliders cannot land on most of the space, because the integers they hold are rounded before conversion. The text fields can: typing #3B82F6 gives exactly that colour. Use the sliders to explore, use the field to commit.
Presets, copy buttons, and what else the page keeps
The 24 preset swatches are a fixed list (#FF6B6B, #FFA94D, #FFD43B, #69DB7C, #38D9A9, #4DABF7, #748FFC, #9775FA, #F783AC, #ADB5BD, #495057, #000000, #FFFFFF, and eleven lighter variants). Clicking one runs it through the same HEX parser as the text box, so these always land.
Each field has its own copy button, and it copies the field's current string verbatim — which means the HEX you copy is always upper case, because the generator formats it that way. Clipboard writes fail silently here: the button shows a tick only on success, so if nothing changes, select the text and copy it yourself.
Nothing on this page persists. Reload and it returns to #3B82F6. There is no history, no local storage, no shareable URL — the colour is never written into the address bar, so you cannot send a link to "the colour I have on screen". Copy the HEX instead.
The extractor: five filters between your photo and the palette
The palette extractor runs entirely in the browser — the file is read with FileReader and never leaves your machine — but between the image you uploaded and the swatches you see there are five places where colours get dropped.
- Format gate. Only
image/png,image/jpegandimage/webpare accepted; anything else gets a toast and no analysis. Theacceptattribute pre-filters the file picker, but the type check is what stops you, so GIF, BMP and SVG are refused. - Full-size decode. The image is drawn to a canvas at its natural pixel dimensions and the whole buffer is read back with
getImageData. There is no downscaling step. A 12-megapixel phone photo is 48 MB of RGBA in memory before the analysis starts, which is why a large file can freeze the tab rather than fail. - Transparency cut. Only pixels with alpha ≥ 128 are considered. Semi-transparent edges — anti-aliased halos, soft shadows, 40%-opacity overlays — are excluded entirely rather than blended.
- Stride sampling. If more than 5,000 opaque pixels survive, the code takes every n-th one, where
n = floor(pixelCount / 5000):
| Image | Opaque pixels | Sampling stride | Pixels analysed |
|---|---|---|---|
| 4000 × 3000 | 12,000,000 | every 2,400th | 5,000 |
| 1920 × 1080 | 2,073,600 | every 414th | 5,000 |
| 1000 × 1000 | 1,000,000 | every 200th | 5,000 |
| 800 × 600 | 480,000 | every 96th | 5,000 |
| 512 × 512 | 262,144 | every 52nd | 5,000 |
| 300 × 300 | 90,000 | every 18th | 5,000 |
This is a fixed grid, not a random sample, and it is taken in scan order — which has a consequence the "0.24% of pixels" figure hides. Stepping by n pixels through a row of width W lands on column (i·n) mod W, so the number of distinct columns the sample ever touches is W / gcd(n, W). Rows are almost fully covered; columns can be not. Measured:
| Image | Columns touched | Share of the width |
|---|---|---|
| 4000 × 3000 | 5 of 4,000 | 0.1% |
| 1000 × 1000 | 5 of 1,000 | 0.5% |
| 800 × 600 | 25 of 800 | 3.1% |
| 1920 × 1080 | 320 of 1,920 | 16.7% |
| 512 × 512 | 128 of 512 | 25.0% |
| 1080 × 1080 | 1,080 of 1,080 | 100% |
| 1920 × 1920 | 1,920 of 1,920 | 100% |
A square 1000 × 1000 image is analysed through five columns; a 4000 × 3000 one through five. Anything that lives in a vertical band the grid skips — a sidebar, a colour strip in a chart, one column of a tile pattern, a logo near the right edge — is not in the sample, so it cannot reach the palette whatever the slider says. Two of the resolutions above (1080 × 1080, 1920 × 1920) have a stride coprime with the width and do visit every column, which is why this never looks like a consistent property of the tool. Resize the same artwork and the set of pixels the extractor can even see changes.
Two boundaries come out of that arithmetic. Below 5,001 opaque pixels there is no sampling at all — every pixel is used. Between 5,001 and 10,000 pixels the stride works out to 1, so the "sample" is just the first 5,000 pixels in reading order: the bottom 1 − 5000/n of the image is never looked at. A 100 × 80 banner (8,000 pixels) loses its bottom 30 rows; a 99 × 99 square (9,801 pixels) loses 49%. Above 10,000 the stride reaches 2 and the grid finally spreads over the whole frame — subject to the column limit above.
5. Saturation suppression. After clustering, the cutoff is computed as 0.18 × the most saturated colour in the set — it is relative, not absolute. Measured both ways: a four-level greyscale image (black, dark grey, light grey, near-white) keeps all four greys at ~25% each, because its highest saturation is 0 and the filter is skipped when the maximum is under 5%; but add one saturated accent to a near-white image and the cutoff jumps, so the white, the greys and the dark neutral band are all deleted and the palette comes back as that single accent at 100%. This is why white backgrounds rarely show up in results, and why a deliberately monochrome image can come back with four swatches while a colourful one comes back with two.
Two runs of the same file can give different palettes
Clustering is K-means++ with 12 iterations, and the seeding is where the randomness enters:
- Centre 1 is not chosen at random. It is
pixels[0]— the first opaque pixel in scan order, which is the top-left corner of your image unless that pixel is transparent. The comment above the line says "随机选第一个中心" ("pick the first centre randomly"); the code does no such thing. - Centres 2…k are random. For each remaining centre the pool's pixels are squared-distance-weighted, summed, and one is taken by drawing a threshold with
Math.random(). This is genuine K-means++ farthest-point sampling — it favours pixels unlike everything already chosen — so the draw is not uniform over the pool, it is uniform over outliers. Asking for 7 colours means 6 draws, not thousands: the number of random decisions is k−1.
So the starting configuration differs between runs, and how much that matters depends entirely on the image. Measured over 20 runs per case, with the page's own extractColors driven directly:
| Image | Runs needed to see every distinct result |
|---|---|
| 3 flat colours, 400 × 400 | 1 palette in 20 runs — perfectly repeatable |
| Same 3 colours, ±6 noise per channel | 16 distinct palettes in 20 runs, differing by 1–2 units per channel |
| 5 colours, ±6 noise, asked for 7 | 8 distinct palettes in 10 runs |
| Blue background + magenta at 1–50% of pixels | 1 palette in 20 runs for every share tested |
Two things follow. Well-separated flat colours are recovered exactly and reliably, so this is a safe tool for logos, UI screenshots and brand artwork. Photographs, gradients and JPEG noise move by a couple of units per channel between runs — #E6282D becomes #E7292D — which is invisible in the swatch row and different in the exported CSS. If you need a palette you can reproduce, take the swatches from one run and never re-run it; the export is the record, not the file.
And because centre 1 is pinned to the top-left pixel, that pixel is guaranteed to become a palette entry, however unrepresentative it is. Measured: an image of 250,000 blue pixels with a single green pixel in the top-left corner returned #0AFA0A as a swatch in 20 of 20 runs — at 0%. One stray pixel, one antialiased corner artefact, or a border drawn in the wrong colour, and the tool will carry it into your palette every time.
The first swatch is not the dominant colour
Two different orderings happen in sequence, and only the last one is what you see:
- Clusters are selected by a score of
frequency × 0.6 + saturation × 0.4, and the top k are kept. A colour that covers 40% of the image but is nearly grey can lose to a smaller, more saturated one. - The kept clusters are then displayed sorted by percentage, descending.
So the leftmost swatch is the most common of the survivors — not the highest-scoring colour, and not necessarily the most common colour in the image. The count slider goes from 5 to 9 only (default 7), so you cannot ask for a 3-colour summary or a 12-colour breakdown.
It is a ceiling, and the measurements say so plainly: three flat primary colours asked for as seven come back as three; a five-colour image returns five at every setting from 5 to 9; twelve well-separated hues asked for 2, 3, 5, 7 or 9 return exactly 2, 3, 5, 7 or 9. You can only ever get fewer than you asked for, never more — which means the way to ask for a shorter palette is the slider, and the way to ask for a longer one is to crop the image so the survivors are the colours you wanted.
One more number worth having before you upload a phone photo: after clustering, the frequency recount walks every opaque pixel once per surviving colour, and a 3,000,000-pixel synthetic image took 3.6 seconds to run through extractColors (30,000 pixels: 0.11 s). Nothing is offloaded — the click handler defers with setTimeout so the loading state paints, then does the entire job on the main thread, so the tab stops responding for exactly that long.
The percentages mean something other than what they look like
Each swatch shows a percentage, and the swatches always add up to 100%. That total is the giveaway: the numbers are shares among the colours that survived, not shares of your image.
Counts are computed by walking every opaque pixel and adding one to any cluster whose RGB centre is within a Euclidean distance of 24 — the same threshold used to merge similar colours. The denominator is then the sum of the surviving clusters' counts, not the pixel count of the file. Measured against ground truth:
| Image | Reported | True share |
|---|---|---|
| Blue + magenta at 1% / 2% / 5% / 10% / 20% / 50% | 1 / 2 / 5 / 10 / 20 / 50 |
exact, every time |
| Same two colours at 70/30 | #283CDC:70, #FA14F0:30 |
exact |
| Magenta at 1% on a near-white background | #FA14F0:100 |
1% |
| 12 distinct hues, asked for 9 | six entries at ~16.7%, three at 0% | 8.3% each |
The middle row is the one that changes how you should read the tool. Near-white has zero saturation, so the suppression filter in step 5 removes it, and the magenta accent is then 100% of what is left. A swatch marked 100% can be a 1% accent. The last row shows the other edge: a centre that ends up with no pixels within its 24-radius simply reports 0% and still occupies a slot in the palette and in the exported CSS.
So use the percentages to rank the palette against itself — which of these colours matters most — and never to size a layout region or to claim a brand colour "covers 60% of the artwork". For that, count pixels yourself.
What the export gives you
The export panel writes CSS custom properties, one triple per colour:
:root {
--color-1: #3B82F6;
--color-1-rgb: 59, 130, 246;
--color-1-hsl: 217, 91%, 60%;
}
The -rgb form exists so you can write rgba(var(--color-1-rgb), 0.5), which is the only way to get an alpha variant out of a custom property. The download button saves the visible code as palette.css. Note that the -hsl line is the rounded reading discussed earlier — if you use it as a source of truth you inherit the same 89.4% round-trip error.
The "show distribution" overlay repaints the preview at display resolution, mapping each visible pixel to its nearest surviving colour and drawing it at 180/255 opacity over the original. It is a picture of the clustering, not a mask: it shows you where each swatch won, including in regions where that colour is a poor fit.
The gradient builder: two to four stops, no alpha, one circle
The gradient generator is the most constrained of the three, and the constraints are not announced anywhere on the page.
- Types:
linear-gradientandradial-gradientonly. There is no conic gradient, and radial output is hard-coded toradial-gradient(circle, …)— noellipse, noat 30% 70%position. If you need either, edit the CSS by hand after copying. - Stops: minimum 2, maximum 4. The + Add Color Stop button is removed from the page at four, and
addStopreturns early as well, so a fifth stop cannot be forced in. At two stops the ✕ control is not rendered at all — it is replaced by a 28 px empty spacer in the same row, anddelStopreturns early too. A five-colour rainbow is not expressible in this tool. - New stops are always
#888888, placed atmin(100, last + 20). That formula has nothing to say about collisions, so if your last stop already sits at 100% the new one is added at 100% too. Two stops on the same position give the second one a band of zero width: the preview's computed background is byte-for-byte identical before and after the click, and the only evidence that anything happened is the counter jumping to "3 colours" and#888888 100%appearing in the code box. Move the last stop off 100% before you add one, or the grey lands somewhere you cannot see it. - No alpha. Colours come from
<input type="color">plus a hex field, and the hex field requires#with 3 or 6 hex digits:#abcand#ff7e5fare accepted;ff7e5f,#ff7e5f80,rgb(1,2,3)andredare all ignored — silently, with your text left in the box while the preview keeps the old colour. There is no opacity control, so a fade to transparent means editing the copied CSS and addingtransparentor an 8-digit hex yourself. - The hex field validates on every keystroke, and three digits is already a colour. Typing
#a1b2c3one character at a time behaves like this:#aignored,#a1ignored,#a1baccepted — so the preview turns that pale grey-blue while you are still typing.#a1b2and#a1b2care invalid, so the colour holds at#a1b, and#a1b2c3only lands when the sixth digit arrives. A value you stop at three digits is written into the code as#a1b, not expanded to#aaaabbcc. So the gradient can change twice on the way to the colour you meant, and the box you are typing in is not the authority on what the output says. - Direction: five buttons, only three of them keyword directions. The grid holds Top to Bottom (
to bottom, selected when the page loads), Left to Right (to right), Diagonal (to bottom right), Radial and Custom, under two type buttons (Linear Gradient / Radial Gradient). There is noto top, noto leftand noto top leftbutton — for those you have to go through Custom and type an angle. The angle slider only appears in Custom mode: the page opens withbackground: linear-gradient(to bottom, #ff7e5f, #feb47b 100%);in the code box, and the 45° value sits unused in state until you press Custom, at which point the same line becomeslinear-gradient(45deg, #ff7e5f, #feb47b 100%). - Radial is one switch, and it only works in one direction. The Radial direction button and the Radial Gradient type button set the same state, and pressing a keyword direction while in radial type flips the type back to linear. The Linear Gradient type button is the one path that forgets to clean up: it changes the type and leaves the direction value on
radial, and the builder pastes the two together intobackground: linear-gradient(radial, #ff7e5f, #feb47b 100%);. That is not valid CSS — a browser reading it paints nothing. The code box will hand it to you anyway, while the preview keeps painting the previous radial gradient because the invalid assignment is simply rejected. So the page looks correct, the copy button says ✓ Copied, and the CSS you paste into your stylesheet is dead. Pressing Linear Gradient again cannot fix it, because the type already reads linear; the only escape is to press any direction button. This happens on the Chinese page too, and the shortest safe habit is to end every gradient by pressing the direction you actually want rather than a type button. - The angle box and the keyword buttons disagree about the diagonals. In CSS,
0degpoints up and angles increase clockwise, so45degisto top right— the opposite corner from the Diagonal button'sto bottom right. Moving from Diagonal to Custom without touching the slider sends your gradient to the other corner, which is the usual reason a pasted gradient "looks flipped".
| You want | What to type in Custom mode |
|---|---|
to top |
0deg |
to top right |
45deg (the default) |
to right |
90deg |
to bottom right |
135deg |
to bottom |
180deg |
to bottom left |
225deg |
to left |
270deg |
to top left |
315deg |
The four cardinal pairs match exactly. The diagonal pairs do not, and cannot: a corner keyword aims the gradient line at the actual corner, so its angle changes with the box's aspect ratio, while a degree value keeps the angle and misses the corner on any box that is not square. Use the keyword when the line must touch the corners; use degrees when the angle itself is the design decision.
The code box holds one line, background: plus the function call plus ; — so copying it gives you a declaration, not a value, and pasting it where a background-image is expected needs the background: prefix edited away. Position formatting is asymmetric: a stop at 0% is written as the bare colour, while every stop above 0 carries its percentage, so the default output reads linear-gradient(to bottom, #ff7e5f, #feb47b 100%) rather than 0%, … 100%. Drag both stops to 0 and the line becomes linear-gradient(to right, #ff7e5f, #feb47b) with no positions at all. The small label under the preview mirrors the function call without the background: prefix, so it is the one place to read the value if you need the bare expression. The copy button replaces its own text with ✓ Copied for 1.2 seconds, and if the clipboard is unavailable the failure is swallowed by an empty handler: no message, no fallback selection, and the button keeps saying Copy as though nothing happened.
The twelve presets — Sunset, Ocean, Aurora, Twilight, Flame, Forest, Neon, Dawn, Lavender, Strawberry, Ice, Desert — are all two-stop to right gradients, and clicking one replaces your stops entirely, including any you had positioned by hand. What it does not replace is the shape: each preset carries a stops array and a separate CSS string, the string is used only to paint the little swatch in the strip, and the click copies the stops. So the swatch always reads as a left-to-right linear band even when the builder is in another mode. Measured: with radial type active, clicking Sunset gives background: radial-gradient(circle, #ff7e5f, #feb47b 100%); — a radial Sunset, not the gradient you saw in the strip. With Custom at 200°, clicking Ocean gives background: linear-gradient(200deg, #2193b0, #6dd5ed); and leaves your angle there. The code box, not the swatch, is the truth; and if you want a preset exactly as drawn, press a direction button first.
Which page for which job
| You have | Use | Watch out for |
|---|---|---|
| A colour in one notation, needed in another | converter | HSL is rounded; commit the HEX |
| A design-tool colour with alpha | converter, after stripping the last 2 digits | 8-digit hex parses from the wrong end |
| A brand palette from a photo or logo | extractor | the pixel grid it reads can skip whole columns |
| A reproducible palette from the same file | extractor — flat art repeats exactly, noisy art drifts | photos move 1–2 units per channel |
| A background you can paste into CSS | gradient builder | 4 stops max, no alpha, circle only, and Linear Gradient can emit invalid CSS |
| Anything with transparency or conic gradients | none of them | write the CSS by hand |
FAQ
Why did my pasted #FF0000ff turn blue?
Because the HEX field is parseInt(str, 16) after removing #, and a 32-bit number is then read as three 8-bit channels from the low end. FF0000ff keeps 0000ff. Drop the alpha pair before pasting.
Is the converter wrong, or is my colour wrong? Neither. The HSL field is a rounded display of the colour, so converting it back can land one to five steps away per channel. 89.4% of colours move at all; the largest drift measured was 5.
Can I get any colour I want from the sliders? No. Integer HSL positions cover 2,037,576 of 16,777,216 colours — 12.1%. Type the HEX for anything that has to be exact.
Why does the page not tell me my input was invalid? It has no error path. Each field's handler simply returns when the parse fails, leaving the previous colour on screen. If a paste changes nothing, the input was rejected — check the syntax against the table above.
Why does my logo's brand colour never appear in the extracted palette? Most likely the stride. On a 12-megapixel image only every 2,400th opaque pixel is analysed, so a small mark can be absent from the sample. Cropping to the region you care about, or resizing the file down to a few hundred pixels wide, makes small colours far more likely to survive.
I asked for 7 colours and got 4. Is that a bug? No. Similar clusters merge within RGB distance 24, and colours below 18% of the image's peak saturation are dropped before the cut-off. The count slider is a ceiling, not a guarantee.
Are the percentages the share of the image? No. They share out the pixels claimed by the surviving clusters, and a pixel can be claimed by more than one cluster. They always total 100%, and they are only comparable to each other.
Why did the palette change when I moved the slider back to 7?
K-means++ picks its second and later centres with Math.random(). Every extraction is a fresh draw, so palettes drift between runs. The first centre is fixed at the first opaque pixel; the rest are not.
Can the gradient tool make a transparent-to-colour fade?
Not through the interface — colour inputs carry no alpha and the hex field rejects 8-digit values. Copy the generated CSS and replace one stop with transparent, or use #RRGGBB00.
Why can I not add a fifth colour stop? The handler returns early at four and hides the button. It is a hard limit in the page, not a rendering issue.
I pressed Linear Gradient and the CSS I pasted does nothing. What happened?
You were in radial mode. That button changes the function name but leaves the direction value set to radial, so the page writes linear-gradient(radial, #ff7e5f, #feb47b 100%) — a function call no browser understands. The preview carries on painting the previous radial gradient, so the only visible symptom is the word radial sitting where a direction should be. Press any direction button to get a valid line; pressing Linear Gradient again changes nothing, because the type already says linear. The Chinese page has the same defect.
Why does the preset I clicked look different from the swatch I clicked?
A preset supplies colours, not a shape. The swatch is painted from the preset's own hard-written to right string, while the builder keeps its current type and angle, so clicking a preset in radial or custom mode gives you those colours in the shape you had. Read the code box; press a direction button first if you want the gradient as drawn.
Does any of this upload my image?
No. The extractor reads the file with FileReader and analyses it on a canvas in the same tab; nothing is sent anywhere. The cost is memory instead of privacy: the full-resolution RGBA buffer is held while it runs.