Inputs

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Check, Copy, CopyX, Plus, Trash2 } from "lucide-react"
import { cn } from "@/lib/utils"

// ---------------------------------------------------------------------------
// Value shape
//
// Token discipline note: every class in this file is semantic (bg-card, border,
// text-muted-foreground, ring…). The *only* raw colors are the gradient stops
// themselves — those are user data, not chrome, so they arrive as props and are
// written straight into inline `background` styles. Never put a literal color
// into a className here.

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/gradient-picker.json

Prompt

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 :active on 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 id and their slot in the array even when dragged past a neighbour; only the CSS serialiser sorts, because linear-gradient clamps 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 onValueChange is 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.

On This Page