Color Wheel Picker HTML CSS JS — HSL Canvas Picker

Color Wheel Picker · Forms · Plain HTML, CSS & JS · Live preview

Share & Support

What's included

Features

Canvas hue ring: 360 wedge segments drawn in HSL with an inner punch-out for the band
Two-gradient square: white-to-hue horizontal over transparent-to-black vertical maps saturation and brightness
Polar hit testing: distance and atan2 detect and decode ring clicks into hue
Cartesian hit testing: square clicks read normalized x and y as saturation and brightness
HSL to RGB: compact chroma-based conversion without a six-case branch
Hex parsing: hexToHsl and rgbToHex convert in both directions for typed input
Live multi-format output: synchronized HEX, RGB and HSL readouts
Recent colors: de-duplicated eight-swatch history, each clickable to restore
Clipboard copy: navigator.clipboard.writeText with a Copied confirmation
Pointer Events: unified mouse, touch and pen dragging via setPointerCapture

About this UI Snippet

How to Build an HSL Color Wheel Picker on HTML5 Canvas

Screenshot of the Color Wheel Picker snippet rendered live

A color wheel picker lets users choose a color by dragging a handle around a circular hue ring and then refining saturation and brightness inside an inner square — the layout popularized by Photoshop and many design tools. This implementation is drawn entirely on a single HTML5 Canvas with the 2D API, and it converts freely between HSL, RGB and hex so users can read or type any format. Here is how every part works.

Drawing the hue ring

The outer ring represents hue from 0° to 360°. It is drawn as 360 thin wedge segments in a loop. For each integer angle a, the code computes a start and end angle in radians, draws a filled triangle-pie slice from the center out to RING_OUTER with ctx.moveTo(cx, cy) followed by ctx.arc, and fills it with hsl(a, 100%, 50%). Drawing one wedge per degree (with a half-degree overlap to avoid seams) produces a smooth rainbow. After the full disc is painted, a circle of the background color is drawn at RING_INNER radius to "punch out" the middle, leaving just the ring band. This subtractive technique is simpler than computing an annulus path.

Drawing the saturation-brightness square

The inner square shows all saturation and brightness values for the currently selected hue. It is built from two overlaid createLinearGradient fills, the classic two-gradient method:

First, a horizontal gradient runs from white on the left to the pure hue (hsl(hue, 100%, 50%)) on the right — this is the saturation axis. Then a vertical gradient runs from transparent at the top to solid black at the bottom — this is the brightness axis. Painting the second over the first multiplicatively darkens the lower rows, so the top-left corner is white, the top-right is the saturated hue, and the entire bottom edge is black. Any point in the square maps to a unique saturation (x position) and brightness (y position) for that hue.

Hit testing clicks: polar and Cartesian

When the user presses on the canvas, the code converts the pointer to canvas coordinates and measures the distance from the center with Math.sqrt(dx*dx + dy*dy). If that distance falls between RING_INNER and RING_OUTER, the click is on the hue ring and dragging is set to 'hue'. If instead the point lies within the square's half-size box on both axes, dragging is set to 'sb'. This is polar hit testing for the ring and Cartesian hit testing for the square.

While dragging the ring, hue is recovered from the angle: hue = (atan2(dy, dx) * 180/PI + 90 + 360) % 360. The atan2 gives the angle of the pointer around the center; the +90 offset aligns 0° to the top, and the modulo keeps it in range. While dragging the square, saturation and brightness are read directly from the normalized x and y within the square and clamped to the 0–1 range. Every move calls updateUI, which redraws the wheel with both handles and refreshes the text readouts.

The color math: HSL, RGB and hex

The picker works internally in an HSV/HSB-like saturation-brightness space for the square, but outputs standard CSS HSL, RGB and hex. hslToRgb implements the well-known conversion using the chroma helper: it defines k(n) = (n + h/30) % 12, computes a = s * min(l, 1-l), and derives each channel as l - a * max(-1, min(k(n)-3, 9-k(n), 1)) for the appropriate n per channel. This compact formulation avoids the long six-case branch of the traditional algorithm.

rgbToHex maps each channel to a two-digit hex string with toString(16).padStart(2, '0'). The reverse, hexToHsl, slices the hex string, parses each pair with parseInt(..., 16), normalizes to 0–1, finds the min and max channels to get lightness, derives saturation from the chroma, and computes hue from whichever channel is the maximum — the standard RGB-to-HSL routine. Because the square uses brightness rather than lightness, getCurrentColor converts the internal saturation-brightness pair into HSL lightness before producing the displayed values, and setFromHex does the inverse so a pasted hex positions both handles correctly.

Recent colors and input

Every time a drag ends or a hex is entered, addRecent pushes the color to the front of a recentColors array, de-duplicates it, and trims to eight swatches. renderRecent rebuilds the swatch row, each clickable to restore that color via setFromHex. The hex input accepts a typed value on Enter, normalizes a missing leading #, and feeds it through the same conversion path. A copy button writes the current hex to the clipboard with navigator.clipboard.writeText.

Because everything is rendered on canvas and computed with plain math, the picker is fully self-contained, works with mouse, touch and pen through Pointer Events with setPointerCapture, and can be themed or resized just by changing a few constants.

Build with AI

Build, Understand, Optimize, and Extend It With AI

You don't have to derive the polar-versus-Cartesian hit testing yourself to see why it works. Paste this snippet's HTML, CSS, and JS into an AI coding assistant like Claude and ask it to explain exactly why the hue ring is drawn as 360 separate wedge slices instead of a single conic gradient, and how atan2 turns a pointer position back into a hue angle. The same assistant can help optimize it — for instance asking whether the ring needs to be fully redrawn on every pointermove event or whether only the handle positions need to move on top of a cached wheel image. It's also useful for extending the picker: ask it to add an alpha/opacity slider alongside hue and saturation-brightness, support arrow-key nudging of the selected hue for accessibility, or swap the saturation-brightness square for an HSL-native square so lightness and hue stay in the same color model throughout. Treat the code less like a finished artifact and more like a starting point for a conversation.

Prompt to recreate it

Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:

text
Build an HSL/HSB color wheel picker in plain HTML, CSS, and JavaScript rendered entirely on an HTML5 canvas using the 2D context — no canvas libraries, no color-picker packages.

Requirements:
- Draw a circular hue ring by filling 360 individual thin pie-slice wedges from the center outward, one per degree with a slight angular overlap to avoid seams, each filled with its own hsl(angle, 100%, 50%) color, then punch out the middle by filling a smaller concentric circle in the background color to leave only an annular band.
- Draw a saturation-brightness square inside the ring using two overlaid canvas linear gradients: a horizontal gradient from white to the currently selected pure hue for saturation, and a vertical gradient from transparent to black for brightness, so every point in the square maps to a unique saturation/brightness combination for the active hue.
- Implement hit testing that distinguishes the two draggable regions: measure the pointer's distance from the canvas center to detect ring clicks (between the inner and outer ring radius), and check both axes against the square's half-size bounds separately to detect square clicks.
- While dragging the ring, recompute hue from the pointer angle using atan2, converting radians to degrees, offsetting so 0 degrees sits at the top, and wrapping with modulo 360. While dragging the square, compute saturation and brightness as the normalized, clamped x and y position within the square.
- Support pointer, touch, and pen input uniformly using Pointer Events with setPointerCapture so dragging keeps tracking even if the pointer leaves the canvas bounds.
- Convert between the internal saturation/brightness space and standard HSL, RGB, and hex on every update, and display all three formats live plus a color preview swatch.
- Maintain a deduplicated, most-recent-first history of the last several picked colors as clickable swatches, and support typing a hex value directly to reposition both handles.

Step by step

How to Use

  1. 1
    Pick a hueDrag the handle around the outer ring to choose the base color from the full 360-degree spectrum.
  2. 2
    Refine the shadeDrag inside the inner square to set saturation horizontally and brightness vertically for the chosen hue.
  3. 3
    Read the valuesSee the live HEX, RGB and HSL readouts update as you move the handles.
  4. 4
    Type a hex codeEnter a hex value and press Enter to position both handles to that exact color.
  5. 5
    Copy the colorClick Copy to put the current hex code on your clipboard.
  6. 6
    Reuse recent colorsClick any swatch in the recent colors row to instantly restore that color.

Real-world uses

Common Use Cases

Theme and brand pickers
Let users choose accent colors in a design tool, pairing naturally with an image filter editor.
Settings and customization
Provide a richer alternative to the native color input for app preferences and profile theming.
Drawing app palettes
Feed the selected color into a drawing canvas as the active brush color.
CSS color tooling
Generate and copy HEX, RGB or HSL strings ready to paste into stylesheets.
Teaching color models
Visualize how hue, saturation, brightness and lightness relate across HSL and RGB.
Dashboard accent config
Allow users to recolor charts and widgets with a precise, readable picker.

Got questions?

Frequently Asked Questions

Canvas gradients cannot follow a circular path, so a conic rainbow is approximated by filling one thin pie slice per degree with its HSL color. A slight angular overlap between slices prevents visible seams.

Two linear gradients are layered: a horizontal white-to-hue gradient for saturation and a vertical transparent-to-black gradient for brightness. Overlaying them makes every point in the square a unique saturation and brightness for the current hue.

The angle of the pointer relative to the center is computed with atan2, converted to degrees, offset so 0 degrees is at the top, and wrapped with modulo 360. The distance from center confirms the click was inside the ring band.

The square works in saturation-brightness (HSB/HSV) space, but the readouts and hex output use HSL. The code converts between them so the displayed HSL values and any pasted hex map correctly onto the handle positions.

Yes. It uses Pointer Events with setPointerCapture, so dragging works identically with mouse, touch and pen, and the handle keeps tracking even if the pointer leaves the canvas mid-drag.