Gradient Picker
A CSS gradient editor — draggable stop rail, angle dial, per-stop colour editing, and a live read-only linear/radial/conic CSS string.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/gradient-picker.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "GradientPicker" component: a visual editor
for one CSS gradient (lucide-react Check/Copy/CopyX/Plus/Trash2 icons, a cn()
class merger, no colour library — the small amount of colour maths is inline).
Contract
- export type GradientType = "linear" | "radial" | "conic"
- export interface GradientStop { id: string; color: string; position: number }
* id — stable identity; it is the React key and it survives a stop being
dragged past its neighbours, so the array is never reordered.
* color — any CSS colour the consumer wants. The built-in editor writes
lowercase hex (8 digits when alpha < 1); consumer-supplied values in
other notations are preserved until that stop is edited.
* position — 0-100.
- export interface GradientValue { type: GradientType; angle: number; stops: GradientStop[] }
- export function gradientToCss(value: GradientValue): string — the same
serialiser the component renders, exported so a parent can paint a preview.
It runs the normalisation pass first, so a hand-written value (angle 720, a
stop at 130%) still comes out as CSS the browser accepts.
- Props (forwardRef div, spreads the rest, omits native onChange/defaultValue):
value?, defaultValue?, onValueChange? → controlled and uncontrolled both work;
when `value` is absent the component keeps its own state.
types?: GradientType[] (default all three; an empty array falls back to all three)
swatches?: string[] (default 10 presets; [] hides the row)
minStops?: number (default 2, floored at 2)
maxStops?: number (default 12, never below minStops)
showCss?: boolean (default true)
disabled?: boolean, label?: string (default "Gradient", the group's accessible name)
Behavior
- Normalisation runs on EVERY render over whatever the consumer passed, so the
UI never has to defend itself: positions clamped to 0-100, angle wrapped into
0-360 (NaN/Infinity → 0), duplicate or missing stop ids repaired, and fewer
than two stops padded — one stop is mirrored to the opposite end (a flat wash
the user can re-colour), zero stops fall back to a built-in pair. Everything
emitted through onValueChange is already normalised. Ids invented during
normalisation are derived from the array index, never from a counter or a
random, so repeated renders produce identical React keys.
- Stop rail: a full-width bar previewing the stops as a 90deg linear gradient
whatever the gradient type is, so x always maps to position. Handles sit under
the bar, positioned by `left: position%`.
- Dragging a stop:
* pointerdown selects and focuses the handle and records {pointerId, startX,
startPosition}. It does NOT capture the pointer. Capturing on press
retargets the compatibility mouse events, so the handle would never receive
click or focus — measured in a browser: focus stayed on <body> and the
trailing click was delivered to <html>.
* the first pointermove past a 3px threshold calls setPointerCapture on the
HANDLE (not on the rail). Capturing on an ancestor would retarget the
trailing click to the rail, where two quick drags read as a double-click and
silently insert a stop.
* position is computed from the delta since the press
(startPosition + (clientX - startX) / railWidth * 100), not from the raw
pointer x. Absolute mapping teleports the stop under the cursor by however
far off-centre the handle was grabbed: on a 293px rail, grabbing 10px off
centre and nudging 4px moved the stop 5 percentage points instead of 1.
* results are rounded to whole percent, matching the keyboard step.
* pointerup/pointercancel release the capture and clear the drag ref; a
pointerId guard makes a second finger a no-op.
* the move handler also drops the drag when e.buttons is 0. A press that never
crossed the threshold holds no capture, so a release outside the rail never
reaches the up handler and the drag stays pending — and because pointer ids
are reused, the next plain hover then drags the stop with no button held.
Measured: press, swipe straight down out of the rail, release, hover back
across it, and the stop travelled from 50% to 98%.
- Per-stop colour editing: a compact text field plus a swatch strip. The field
parses #rgb, #rgba, #rrggbb, #rrggbbaa and hsl()/hsla() in both comma and
space-slash notation, echoes lowercase hex, flags an unparsable draft with
aria-invalid while leaving the committed colour alone, and reverts to the
committed colour on blur. It never emits a half-typed value.
- Double-clicking the rail body inserts a stop there, its colour lerped in sRGB
between the two neighbouring stops. A double-click that lands on a handle is
ignored (that gesture means "edit this stop"). An explicit add button does the
same thing for keyboard users by splitting the widest gap.
- Keyboard, on a focused handle: Left/Down -1%, Right/Up +1%, Shift for 5%,
Home 0%, End 100%, Delete/Backspace removes the stop. Each handle is
role="slider" with aria-valuemin/max/now and an aria-label carrying its index,
colour and position; aria-keyshortcuts advertises Delete.
- Floor and ceiling: at minStops the delete control is disabled and a visible
line (wired with aria-describedby) explains that a gradient needs two ends; at
maxStops the add control and the double-click insert are disabled the same way.
Never silently ignore the press.
- Type switch: role="radiogroup" with roving tabindex; arrow keys move and
select in one step, and focus is moved imperatively right after the state
update because all buttons are already mounted.
- Angle: shown for linear and conic only (radial has no angle, but the value
keeps it so switching back restores it). A dial plus a number field. The dial
is role="slider" and captures immediately — it has no click semantics to
protect. Screen angle → CSS angle is atan2(dx, -dy), because CSS gradient
angles run clockwise from "to top". Arrow keys ±1, Shift ±15, Home 0.
- Number fields keep a draft string while focused so the field can be emptied
mid-typing; the draft is dropped on blur and the committed (normalised) value
reappears — typing 405 emits 45 while the field still shows what was typed.
- CSS output: a read-only <output aria-live="off"> (live regions must stay
silent here, the text changes on every drag frame) plus a copy button that
writes `background-image: …;`. If navigator.clipboard is missing or rejects,
the button shows a failure icon and an "Copy failed" label — never a check
mark for a copy that did not happen.
- disabled removes the handles from the tab order and inerts every control.
- Clean up the copy-reset timeout on unmount; there are no other timers,
observers or global listeners.
Rendering & styling
- This component edits arbitrary user colours, so draw the line clearly: the
CHROME is token-only (bg-card/bg-muted/bg-background/bg-primary,
text-foreground/text-muted-foreground/text-destructive, border, ring-ring,
focus-visible:ring-2) and must contain no literal colour. The gradient stops,
the preset swatches and the previews are DATA and go into inline
backgroundColor / backgroundImage styles. Never put a stop colour in a
className.
- Transparency is shown by a token-driven checkerboard
(repeating-conic-gradient over color-mix(in oklab, var(--muted-foreground)
32%, transparent)) behind the preview, the rail and every swatch, so 8-digit
hex reads correctly in both themes.
- The only motion is a hover scale on swatches and a scale on the dragged
handle; both carry motion-reduce:transition-none / motion-reduce:hover:scale-100.
- Merge the consumer className with cn(); spread the remaining props onto the
root, which is role="group" with aria-label={label} (a group, not a landmark).
Customization levers
- Colour editor: the per-stop row is deliberately a compact hex/hsl() field plus
a swatch strip so the component has no dependency on a full picker. If you
already ship `color-picker`, swap that field for `<ColorPicker value={stop.color}
onValueChange={hex => patchStop(stop.id, { color: hex })} alpha />` and delete
parseColor/formatHex — the rest of the component is unchanged.
- Limits: minStops / maxStops; raise the floor to 3 for tri-tone brand ramps.
- Surface area: showCss={false} when the parent already renders the CSS;
swatches={[]} to drop the preset strip; types={["linear"]} to lock the type.
- Precision: drag currently rounds to whole percent. Round to 0.1 (and widen the
number inputs) for print-grade work; keep the keyboard step in sync.
- Density: preview height (h-20), rail height (h-14) and handle size (size-5)
are the three numbers that set the control's weight; the rail's hit area is
the 24px-wide button, keep it at least that wide for touch.
- Serialisation: gradientToCss is one small function — extend it for
`radial-gradient(ellipse at …)`, colour interpolation hints, or a
repeating-* variant without touching the editing model.
- Emit cadence: onValueChange fires on every drag frame. Add an onValueCommit on
pointerup if you are writing straight into a document history.Concepts
- Deferred pointer capture — the press only records an anchor; capture is taken on the first move past a 3px threshold, and on the handle rather than an ancestor. Capturing on press retargets the compatibility mouse events, which kills click, focus and
:activeon the thing you pressed. - Drag by delta — the new position is the press position plus the pointer's travel, so grabbing a handle off-centre does not teleport the stop under the cursor on the first qualifying move.
- Identity over order — stops keep their
idand their slot in the array even when dragged past a neighbour; only the CSS serialiser sorts, becauselinear-gradientclamps a stop that sits before its predecessor. - Normalise on the way in and out — one pure pass repairs whatever the consumer passed (out-of-range positions, a NaN angle, duplicate ids, a single stop) and the same pass runs on everything emitted, so the value handed to
onValueChangeis always renderable. - Chrome versus data — the picker's own surfaces are token-only so it re-themes for free, while the colours being edited are inline styles by definition. The checkerboard under every colour swatch is chrome, so it is token-driven too.
- Honest limits — hitting the stop floor or ceiling disables the control and says why through
aria-describedby, instead of swallowing the gesture.
Dimension Input
A paired width × height field with a real aspect-ratio lock — the ratio is frozen when you lock it, and min/max rolls the edited side back instead of letting its partner escape.
Shortcut Recorder
A field that captures a keyboard shortcut by pressing it — swallows every key while recording, waits for a real key past the modifiers, and tells reserved combos apart from ones that would override an existing binding.