The Colour Picker That Escaped the Page
Two inputs, an afternoon's work, and about four years of not leaving it alone.
Years ago I used a website that did one thing beautifully. Two inputs, side by side. Type an RGB value into one and the HEX appeared in the other; type a HEX value and it worked in reverse. The page background changed to match whatever you'd entered, and that was the whole interface. No panels, no sliders, no floating windows of controls.
I used it constantly while doing web work. I've since lost the URL and can't find it again, which I regret, because I'd like to credit it properly. What I can do is be honest that @mdcrty/color began as a copy of it. Rebuilding something you admire is one of the better ways to understand it - you can't skim the parts you glossed over when you were only a user.
The first version took an afternoon. One HTML file, a little JavaScript, two inputs bound to each other, and a line setting document.body.style.backgroundColor on every keystroke. Type in one box, the other updates, the page changes colour.
It was useful for about a week.
The page starts reacting
The colour changes were abrupt - a hard cut from one background to the next. A CSS transition fixed it in one line, and the moment it was smooth the page stopped feeling like a form and started feeling like something paying attention. That's a dangerous feeling. It makes you want to add more.
The next problem was legibility: black text disappears against a dark background, so the text colour needed to flip to white automatically. That sounds like an afternoon's work too, and my first attempt was exactly what you'd expect - average the red, green and blue channels, and call anything under halfway "dark."
It's wrong, and it's wrong in a way that's obvious once you see it. Pure blue (#0000FF) and pure green (#00FF00) produce the same average, but on screen green is blindingly bright and blue is nearly black. Human vision isn't democratic about channels - we're far more sensitive to green than to blue, and the weightings aren't close. Getting the flip right meant reading about perceived brightness, then about relative luminance and gamma correction, then about how WCAG actually defines contrast ratios. I went in wanting an if-statement and came out understanding why colour maths is its own field.
Then I did something slightly out of scope, and it's still my favourite thing in the project. Once the page was reacting to colour, I wanted the browser to react as well. Updating the <meta name="theme-color"> tag means Safari and most mobile browsers tint their own chrome to match the selected colour - pick a violet and the entire top of the phone goes violet with you. Almost nobody notices it consciously. I love it anyway, because it's the point where an application reaches past its own frame. Most projects never bother.
What a converter is actually made of
CMYK came next, then HSV, then HWB. The honest reason is that I wanted to know how the conversions worked, and there's no faster way to find out than being forced to implement one.
Somewhere in the middle of adding the third format I stopped writing conversion functions and wrote a ColorObject instead - a single class that holds the colour and exposes each representation as a property. That change is easy to undersell. Standalone functions mean every new format is n new conversions and n new places to keep in sync. An object with one internal representation means a new format is one conversion in and one out, and everything else comes free.
I didn't plan it. I almost never end up with the thing I intended on day one. But it's the moment the project quietly stopped being a page with a converter on it and became a converter with a page on it, and everything after that was possible because of it.
Canvas, and the Bloom problem
I've always loved Brian Eno and Peter Chilvers' Bloom. You touch the screen, and sound and colour spread outward. It behaves more like an instrument than an app, and it's the clearest example I know of software being pleasant for no productive reason at all.
I wanted some of that. So I added a ripple: touch anywhere on the page, a random colour generates, a ring expands out from your finger, the values update, and the browser chrome shifts with it.
This was the first time I reached for Canvas, and I reached for it because the problem had outgrown CSS. A transition can move one property from one value to another; it can't render an expanding ring that redraws itself every frame and is gone in 500 milliseconds. So I ended up writing something closer to a game loop than a web interface - requesting frames, tracking state between them, drawing something that exists briefly and then doesn't. It's a different discipline from the DOM, where you describe what should be true and let the browser sort it out. Here nothing persists unless you redraw it.
I wouldn't call it learning Canvas. I used one feature of it to solve one problem, and I couldn't have told you much about the rest. Actually sitting down with it came later, with Paint.
It's also the feature I'm proudest of, partly because I haven't seen it anywhere else. Combined with the theme-colour tag, a single tap repaints the page, the ripple, and the operating system's idea of what colour your browser is.
Escaping the page
By this point the project had two distinct halves that had nothing to do with each other: an interface, and a colour engine underneath it. I converted the HTML page into a React component, which made the split obvious, and then hit the problem that eventually forces everyone's hand - I wanted the same code on more than one site, and I was copying files between projects to get it.
I'd already been through this with @mdcrty/bees and @mdcrty/digital-rain. Those two proved the pipeline: I consume npm packages constantly, so how hard could publishing one be? Not very, it turns out, but the first one teaches you all the parts nobody documents. Colour was the first package where the move actually changed the code.
Two things came out of it. The first was LAB, LCH and OKLCH, added while I was in there rounding out the formats - and they took a few evenings rather than a restructure, precisely because ColorObject already existed. The computation had a home; I was extending it, not rebuilding around it.
The second was less glamorous. A component that lives inside your own site can reach out and restyle whatever it likes. A component that lives inside someone else's site cannot, and shouldn't. The picker's cursor ring and slider thumbs are white, which looks correct on a light page and disappears entirely on a dark one - and the consumer has no clean way to fix that, because fighting a package's styles with :global() selectors is miserable and fragile. So the component grew a classNames prop that exposes named slots: squareCursor, hueThumb, alphaThumb, sliderThumb, copyBtn, and the rest. You hand it your own classes, per part, and never have to out-specify mine.
Exporting ColorObject itself came from the same instinct. If the engine is useful - and it is - somebody should be able to use it without touching the UI at all. That turned out to take more than an extra line in the index file. Importing from the package root pulls in the component, its stylesheet and a "use client" directive, which is a lot of weight to carry if all you wanted was hex to LAB in a build script. So the class also ships as its own subpath, @mdcrty/color/colorObject, resolving to a self-contained module with no React anywhere in it.
Same class, two doors. Give people something simple that works out of the box, and let them lift the bonnet without paying for the rest of the car.
What it cost
The version on npm today has a gradient square, a hue bar, an alpha bar with full alpha passthrough, per-channel sliders, nine formats, copy buttons on every input, and a toggle for whether any of it renders at all.
That is not two inputs.
I think about this every time I open the repo. The original site was good because of what it refused to do, and I've spent four years adding the exact things it left out. What I'd defend is that the additions are opt-in - <Color touch /> gives you nothing but a ripple, and inputs={["hex", "rgb"]} gets you most of the way back to where this started. The simplicity survives as a configuration rather than as a constraint. Whether that counts is arguable, and I'd rather leave it arguable than pretend I didn't notice.
The other thing I'd defend is the boundary. My own site runs a dark mode, and making the picker sit properly inside it took some CSS I'm not proud of - but that CSS lives on my site, not in the package. A React component should not know what page it's on. Everything I wanted to bolt on that would have violated that stayed outside the package where it belongs.
Two things didn't stay outside. The component sets document.body.style.backgroundColor, and it rewrites the <meta name="theme-color"> tag - both of which reach well past the line I've just finished claiming to respect. I've considered pulling them out and handing the job to the consumer, and I'm not going to. Those two lines are the difference between a widget sitting on a page and a page that has a colour. They're also the entire idea the project is named after.
Some things escape the page on purpose.
What's next
Query parameter handling is the obvious gap: ?hex=0044cd, or ?r=45&g=0&b=205, so a colour can be linked and shared rather than retyped. I only thought of it while writing this, which is a decent argument for writing things down.
After that, accessibility. The picker is currently drag-driven, and drag-driven controls exclude people. Keyboard-steppable inputs on every channel, real focus states, and contrast testing against the component's own output - which would be a pleasing loop, given that contrast maths is what turned this from a two-input page into a project in the first place.
The nice part is that all of it is now a version bump instead of a copy-paste across three repos. That's the whole reason for the package, and it took me a lot longer to arrive at than it should have.