javascript 58 lines · 9 steps

Building a two-way color picker in JS

Convert between hex and RGB, then wire both representations to stay in sync as the user edits either one.

Explained by highlit
1function hexToRgb(hex) {
2 const normalized = hex.replace(/^#/, '');
3 const full = normalized.length === 3
4 ? normalized.split('').map((c) => c + c).join('')
5 : normalized;
6 
7 if (!/^[0-9a-f]{6}$/i.test(full)) {
8 throw new Error(`Invalid hex color: ${hex}`);
9 }
10 
11 const int = parseInt(full, 16);
12 return {
13 r: (int >> 16) & 255,
14 g: (int >> 8) & 255,
15 b: int & 255,
16 };
17}
18 
19function rgbToHex({ r, g, b }) {
20 const clamp = (n) => Math.max(0, Math.min(255, Math.round(n)));
21 const toHex = (n) => clamp(n).toString(16).padStart(2, '0');
22 return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
23}
24 
25function initColorPicker(root) {
26 const swatch = root.querySelector('[data-swatch]');
27 const hexInput = root.querySelector('[data-hex]');
28 const channels = ['r', 'g', 'b'].map((key) => ({
29 key,
30 el: root.querySelector(`[data-channel="${key}"]`),
31 }));
32 
33 const render = (rgb) => {
34 swatch.style.backgroundColor = `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`;
35 hexInput.value = rgbToHex(rgb);
36 channels.forEach(({ key, el }) => { el.value = rgb[key]; });
37 };
38 
39 channels.forEach(({ el }) => {
40 el.addEventListener('input', () => {
41 const rgb = channels.reduce(
42 (acc, { key, el }) => ({ ...acc, [key]: Number(el.value) }),
43 {},
44 );
45 render(rgb);
46 });
47 });
48 
49 hexInput.addEventListener('change', () => {
50 try {
51 render(hexToRgb(hexInput.value));
52 } catch {
53 hexInput.classList.add('invalid');
54 }
55 });
56 
57 render(hexToRgb(hexInput.value || '#3b82f6'));
58}
01 / 01
STEP 01

Walkthrough

Space play step click any line
Three takeaways
  1. 1Packing RGB into a single integer lets bit shifts and masks pull each channel back out cleanly.
  2. 2A single render function fed by conversions keeps multiple UI representations of one value in sync.
  3. 3Validating and clamping at the conversion boundary keeps malformed input from corrupting the shared state.

Related explainers

typescript
import { NestFactory } from '@nestjs/core';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { ValidationPipe } from '@nestjs/common';
import { ApiProperty } from '@nestjs/swagger';

Wiring validation and Swagger docs in NestJS

validation openapi decorators
Intermediate 8 steps
javascript
const ROLE_PERMISSIONS = {
  admin: ['users:read', 'users:write', 'billing:read', 'billing:write'],
  manager: ['users:read', 'billing:read'],
  member: ['users:read'],

Role-based permissions middleware in Express

authorization middleware rbac
Intermediate 9 steps
javascript
function attachThousandSeparators(input, { locale = 'en-US' } = {}) {
  const formatter = new Intl.NumberFormat(locale);
  const groupSep = formatter.format(11111).replace(/\d/g, '')[0] || ',';
  const decimalSep = formatter.format(1.1).replace(/\d/g, '')[0] || '.';

Live thousand separators without losing the caret

dom intl caret-preservation
Advanced 8 steps
javascript
import { useReducer, useEffect } from "react";
 
const initialState = { status: "idle", data: null, error: null };
 

Building a data-fetching hook in React

custom-hooks usereducer data-fetching
Intermediate 9 steps
javascript
const express = require('express');
const app = express();
 
app.get('/health', (req, res) => res.json({ status: 'ok' }));

Graceful shutdown in an Express server

graceful-shutdown signal-handling connection-tracking
Advanced 9 steps
typescript
import { parsePhoneNumberFromString, CountryCode } from 'libphonenumber-js';
 
export interface NormalizedPhone {
  e164: string;

Normalizing phone numbers to E.164 in TypeScript

validation normalization error-handling
Intermediate 7 steps

Share this explainer

Here's the card — post it anywhere.

Building a two-way color picker in JS — share card
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code