Skip to content

Color Picker

Located in src/components/HctColorPicker.tsx. A compact inline color picker with three sliders.

Sliders

SliderRangeVisual
Hue (H)0--360Rainbow gradient
Chroma (C)0--150Gradient from neutral gray to full saturation at the current hue/tone
Tone (T)0--100Gradient from black through the current hue/chroma to white

All sliders use step 0.1. Drag modifiers: Shift = snap to integer, Alt/Option = 0.1 precision. Slider thumbs show grab/grabbing cursors.

Slider geometry. Track is h-6 (24 px) with rounded-md corners. Thumb is a 24×24 rounded-md square that echoes the rounded track and matches the adjacent numeric Input's height. The small focus/ring indicator inside the thumb stays circular.

Live track gradients are memoised per-axis inside HctColorPicker: the hue track only recomputes when chroma or tone changes, the chroma track only when hue or tone changes, and the tone track only when hue or chroma changes. Keeps drag rendering cheap since two of the three inline linear-gradient track strings (built with the local hctToHexSafe helper) stay referentially stable during any given axis drag.

Adaptive debounce. onChange commits to the zustand store through a debouncedOnChange whose window scales with theme complexity via pickerDebounceForTheme(theme):

ts
entityCost = sum( max(1, colorIds) × max(1, contrastRatios|levelConfig.count) × max(1, parentEntityIds) )
base       = entityCost × enabledModes × enabledInteractions × enabledConditions
weight     = base + totalGradientPoints × 2   // totalGradientPoints = Σ color.hex.length
ms         = clamp(16 + round(weight × 0.08), 16, 200)

That means a tiny theme (≤ 4 entities, 1 mode, no conditions/interactions) commits at ~17 ms (perceptually live preview), a busy production theme (10+ colors × 2 modes × 5 interactions with 4 ratios per entity) backs off to ~150 ms, and a worst-case theme caps at 200 ms. The heavier downstream regenerateTokens and JSON.stringify(isDirty) are further debounced inside the store (300 ms / 150 ms respectively) so they only fire after the drag stops.

Slider & Number Input Keyboard Shortcuts

Sliders and number inputs use slightly different arrow steps:

Slider (focused thumb) — arrows also respond to Left/Right:

KeyStep
Arrow+/- 0.1
Shift + Arrow+/- 10
Alt/Option + Arrow+/- 0.01

Number input (H / C / T):

KeyStep
Arrow Up / Down+/- 1
Shift + Arrow+/- 10
Alt/Option + Arrow+/- 0.1

Dragging a slider thumb snaps differently again: Shift = snap to integer, Alt/Option = 0.1 precision, no modifier = round to integer.

All number inputs use free-text editing — type freely, value is applied on blur or Enter. Commas are auto-replaced with dots.

Gamut Clamping

When the requested HCT values exceed the sRGB gamut, the picker detects the discrepancy by round-tripping through hctToHex then hexToHct and comparing all three channels. Any channel (Hue, Chroma, or Tone) that drifts by more than 0.5 after the round-trip is flagged — hue uses shortest-arc distance on the 360° wheel so values near 0°/360° don't false-positive.

When clamped, a snap affordance appears overlaid on the right edge of the hex input:

  • Default editor layout (gridAligned): a compact Snap pill. Its tooltip reads "Snap to gamut" with the exact drifting channels listed below (e.g. H:204.8 C:45.2).
  • Legacy standalone layout: a button showing the drifting channels inline (H:X C:Y) with a warning icon; tooltip "Clamped — click to snap to gamut".

Either way, clicking snaps only the drifted channels to their gamut-safe (round-tripped) values, rounded to 0.1.

Hex Input

A #RRGGBB text input with round-trip validation. Shorthand expansion on blur (e.g. fff#FFFFFF).

Swatch Copy & Native Picker

The preview pill at the top of the picker is split into two interactive halves on top of the same color background. Hovering anywhere on the swatch reveals both icons at once; only the half under the cursor gets the bg highlight (hover:bg-black/10 dark:hover:bg-white/10) so the click target is unambiguous. Tooltips use shadcn Tooltip (not native title).

  • Left half -- click to copy hex. Uses textarea + execCommand("copy") directly: Figma's plugin iframe lacks clipboard-write in its permissions-policy, so navigator.clipboard.writeText would always reject AND log a console violation. After click, "Done" feedback shows for 1.5s. Adaptive icon contrast (tone > 50 ? rgba(0,0,0,0.85) : rgba(255,255,255,0.85)) — same logic used by gradient marker dots, just at higher opacity for legibility.
  • Right half -- click to open the OS-native color picker (<input type="color">). Hover reveals a pencil-edit icon (IconEditPencil from coolicons — coolicons has no eyedropper; the pencil semantically conveys "edit color"). Selecting a color updates the picker via handleHexChange, but is routed through the same adaptive debouncedOnChange (16–200 ms depending on theme complexity) the HCT sliders use, so rapid onChange events from the OS picker during drag don't trigger full-theme regeneration on every frame.

Gradient Markers

When a color has multiple gradient points, SVG pin markers appear above the gradient bar:

  • Drag markers to reposition (0.1% precision, Shift = 10% snap). The marker toggles its cursor grab → grabbing via React drag state (draggingPid === pid) rather than CSS :active. (The Slider thumb and SortableItem drag handles are the ones that use the CSS cursor-grab active:cursor-grabbing pattern.)
  • Click the gradient bar to add a new point (color interpolated from surrounding points via CAM16-UCS)
  • Arrow keys on focused marker: ±1%, Shift ±10%
  • Markers sort automatically as positions change, with stable identity tracking across re-renders
  • Editing a color point highlights the corresponding marker with a stroke indicator
  • Dashed outline on markers when contrast with the background is insufficient — mode-dependent: light markers (tone > 85) in light mode, dark markers (tone < 15) in dark mode

Active Point Highlight

While a gradient marker is being dragged or arrow-key navigated, and while focus is inside any input/slider of a particular point's editor block, the entire wrapper block of that point gets a border-foreground outline instead of the default border-border. This makes it visually obvious which gradient point you're currently manipulating — useful when a color has many points and the markers visually overlap. The signal is plumbed through EasingSelectWithPreview's onActivePidChange callback (for marker drag) plus editingPointPid state (for focus-inside).

Position Input

Each gradient point has a position % input with free-text editing, arrow key stepping (±1%, Shift ±10%), and automatic sorting with focus transfer on Enter.

Gradient Interpolation

The gradient bar uses CAM16-UCS perceptual blending with position-based segments. Hard edges are rendered correctly when two points share the same position (no color bleeding). The CSS gradient is generated with 32+ sample stops plus extra stops at each control point for sharp transitions.