Skip to main content
Ben Nadel at CFUNITED 2010 (Landsdown, VA) with: Chris Peters
Ben Nadel at CFUNITED 2010 (Landsdown, VA) with: Chris Peters ( @cf_chrispeters )

Color Palette Utility In Apline.js

By
Published in Comments (1)

A week or so ago, as I was working on my Feature Flags Book companion app, I needed to pick colors that correspond to the different variants available within a given feature flag. I'm not good at colors, so I took to Google; and, in my searches, I came across the utility site, Coolors. Coolors is very cool; but, I could only generate 5 colors for free and I needed about 7 colors for my feature flags. So, in typical "Developer fashion", I wanted to see if I could build something akin to Coolors on my own, using Alpine.js.

Run this demo in my JavaScript Demos project on GitHub.

View this code in my JavaScript Demos project on GitHub.

My intention here was to have a little fun and maybe spend 2-3 hours building this (mostly as a distraction from my more important work). But, I really fell deep down the rabbit hole and ended up putting about a week's worth of mornings into this demo. Every time I thought I was done, I had an idea for another tweak. In the end, what could have been 20 lines of code became 1,500 lines of code (see bottom of post).

It's too much code to discuss in detail; but, I'll give you the high-level overview of how the app works.

A Palette of Random Colors

The whole point of this utility is to build up a palette of colors. To provide a jumping-off point, the initial rendering is completely random. And, after the initial rendering, you can use the Space key to regenerate all colors; or, use the C key to regenerate only the selected color entry. Individual colors can also be tweaked using Hue, Saturation, and Lightness range inputs:

Random colors being applied to a color palette.

I decided to work with Hue, Saturation, and Lightness (HSL) instead of Red, Green, and Blue (RGB) because it lends more naturally to fine-tuning. Meaning, it's easier to think about sliding across the full range of hues than it is to think about which RGB colors channels need to be mixed in order to create different colors.

That said, the output gives you web-standard HEX strings. Which means, I had to learn about converting values between HSL and RGB encodings. This is math that I don't understand and had to copy-paste-modify from various sources (big thanks to CSS Tricks and Stack Overflow).

Keyboard Shortcuts

One of the luxurious features of Alpine.js is the fact that you can easily bind to window and document events directly from the HTML by appending the .window or .document modifier, respectively. As such, implementing keyboard shortcuts was a matter of adding a @keydown.window binding to my markup; and then, inspecting the event.key to see which view-model changes I needed to apply.

Here are the keyboard shortcuts available in the demo:

  • Space - cycles all non-locked swatches in the existing palette.

  • c - cycles only the currently-focused swatch (as long as it's unlocked).

  • [ or < - moves focus to the previous swatch.

  • ] or > - moves focus to the next swatch.

  • Shift+[ or Shift+< - moves the currently-focused swatch to the left by one.

  • Shift+] or Shift+> - moves the currently-focused swatch to the right by one.

  • + - adds a random swatch to the end of the palette.

  • - - removes the currently-focused swatch from the palette.

  • h - moves focus to the Hue slider in the currently-focused swatch.

  • s - moves focus to the Saturation slider in the currently-focused swatch.

  • l - moves focus to the Lightness slider in the currently-focused swatch.

  • t - toggles the lock (on and off) for the currently-focused swatch.

  • d - duplicates the currently-focused swatch.

The HSL range inputs also have their own set of keyboard commands for both course and fine adjustments.

  • ArrowLeft - (native behavior) nudges the HSL range down by "step".

  • ArrowRight - (native behavior) nudges the HSL range up by "step".

  • Shift+ArrowLeft - nudges the HSL range down by "5 x step".

  • Shift+ArrowRight - nudges the HSL range up by "5 x step".

  • ArrowUp - nudges the HSL range down by "10 x step".

  • ArrowDown - nudges the HSL range up by "10 x step".

  • Shift+ArrowUp - nudges the HSL range down by "20 x step".

  • Shift+ArrowDown - nudges the HSL range up by "20 x step".

  • 1 through 9 - moves the HSL range to a %-based offset. For example, if you hit 2, it will move the HSL range to 20% of the min-max range. And, if you hit 7, it will move the HSL range to 70% of the min-max range.

And, of course, the Tab key will naturally move in between all focusable elements. Each swatch also has a tabindex="0" for keyboard access.

Persisting Color Palette State

There's no back-end storage here, this is a client-side only demo. But, the state of the color palette is persisted in the URL as the fragment / hash. This makes the color palette shareable via URL.

Beyond the URL, I'm also dynamically generating the title (as a list of HEX values) and I'm using the Canvas API to create a favicon from the current color palette. This way, the browser History actually gives you a meaningful representation of the history of the your palette:

Browser history back menu showing a series of previous color palettes complete with representative favicon.

Generating Color Palette Images

Once you have a color palette selected, you can save the color palette as either an SVG or a PNG image. This will generate a horizontal graphic with 300 x 300 swatches with their corresponding HEX values:

What's really cool, though, is that you can then drag-and-drop those same SVG / PNG images back onto the window and I'll parse the colors out of the file and regenerate the color palette state:

Dragging PNG and SVG images onto the browser regenerates the color palette state from the dropped image.

If you hold down the Shift key during the file drop, it will append the colors to the current palette rather than resetting the palette.

Pasting Hex Values

Another way to get colors into the palette is to paste a string that contains HEX-encoded colors. For this, I listen for the global paste event, @paste.document, and extract all embedded 6-digit HEX values and turn them each into color swatches:

Pasting strings containing HEX values causes those colors to be added to the current palette.

The Code

I'm not going to discuss this code in any more detail since there's way too much of it. But, I'll leave it here for anyone who wants to skim it. And, of course, you can always try the demo for yourself.

The user interface is quite small—this whole thing is really just a few columns with a few buttons. All of the heavy lifting is done by Alpine.js:

<!doctype html>
<html lang="en">
<head>
	<meta charset="utf-8" />
	<meta name="viewport" content="width=device-width, initial-scale=1" />
	<link rel="stylesheet" type="text/css" href="./main.css" />
</head>
<body
	x-data="App"
	@hashchange.window="handleGlobalHashchange( $event )"
	@keydown.window="handleGlobalKeydown( $event )"
	@dragover.document="handleGlobalDropEvents( $event )"
	@drop.document="handleGlobalDropEvents( $event )"
	@paste.document="handlePaste( $event )"
	@resize.window.debounce.250ms="handleResize( $event )">

	<header class="masthead">
		<h1 class="masthead__title">
			Color Palette Utility In Alpine.js
		</h1>

		<button @click="addSwatch()" class="masthead__add">
			Add Color
		</button>

		<a
			@click="downloadPaletteAsPNG( $event.currentTarget )"
			download="palette.png"
			class="masthead__download">
			PNG
		</a>
		<a
			@click="downloadPaletteAsSVG( $event.currentTarget )"
			download="palette.svg"
			class="masthead__download">
			SVG
		</a>
	</header>

	<main class="panels">
		<template x-for="( swatch, i ) in palette" :key="swatch.id">

			<div class="panels__item">

				<!-- Begin: Panel. -->
				<div
					tabindex="0"
					@focusin="handleFocusin( i )"
					class="panel"
					:class="{
						'panel--active': ( activeSwatchIndex === i ),
						'panel--narrow': isNarrowPanels
					}"
					:style="{ backgroundColor: swatch.hex }">

					<h2 class="panel-name">
						<span class="panel-name__hex"
							x-text="swatch.hex">
						</span>
						<span class="panel-name__hsl">
							<span x-text="swatch.hue"></span> &middot;
							<span x-text="swatch.saturation"></span> &middot;
							<span x-text="swatch.lightness"></span>
						</span>
					</h2>

					<div class="panel-settings">
						<input
							type="range"
							x-model.number="swatch.hue"
							@keydown="handleRangeKeydown( $event )"
							@input="handleRangeInput( $event, swatch )"
							min="0"
							max="360"
							step="1"
							class="panel-settings__range hue"
						/>
						<input
							type="range"
							x-model.number="swatch.saturation"
							@keydown="handleRangeKeydown( $event )"
							@input="handleRangeInput( $event, swatch )"
							min="0"
							max="100"
							step="0.1"
							class="panel-settings__range saturation"
						/>
						<input
							type="range"
							x-model.number="swatch.lightness"
							@keydown="handleRangeKeydown( $event )"
							@input="handleRangeInput( $event, swatch )"
							min="0"
							max="100"
							step="0.1"
							class="panel-settings__range lightness"
						/>
					</div>

					<div class="panel-tools">
						<button @click="moveSwatchLeft( i )" class="panel-tools__button">
							<svg>
								<title>Arrow Left</title>
								<use xlink:href="#arrow-left"></use>
							</svg>
						</button>
						<button
							@click="toggleLock( i )"
							class="panel-tools__button"
							:class="{
								'panel-tools__button--locked': swatch.isLocked
							}">
							<template x-if="swatch.isLocked">
								<svg>
									<title>Locked</title>
									<use xlink:href="#lock-locked"></use>
								</svg>
							</template>
							<template x-if="( ! swatch.isLocked )">
								<svg>
									<title>Unlocked</title>
									<use xlink:href="#lock-unlocked"></use>
								</svg>
							</template>
						</button>
						<button @click="removeSwatch( i )" class="panel-tools__button">
							<svg>
								<title>Remove</title>
								<use xlink:href="#trash"></use>
							</svg>
						</button>
						<button @click="moveSwatchRight( i )" class="panel-tools__button">
							<svg>
								<title>Arrow Right</title>
								<use xlink:href="#arrow-right"></use>
							</svg>
						</button>
					</div>

				</div>
				<!-- End: Panel. -->

			</div>

		</template>
	</main>

	<!-- Begin: SVG sprite sheet (https://streamlinehq.com). -->
	<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="display: none ;">
		<symbol id="lock-locked" viewBox="0 0 24 24">
			<path d="M19.5 9.5h-0.75V6.75a6.75 6.75 0 0 0 -13.5 0V9.5H4.5a2 2 0 0 0 -2 2V22a2 2 0 0 0 2 2h15a2 2 0 0 0 2 -2V11.5a2 2 0 0 0 -2 -2Zm-7.5 9a2 2 0 1 1 2 -2 2 2 0 0 1 -2 2ZM16.25 9a0.5 0.5 0 0 1 -0.5 0.5h-7.5a0.5 0.5 0 0 1 -0.5 -0.5V6.75a4.25 4.25 0 0 1 8.5 0Z" fill="currentColor" stroke-width="1"></path>
		</symbol>
		<symbol id="lock-unlocked" viewBox="0 0 24 24">
			<path d="M19.5 9.5h-0.75V6.75A6.75 6.75 0 0 0 5.53 4.81a1.25 1.25 0 0 0 2.4 0.72 4.25 4.25 0 0 1 8.32 1.22V9a0.5 0.5 0 0 1 -0.5 0.5H4.5a2 2 0 0 0 -2 2V22a2 2 0 0 0 2 2h15a2 2 0 0 0 2 -2V11.5a2 2 0 0 0 -2 -2Zm-7.5 9a2 2 0 1 1 2 -2 2 2 0 0 1 -2 2Z" fill="currentColor" stroke-width="1"></path>
		</symbol>
		<symbol id="arrow-left" viewBox="0 0 24 24">
			<path d="M22.5 10.5H7a0.25 0.25 0 0 1 -0.25 -0.25v-3a1 1 0 0 0 -1.71 -0.71L0.29 11.29a1 1 0 0 0 0 1.42l4.78 4.77a1 1 0 0 0 0.71 0.3 1 1 0 0 0 1 -1v-3A0.25 0.25 0 0 1 7 13.5h15.5a1.5 1.5 0 0 0 0 -3Z" fill="currentColor" stroke-width="1"></path>
		</symbol>
		<symbol id="arrow-right" viewBox="0 0 24 24">
			<path d="m23.71 11.29 -4.78 -4.78a1 1 0 0 0 -1.09 -0.21 1 1 0 0 0 -0.62 0.92v3a0.25 0.25 0 0 1 -0.25 0.25H1.5a1.5 1.5 0 0 0 0 3H17a0.25 0.25 0 0 1 0.25 0.25v3a1 1 0 0 0 1 1 1 1 0 0 0 0.71 -0.3l4.78 -4.77A1.05 1.05 0 0 0 24 12a1 1 0 0 0 -0.29 -0.71Z" fill="currentColor" stroke-width="1"></path>
		</symbol>
		<symbol id="trash" viewBox="0 0 24 24">
			<path d="M19.5 7.5h-15A0.5 0.5 0 0 0 4 8v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2 -2V8a0.5 0.5 0 0 0 -0.5 -0.5Zm-9.25 13a0.75 0.75 0 0 1 -1.5 0v-9a0.75 0.75 0 0 1 1.5 0Zm5 0a0.75 0.75 0 0 1 -1.5 0v-9a0.75 0.75 0 0 1 1.5 0Z" fill="currentColor" stroke-width="1"></path>
			<path d="M22 4h-4.75a0.25 0.25 0 0 1 -0.25 -0.25V2.5A2.5 2.5 0 0 0 14.5 0h-5A2.5 2.5 0 0 0 7 2.5v1.25a0.25 0.25 0 0 1 -0.25 0.25H2a1 1 0 0 0 0 2h20a1 1 0 0 0 0 -2ZM9 3.75V2.5a0.5 0.5 0 0 1 0.5 -0.5h5a0.5 0.5 0 0 1 0.5 0.5v1.25a0.25 0.25 0 0 1 -0.25 0.25h-5.5A0.25 0.25 0 0 1 9 3.75Z" fill="currentColor" stroke-width="1"></path>
		</symbol>
	</svg>
	<!-- End: SVG sprite sheet. -->

	<script type="text/javascript" src="./main.js" defer></script>
	<script type="text/javascript" src="../../vendor/alpine/3.13.5/alpine.3.13.5.min.js" defer></script>

</body>
</html>

There's a lot to like about Alpine.js—in many ways, it feels like a nice compromise between Angular.js and plain-old HTML. The part that I don't feel like I have a good strategy for is dealing with public vs. private methods. Since Alpine.js works by wrapping the state in a JavaScript Proxy (to power the reactive rendering), you need to put all methods on the state in order for the this bindings to make sense in all places. This is why I chose to prefix my private methods with _, so that it's very clear which methods are meant for public consumption and which are meant for internal usage only.

With that said, here's the rest of my code. It's a lot! But, I've tried to leave comments where things need to be explained.

function App() {

	var host = this.$el;
	var swatchID = 0;
	var persistanceTimer = null;
	var urlSetAt = 0;

	// For both generating (download) and parsing (drag-drop) swatches.
	var swatchWidth = 300;
	var swatchHeight = 300;

	return {
		// Public properties.
		palette: [ /* Collection of swatches. */ ],
		activeSwatchIndex: 0,
		isNarrowPanels: false,

		// Public methods.
		init: $init,
		addSwatch: addSwatch,
		cyclePalette: cyclePalette,
		cycleSwatch: cycleSwatch,
		downloadPaletteAsPNG: downloadPaletteAsPNG,
		downloadPaletteAsSVG: downloadPaletteAsSVG,
		duplicateSwatch: duplicateSwatch,
		handleFocusin: handleFocusin,
		handleGlobalDropEvents: handleGlobalDropEvents,
		handleGlobalHashchange: handleGlobalHashchange,
		handleGlobalKeydown: handleGlobalKeydown,
		handlePaste: handlePaste,
		handleRangeInput: handleRangeInput,
		handleRangeKeydown: handleRangeKeydown,
		handleResize: handleResize,
		moveActiveIndexLeft: moveActiveIndexLeft,
		moveActiveIndexRight: moveActiveIndexRight,
		moveSwatchLeft: moveSwatchLeft,
		moveSwatchRight: moveSwatchRight,
		removeSwatch: removeSwatch,
		toggleLock: toggleLock,

		// Private methods.
		// --
		// Note: Alpine.js doesn't really allow for private method because the "this"
		// context isn't the return value of the constructor. Instead, it's the Proxy that
		// Alpine builds around your return value. As such, all private methods need to
		// actually exist on the return value in order for the "this" bindings to wire-up
		// correctly. For that reason, I'm prefixing them with an underscore to remove the
		// temptation of calling these methods from the HTML template.
		_checkPanelWidth: checkPanelWidth,
		_checkPanelWidthAsync: checkPanelWidthAsync,
		_createRandomSwatch: createRandomSwatch,
		_createSwatch: createSwatch,
		_focusHue: focusHue,
		_focusHueAsync: focusHueAsync,
		_focusLightness: focusLightness,
		_focusSaturation: focusSaturation,
		_generateDownloadFilename: generateDownloadFilename,
		_loadState: loadState,
		_persistState: persistState,
		_persistStateSync: persistStateSync,
		_readColorsFromDropEvent: readColorsFromDropEvent,
		_readColorsFromPngFile: readColorsFromPngFile,
		_readColorsFromSvgFile: readColorsFromSvgFile,
		_serializePalette: serializePalette,
		_setDocumentTitle: setDocumentTitle,
		_setFavicon: setFavicon,
		_setUrlHash: setUrlHash
	};

	// ---
	// PUBLIC METHODS.
	// ---

	/**
	* I initialize the app component.
	*/
	function $init() {

		if ( ! this._loadState() ) {

			this.palette.push( this._createRandomSwatch() );
			this.palette.push( this._createRandomSwatch() );
			this.palette.push( this._createRandomSwatch() );
			this.palette.push( this._createRandomSwatch() );
			this.palette.push( this._createRandomSwatch() );
			this._persistState();

		}

		this._focusHueAsync();
		this._checkPanelWidthAsync();

	}

	/**
	* I add a new (random) swatch to the current palette.
	*/
	function addSwatch() {

		this.palette.push( this._createRandomSwatch() );
		this.activeSwatchIndex = ( this.palette.length - 1 );
		this._persistState();
		this._focusHueAsync();
		this._checkPanelWidthAsync();

	}

	/**
	* I cycle all of the (unlocked) swatches in the palette.
	*/
	function cyclePalette() {

		this.palette = this.palette.map(
			( swatch ) => {

				if ( swatch.isLocked ) {

					return swatch;

				}

				return this._createRandomSwatch();

			}
		);
		this._persistState();

	}

	/**
	* I replace the swatch at the given index with a new, random swatch.
	*/
	function cycleSwatch( swatchIndex ) {

		if ( this.palette[ swatchIndex ].isLocked ) {

			return;

		}

		this.palette[ swatchIndex ] = this._createRandomSwatch();
		this._persistState();
		this._focusHueAsync();

	}

	/**
	* I render the current palette to a canvas and then generate a data URI in the given
	* anchor link.
	*/
	function downloadPaletteAsPNG( anchor ) {

		if ( ! this.palette.length ) {

			return;

		}

		var hexHeight = 70;
		var canvasHeight = ( swatchHeight + hexHeight );
		var canvasWidth = ( swatchWidth * this.palette.length );

		var canvas = document.createElement( "canvas" );
		var context = canvas.getContext( "2d" );
		canvas.width = canvasWidth;
		canvas.height = canvasHeight;

		// Provide a white background.
		context.fillStyle = "#ffffff";
		context.fillRect( 0, 0, canvasWidth, canvasHeight );

		// Add each swatch to the canvas.
		this.palette.forEach(
			( swatch, i ) => {

				// Add the colored square.
				context.fillStyle = swatch.hex;
				context.fillRect(
					( i * swatchWidth ),
					0,
					swatchWidth,
					swatchHeight
				);

				// Add the hex value.
				context.font = "36px monospace";
				context.fillStyle = "#121212";
				context.fillText(
					swatch.hex,
					( ( i * swatchWidth ) + 20 ),
					( swatchHeight + hexHeight - 20 )
				);

			}
		);

		// Override the anchor properties to force the download.
		anchor.href = canvas.toDataURL( "image/png" );
		anchor.download = this._generateDownloadFilename( "png" );

	}

	/**
	* I render the current palette to an SVG document and then generate a data URI in the
	* given anchor link.
	*/
	function downloadPaletteAsSVG( anchor ) {

		if ( ! this.palette.length ) {

			return;

		}

		var hexHeight = 70;
		var svgHeight = ( swatchHeight + hexHeight );
		var svgWidth = ( swatchWidth * this.palette.length );

		// All SVG elements need to be created with the SVG namespace.
		var ns = "http://www.w3.org/2000/svg";
		var svg = document.createElementNS( ns, "svg" );
		svg.setAttribute( "width", svgWidth );
		svg.setAttribute( "height", svgHeight );
		svg.setAttribute( "viewbox", `0 0 ${ svgWidth } ${ svgHeight }` );

		// Provide a white background.
		var rectNode = document.createElementNS( ns, "rect" );
		rectNode.setAttribute( "width", svgWidth );
		rectNode.setAttribute( "height", svgHeight );
		rectNode.setAttribute( "x", 0 );
		rectNode.setAttribute( "y", 0 );
		rectNode.setAttribute( "fill", "#ffffff" );
		svg.appendChild( rectNode );

		// Set the title.
		var titleNode = document.createElementNS( ns, "title" );
		titleNode.textContent = "Color Palette generated with Alpine.js";
		svg.appendChild( titleNode );

		// In the description, include the URL that was used to generate the palette. This
		// way, the user can open the SVG, grab the URL, and start editing it.
		var descNode = document.createElementNS( ns, "desc" );
		descNode.textContent = window.location.href;
		svg.appendChild( descNode );

		// Add each swatch to the SVG document.
		this.palette.forEach(
			( swatch, i ) => {

				// Add the colored square.
				var rectNode = document.createElementNS( ns, "rect" );
				rectNode.setAttribute( "width", swatchWidth );
				rectNode.setAttribute( "height", swatchHeight );
				rectNode.setAttribute( "x", ( i * swatchWidth ) );
				rectNode.setAttribute( "y", 0 );
				rectNode.setAttribute( "fill", swatch.hex );
				rectNode.setAttribute( "class", "swatch" );
				rectNode.setAttribute( "data-hue", swatch.hue );
				rectNode.setAttribute( "data-saturation", swatch.saturation );
				rectNode.setAttribute( "data-lightness", swatch.lightness );
				svg.appendChild( rectNode );

				// Add the hex value.
				var textNode = document.createElementNS( ns, "text" );
				textNode.textContent = swatch.hex;
				textNode.setAttribute( "x", ( ( i * swatchWidth ) + 20 ) );
				textNode.setAttribute( "y", ( swatchHeight + hexHeight - 20 ) );
				textNode.setAttribute( "fill", "#333333" );
				textNode.setAttribute( "font-family", "monospace" );
				textNode.setAttribute( "font-size", "36" );
				svg.appendChild( textNode );

			}
		);

		var svgMarkup = new XMLSerializer()
			.serializeToString( svg )
		;

		// Override the anchor properties to force the download.
		anchor.href = `data:image/svg+xml;base64,${ btoa( svgMarkup ) }`;
		anchor.download = this._generateDownloadFilename( "svg" );

	}

	/**
	* I duplicate the swatch at the given index.
	*/
	function duplicateSwatch( swatchIndex ) {

		var swatch = this.palette[ swatchIndex ];
		var newSwatch = this._createSwatch(
			swatch.hue,
			swatch.saturation,
			swatch.lightness
		);

		this.palette.splice( swatchIndex, 0, newSwatch );
		this.activeSwatchIndex += 1;
		this._persistState();
		this._focusHueAsync();
		this._checkPanelWidthAsync();

	}

	/**
	* I handle the user tabbing into a given swatch panel.
	*/
	function handleFocusin( swatchIndex ) {

		this.activeSwatchIndex = swatchIndex;

	}

	/**
	* I handle the user dragging-and-dropping an SVG / PNG swatch onto the window. Doing
	* so will load the given swatches into the palette.
	*/
	function handleGlobalDropEvents( event ) {

		// If we don't cancel the "dragover" and "drop" events, the browser will always
		// try to open the dropped file, regardless of what our event-handler does.
		event.preventDefault();

		if ( event.type === "dragover" ) {

			return;

		}

		// When the user drops the file, the use of the "shift" key will determine how the
		// colors are consumed within the UI. Without the "shift" key depressed, the
		// entire palette will be replaced. With the "shift" key depressed, the dropped
		// palette will be appended to the existing palette.
		// --
		// Note: We have to read this property before we enter the asynchronous control
		// flow since it seems to get reset (or maybe I'm just doing something wrong).
		var isResettingPalette = ! event.shiftKey;

		this._readColorsFromDropEvent( event ).then(
			( colors ) => {

				if ( isResettingPalette ) {

					this.palette.length = 0;
					this.activeSwatchIndex = 0;

				}

				for ( var color of colors ) {

					this.palette.push(
						this._createSwatch( color.hue, color.saturation, color.lightness )
					);

				}

				this._persistState();
				this._focusHueAsync();
				this._checkPanelWidthAsync();

			},
			( error ) => {

				console.error( error );

			}
		);

	}

	/**
	* I handle the location hashchange event, syncing the URL state back into the palette.
	*/
	function handleGlobalHashchange( event ) {

		var currentState = this._serializePalette();
		var urlState = window.location.hash.slice( 1 );

		if ( urlState && ( urlState !== currentState ) ) {

			this.palette.length = 0;
			this.activeSwatchIndex = 0;
			this._loadState();
			this._focusHueAsync();
			this._checkPanelWidthAsync();

		}

	}

	/**
	* I handle the global keyboard binding (for short-cuts).
	*/
	function handleGlobalKeydown( event ) {

		// If the event has already been intercepted and consumed and overridden, then we
		// don't want to consume it again at the global level.
		if ( event.defaultPrevented ) {

			return;

		}

		// If the event contains the META key, we want to let the browser perform its
		// native behaviors.
		if ( event.metaKey || event.ctrlKey ) {

			return;

		}

		// The following events depend on having a targeted swatch.
		if ( this.palette[ this.activeSwatchIndex ] ) {

			switch ( event.key ) {
				case "-":
				case "_":

					event.preventDefault();
					this.removeSwatch( this.activeSwatchIndex );

				break;
				case "[":
				case ",": // Left-angle bracket (<) (without shift).

					event.preventDefault();
					this.moveActiveIndexLeft();

				break;
				case "]":
				case ".": // Right-angle bracket (>) (without shift).

					event.preventDefault();
					this.moveActiveIndexRight();

				break;
				case "{":
				case "<":

					event.preventDefault();
					this.moveSwatchLeft( this.activeSwatchIndex );

				break;
				case "}":
				case ">":

					event.preventDefault();
					this.moveSwatchRight( this.activeSwatchIndex );

				break;
				case "h":

					event.preventDefault();
					this._focusHue();

				break;
				case "s":

					event.preventDefault();
					this._focusSaturation();

				break;
				case "l":

					event.preventDefault();
					this._focusLightness();

				break;
				case "t":

					event.preventDefault();
					this.toggleLock( this.activeSwatchIndex );

				break;
				case "d":

					event.preventDefault();
					this.duplicateSwatch( this.activeSwatchIndex );

				break;
				case "c":

					event.preventDefault();
					this.cycleSwatch( this.activeSwatchIndex );

				break;
			}

		}

		// The following events are global and don't required a color to exist.
		switch ( event.key ) {
			case " ":
			case "Spacebar":

				event.preventDefault();
				this.cyclePalette();

			break;
			case "+":
			case "=":

				event.preventDefault();
				this.addSwatch();

			break;
		}

	}

	/**
	* I handle paste events - users can paste 6-digit HEX colors into the browser and they
	* will be added to the palette.
	*/
	function handlePaste( event ) {

		event.preventDefault();

		var data = event.clipboardData
			?.getData( "text/plain" )
		;

		if ( ! data ) {

			return;

		}

		// Hex characters must be 6-digits.
		var matches = data.toLowerCase().match( /[0-9a-f]{6}/g );

		if ( ! matches ) {

			return;

		}

		for ( var match of matches ) {

			var red = parseInt( match.slice( 0, 2 ), 16 );
			var green = parseInt( match.slice( 2, 4 ), 16 );
			var blue = parseInt( match.slice( 4, 6 ), 16 );
			var color = rgbToHsl( red, green, blue );

			this.palette.push(
				this._createSwatch( color.hue, color.saturation, color.lightness )
			);

		}

		this.activeSwatchIndex = ( this.palette.length - 1 );
		this._persistState();
		this._focusHueAsync();
		this._checkPanelWidthAsync();

	}

	/**
	* I handle input[type=range] updates for one of the HSL channels in the given swatch.
	*/
	function handleRangeInput( event, swatch ) {

		// Since the ranges are bound to directly to the swatch properties via x-model,
		// all we need to do is recalculate the hex.
		swatch.hex = hslToHex( swatch.hue, swatch.saturation, swatch.lightness );
		// Whenever a swatch is manually edited, let's assume that the user want to lock
		// it so that it won't be overwritten accidentally on a future cycling.
		swatch.isLocked = true;

		this._persistState();

	}

	/**
	* I handle the keydown event on the HSL range input for more dynamic control.
	*/
	function handleRangeKeydown( event ) {

		var target = event.currentTarget;
		var currentValue = +target.value;
		var nextValue = currentValue;
		var min = +target.min;
		var max = +target.max;
		var step = +target.step;

		var smallJump = 5;
		var mediumJump = 10;
		var largeJump = 20;

		switch ( event.key ) {
			// Left / Right is for fine-tuning.
			// --
			// Note: Using the Left / Right arrows WITHOUT shift key will naturally
			// increment the value by the predefined step (combined with the x-model
			// binding). As such, we don't have to do anything special for those subset
			// of actions.
			case "ArrowLeft":

				if ( event.shiftKey ) {

					nextValue -= ( step * smallJump );

				}

			break;
			case "ArrowRight":

				if ( event.shiftKey ) {

					nextValue += ( step * smallJump );

				}

			break;
			// Up / Down is for course-tuning.
			case "ArrowUp":

				nextValue -= ( event.shiftKey )
					? ( step * largeJump )
					: ( step * mediumJump )
				;

			break;
			case "ArrowDown":

				nextValue += ( event.shiftKey )
					? ( step * largeJump )
					: ( step * mediumJump )
				;

			break;
			// Number keys are for arbitrary %-based jumps.
			case "1":
			case "2":
			case "3":
			case "4":
			case "5":
			case "6":
			case "7":
			case "8":
			case "9":

				nextValue = Math.round( ( max - min ) * ( +event.key / 10 ) );

			break;
		}

		// We only need to override the default behavior of the range input if the "next"
		// value that we calculated internally has been changed.
		// --
		// Note: In the following workflow, the $dispatch() magic (I believe) applies to
		// the current $el magic, which is set to the event's currentTarget (the input on
		// which the event handler was bound). When we dispatch the custom "input" event,
		// the x-model directive will catch it and adjust the model value as well as
		// reflect the change back into the range input control.
		if ( nextValue !== currentValue ) {

			event.preventDefault();
			this.$dispatch( "input", clamp( nextValue, min, max ) );

		}

	}

	/**
	* I handle the window resize event and check to see if the panels are getting too
	* narrow to display the tool buttons.
	*/
	function handleResize() {

		this._checkPanelWidth();

	}

	/**
	* I move the active index left by 1 place.
	*/
	function moveActiveIndexLeft() {

		this.activeSwatchIndex = arrayGetPrevIndex( this.palette, this.activeSwatchIndex );
		this._focusHueAsync();

	}

	/**
	* I move the active index right by 1 place.
	*/
	function moveActiveIndexRight() {

		this.activeSwatchIndex = arrayGetNextIndex( this.palette, this.activeSwatchIndex );
		this._focusHueAsync();

	}

	/**
	* I move the swatch at the given index left by 1 place.
	*/
	function moveSwatchLeft( swatchIndex ) {

		var futureIndex = arrayGetPrevIndex( this.palette, swatchIndex );

		arraySwap( this.palette, swatchIndex, futureIndex );
		this.activeSwatchIndex = futureIndex;
		this._persistState();
		this._focusHueAsync();

	}

	/**
	* I move the swatch at the given index right by 1 place.
	*/
	function moveSwatchRight( swatchIndex ) {

		var futureIndex = arrayGetNextIndex( this.palette, swatchIndex );

		arraySwap( this.palette, swatchIndex, futureIndex );
		this.activeSwatchIndex = futureIndex;
		this._persistState();
		this._focusHueAsync();

	}

	/**
	* I remove the swatch with the given index.
	*/
	function removeSwatch( swatchIndex ) {

		this.palette.splice( swatchIndex, 1 );
		this._persistState();
		this._checkPanelWidthAsync();

		if ( this.palette.length ) {

			this.activeSwatchIndex = clamp( swatchIndex, 0, ( this.palette.length - 1 ) );
			this._focusHueAsync();

		}

	}

	/**
	* I toggle the lock flag for the swatch with the given index. A locked swatch will not
	* be affected when cycling colors (either the entire palette or a single swatch).
	*/
	function toggleLock( swatchIndex ) {

		this.palette[ swatchIndex ].isLocked = ! this.palette[ swatchIndex ].isLocked;

	}

	// ---
	// PRIVATE METHODS.
	// ---

	/**
	* I check the panel width and decide if the panels are "narrow" (relative to the tools
	* that have to fit within them).
	*
	* Note: I originally tried to do this with a CSS container query; but, container
	* queries don't appear to play nicely with CSS Flexbox layouts. I'm sure there's a way
	* to get it to work; but, it wasn't a tangent I needed to run down.
	*/
	function checkPanelWidth() {

		this.isNarrowPanels = ( this.palette.length )
			? ( host.querySelector( ".panel" ).getBoundingClientRect().width < 250 )
			: false
		;

	}

	/**
	* I check the panel width in the NEXT TICK. This gives Alpine a chance to update the
	* DOM to reflect the current view-model.
	*/
	function checkPanelWidthAsync() {

		Alpine.nextTick( () => this._checkPanelWidth() );

	}

	/**
	* I create a swatch with random Hue, Saturation, and Lightness values.
	*/
	function createRandomSwatch() {

		var hue = randRange( 0, 360 );
		var saturation = randRange( 0, 100 );
		var lightness = randRange( 0, 100 );

		return this._createSwatch( hue, saturation, lightness );

	}

	/**
	* I create a swatch with the given Hue, Saturation, and Lightness values.
	*/
	function createSwatch( hue, saturation, lightness, isLocked ) {

		var hex = hslToHex( hue, saturation, lightness );

		return {
			id: ++swatchID,
			hue: hue,
			saturation: saturation,
			lightness: lightness,
			hex: hex,
			isLocked: ( isLocked || false )
		};

	}

	/**
	* I focus the hue input in the active panel.
	*/
	function focusHue() {

		host.querySelector( ".panel--active input.hue" )
			?.focus()
		;

	}

	/**
	* I focus the hue input in the active panel in the NEXT TICK. This gives Alpine a
	* chance to update the DOM to reflect the current view-model.
	*/
	function focusHueAsync() {

		Alpine.nextTick( () => this._focusHue() );

	}

	/**
	* I focus the lightness input in the active panel.
	*/
	function focusLightness() {

		host.querySelector( ".panel--active input.lightness" )
			?.focus()
		;

	}

	/**
	* I focus the saturation input in the active panel.
	*/
	function focusSaturation() {

		host.querySelector( ".panel--active input.saturation" )
			?.focus()
		;

	}

	/**
	* I generate the filename for the palette download.
	*/
	function generateDownloadFilename( fileExtension ) {

		// TODO: I think some systems run into an issue with long filenames. As such, it
		// might make sense to have a max-length check and then use a slightly different
		// approach for really large palettes?? But, not a problem I have to worry about
		// at this point.

		var stub = this.palette
			.map(
				( swatch ) => {

					return swatch.hex.slice( -6 );

				}
			)
			.join( "-" )
		;

		return `palette-${ stub }.${ fileExtension }`;

	}

	/**
	* I load state that has been persisted in the URL fragment.
	*/
	function loadState() {

		// This pattern is a bit complicated. It will match either an integer or an
		// integer followed by a decimal place.
		var matches = window.location.hash
			.slice( 1 )
			.match( /\d+(\.\d+)?,\d+(\.\d+)?,\d+(\.\d+)?/g )
		;

		if ( ! matches ) {

			return 0;

		}

		for ( var match of matches ) {

			var channels = match.split( "," );
			var hue = clamp( +channels[ 0 ], 0, 360 );
			var saturation = clamp( +channels[ 1 ], 0, 100 );
			var lightness = clamp( +channels[ 2 ], 0, 100 );

			this.palette.push( this._createSwatch( hue, saturation, lightness ) );

		}

		this._setDocumentTitle();
		this._setFavicon();

		return this.palette.length;

	}

	/**
	* I (eventually) persist the current palette to the URL fragment.
	*
	* Note: Because we might potentially be triggering a large number of URL changes based
	* on input-range sliders, we want to debounce the persistence throughput so that the
	* browser doesn't complain about the volume of URL updates.
	*/
	function persistState() {

		clearTimeout( persistanceTimer );

		persistanceTimer = setTimeout(
			() => {
				this._persistStateSync();
			},
			500
		);

	}

	/**
	* I implement the synchronous persistence operation to the URL.
	*/
	function persistStateSync() {

		this._setUrlHash();
		this._setDocumentTitle();
		this._setFavicon();

	}

	/**
	* I read the swatch colors (HSL) from the file dropped in the given event.
	*/
	async function readColorsFromDropEvent( event ) {

		if ( ! event.dataTransfer?.items?.length ) {

			throw( new Error( "No items dropped." ) );

		}

		var item = event.dataTransfer.items[ 0 ];

		if ( item?.kind !== "file" ) {

			throw( new Error( "Dropped item is not a file." ) );

		}

		var file = item.getAsFile();

		if ( file.type === "image/svg+xml" ) {

			return this._readColorsFromSvgFile( file );

		} else if ( file.type === "image/png" ) {

			return this._readColorsFromPngFile( file );

		}

		throw( new Error( "Dropped file must be SVG or PNG palette." ) );

	}

	/**
	* I parse the given PNG file (generated by the color utility) and extract the HSL
	* values embedded within the pixel data.
	*/
	async function readColorsFromPngFile( file ) {

		var canvas = document.createElement( "canvas" );
		var context = canvas.getContext(
			"2d",
			// Chrome dev tools tells me that setting this value is important (for
			// performance) when I am going to call .getImageData() multiple times, which
			// I do, once per pixel.
			{ willReadFrequently: true }
		);
		var tempImageUrl = URL.createObjectURL( file );

		try {

			var image = new Image();

			await new Promise(
				( resolve, reject ) => {

					image.onload = resolve;
					image.onerror = reject;
					image.src = tempImageUrl;

				}
			);

			canvas.width = image.width;
			canvas.height = image.height;
			context.drawImage( image, 0, 0 );

		} finally {

			// Now that we've written the image to the canvas, we can free up the memory
			// being used by the blob.
			URL.revokeObjectURL( tempImageUrl );

		}

		// We want to grab from the center of the swatch.
		var x = Math.floor( swatchWidth / 2 );
		var y = Math.floor( swatchHeight / 2 );
		var colors = [];

		for ( ; x < canvas.width ; x += swatchWidth ) {

			var imageData = context.getImageData( x, y, 1, 1 );
			// Pixel data is stored as a single contiguous array in which the RGBA data is
			// stored in subsequent indices. Therefore, when we get a 1x1 slice of the
			// canvas image data, it returns an array of length 4 in which the 4 indices
			// represent Red, Green, Blue, and Alpha, respectively.
			colors.push(
				rgbToHsl(
					imageData.data[ 0 ],
					imageData.data[ 1 ],
					imageData.data[ 2 ]
				)
			);

		}

		return colors;

	}

	/**
	* I parse the given SVG file (generated by the color utility) and extract the HSL
	* values embedded within the markup.
	*/
	async function readColorsFromSvgFile( file ) {

		var xmlContent = await file.text();
		var xml = new DOMParser()
			.parseFromString( xmlContent, "image/svg+xml" )
		;

		var colors = [];

		for ( var node of xml.querySelectorAll( ".swatch" ) ) {

			colors.push({
				hue: ( +node.dataset.hue || 0 ),
				saturation: ( +node.dataset.saturation || 0 ),
				lightness: ( +node.dataset.lightness || 0 )
			});

		}

		return colors;

	}

	/**
	* I serialize the palette into a URL-safe string.
	*/
	function serializePalette() {

		var serialized = this.palette
			.map(
				( swatch ) => {

					return `(${ swatch.hue },${ swatch.saturation },${ swatch.lightness })`;

				}
			)
			.join( "," )
		;

		return serialized;

	}

	/**
	* I set the document title using the current swatch hex codes.
	*/
	function setDocumentTitle() {

		document.title = this.palette
			.map(
				( swatch ) => {

					return swatch.hex;

				}
			)
			.join( ", " )
		;

	}

	/**
	* I render a dynamic favicon using the current swatch hex codes.
	*/
	function setFavicon() {

		document.querySelector( "link.icon" )
			?.remove()
		;

		if ( ! this.palette.length ) {

			return;

		}

		var canvas = document.createElement( "canvas" );
		canvas.width = 64;
		canvas.height = 64;

		var colorWidth = Math.ceil( canvas.width / this.palette.length );
		var context = canvas.getContext( "2d" );

		this.palette.forEach(
			( swatch, i ) => {

				context.fillStyle = swatch.hex;
				context.fillRect(
					( i * colorWidth ),
					0,
					colorWidth,
					canvas.height
				);

			}
		);

		var link = document.createElement( "link" );
		link.classList.add( "icon" );
		link.type = "image/x-icon";
		link.rel = "shortcut icon";
		link.href = canvas.toDataURL( "image/x-icon" );

		document.head.appendChild( link );

	}

	/**
	* I set the URL fragment using the serialized palette.
	*/
	function setUrlHash() {

		// Since the URL stores the current color selections, it means that tweaking the
		// colors will lead to many new URLs. To help reduce the many intermediary URLs on
		// the way to a desired outcome, we're going to "debounce" some URL changes by
		// using replaceState() if when the URL is set with high frequency.
		var now = Date.now();
		var cuttoff = ( 10 * 1000 ); // 10 seconds.

		var isReplace = (
			// When setting the hash for the first time, always replace.
			! window.location.hash ||
			// When the hash already exists, only replace when the URL has already been
			// explicitly set (and is before the debouncing cutoff). This way, if a page
			// with an existing hash is refreshed, the initial state is always kept in the
			// history even when the colors are tweaked immediately after load.
			( urlSetAt && ( ( now - urlSetAt ) < cuttoff ) )
		);

		urlSetAt = now;

		if ( isReplace ) {

			window.history.replaceState( null, null, ( "#" + this._serializePalette() ) );

		} else {

			window.location.hash = this._serializePalette();

		}

	}

}

// ----------------------------------------------------------------------------------- //
// Utility Function - these aren't tied to any component state.
// ----------------------------------------------------------------------------------- //

/**
* I get the next available index, cycling back to the head of the array as needed.
*/
function arrayGetNextIndex( array, i ) {

	if ( i === ( array.length - 1 ) ) {

		return 0;

	}

	return ( i + 1 );

}

/**
* I get the previous available index, cycling back to the tail of the array as needed.
*/
function arrayGetPrevIndex( array, i ) {

	if ( i === 0 ) {

		return ( array.length - 1 );

	}

	return ( i - 1 );

}

/**
* I swap the elements at the given indices.
*/
function arraySwap( array, m, n ) {

	var temp = array[ m ];
	array[ m ] = array[ n ];
	array[ n ] = temp;

	return array;

}

/**
* I return the given value, constrained to the given min/max inclusive.
*/
function clamp( value, min, max ) {

	if ( value < min ) {

		return min;

	}

	if ( value > max ) {

		return max;

	}

	return value;

}

/**
* I convert the given HSL values to a HEX color string.
*/
function hslToHex( hue, saturation, lightness ) {

	var channels = hslToRgb( hue, saturation, lightness );

	return (
		"#" +
		padHex( channels.red.toString( 16 ) ) +
		padHex( channels.green.toString( 16 ) ) +
		padHex( channels.blue.toString( 16 ) )
	);

}

/**
* I convert the given HSL values to RGB channels.
*
* CAUTION: I have no idea how this method works. It's all math that I don't know about. I
* grabbed it from Stack Overflow (and then modified it to make it a bit more readable):
*
* https://stackoverflow.com/questions/2353211/hsl-to-rgb-color-conversion
*/
function hslToRgb( hue, saturation, lightness ) {

	saturation /= 100;
	lightness /= 100;

	var a = ( saturation * Math.min( lightness, 1 - lightness ) );

	return {
		red: Math.round( getChannel( 0 ) * 255 ),
		green: Math.round( getChannel( 8 ) * 255 ),
		blue: Math.round( getChannel( 4 ) * 255 )
	};

	function getChannel( n ) {

		var k = ( ( n + hue / 30 ) % 12 );

		return ( lightness - ( a * Math.max( Math.min( ( k - 3 ), ( 9 - k ), 1 ), -1 ) ) );

	}

}

/**
* I ensure that the given hex value is two digits.
*/
function padHex( value ) {

	return ( "0" + value ).slice( -2 );

}

/**
* I produce a random value between 0 (inclusive) and 1 (exclusive).
*/
function randFloat() {

	// First, we'll try the Web Crypto API, which hopefully produces values with better
	// randomness. Then, if that fails, we'll fallback to the traditional Math.random()
	// method.
	try {

		var maxValue = 65535;
		var values = window.crypto.getRandomValues( new Uint16Array( 1 ) );

		return ( values[ 0 ] / ( maxValue + 1 ) );

	} catch ( error ) {

		console.error( error );
		return Math.random();

	}

}

/**
* I return a random number between the min and max, inclusive.
*/
function randRange( min, max ) {

	return ( min + Math.floor( randFloat() * ( max - min + 1 ) ) );

}

/**
* I convert the given RGB channels to HSL values.
*
* CAUTION: I have no idea how this method works. It's all math that I don't know about. I
* grabbed it from CSS Tricks (and then modified it to make it a bit more readable):
*
* https://css-tricks.com/converting-color-spaces-in-javascript/#aa-rgb-to-hsl
*/
function rgbToHsl( red, green, blue ) {

	red /= 255;
	green /= 255;
	blue /= 255;

	var hue = 0;
	var saturation = 0;
	var lightness = 0;
	var cmin = Math.min( red, green, blue );
	var cmax = Math.max( red, green, blue );
	var delta = ( cmax - cmin );

	if ( ! delta ) {

		hue = 0;

	} else if ( cmax === red ) {

		hue = ( ( ( green - blue ) / delta ) % 6 );

	} else if ( cmax === green ) {

		hue = ( ( ( blue - red ) / delta ) + 2 );

	} else {

		hue = ( ( ( red - green ) / delta ) + 4 );

	}

	hue = Math.round( hue * 60 );

	if ( hue < 0 ) {

		hue += 360;

	}

	lightness = ( ( cmax + cmin ) / 2 );

	saturation = ( delta )
		? ( delta / ( 1 - Math.abs( ( 2 * lightness ) - 1 ) ) )
		: 0
	;

	saturation = +( saturation * 100 ).toFixed( 1 );
	lightness = +( lightness * 100 ).toFixed( 1 );

	return {
		hue: hue,
		saturation: saturation,
		lightness: lightness
	};

}

This was a so much fun to build! Happy Friday!

Want to use code from this post? Check out the license.

Reader Comments

15,752 Comments

In this post, I used the Crypto.getRandomValues() method - which I learned about while writing this post - to randomly select HSL values. I just assumed that this method was going to be "more random" than Math.random(). But, is it? I wanted to take a closer look:

www.bennadel.com/blog/4669-exploring-randomness-in-javascript.htm

While the Crypto module might be more "secure", both the Crypto and Math modules seem to provide the same level of randomness from a human perspective. So, as long as I don't need cryptographically secure randomization, I probably should have just used Math.random() - it's much faster.

Post A Comment — I'd Love To Hear From You!

Post a Comment

I believe in love. I believe in compassion. I believe in human rights. I believe that we can afford to give more of these gifts to the world around us because it costs us nothing to be decent and kind and understanding. And, I want you to know that when you land on this site, you are accepted for who you are, no matter how you identify, what truths you live, or whatever kind of goofy shit makes you feel alive! Rock on with your bad self!
Ben Nadel