Gradient Editor
Inputs & Forms

Gradient Editor

A linear/radial gradient builder: click the rail to add interpolated stops, drag them with pointer capture or arrow keys, recolor through a popover picker, aim linear gradients with the angle dial, and copy the live CSS.

Install

npx shadcn@latest add @paragon/gradient-editor

Also installs: angle-dial, color-picker, copy-button, popover

gradient-editor.tsx

"use client";

import * as React from "react";
import { X } from "lucide-react";
import { AngleDial } from "@/registry/paragon/ui/angle-dial";
import {
  ColorPicker,
  hsvaToRgba,
  parseColor,
} from "@/registry/paragon/ui/color-picker";
import { CopyButton } from "@/registry/paragon/ui/copy-button";
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/registry/paragon/ui/popover";
import { cn } from "@/lib/utils";

export interface GradientStop {
  id: string;
  /** Any CSS color — hex (incl. hex8 alpha), rgb(a), hsl(a). */
  color: string;
  /** 0–100 along the gradient axis. */
  position: number;
}

export interface GradientValue {
  type: "linear" | "radial";
  /** Degrees, linear gradients only — 0 points up, clockwise. */
  angle: number;
  stops: GradientStop[];
}

const byPosition = (a: GradientStop, b: GradientStop) => a.position - b.position;

/** Serialize a gradient object as a CSS gradient string. */
export function gradientToCss(value: GradientValue): string {
  const stops = [...value.stops]
    .sort(byPosition)
    .map((stop) => `${stop.color} ${Math.round(stop.position * 10) / 10}%`)
    .join(", ");
  return value.type === "linear"
    ? `linear-gradient(${value.angle}deg, ${stops})`
    : `radial-gradient(circle at 50% 50%, ${stops})`;
}

const clamp = (v: number, min: number, max: number) =>
  Math.min(max, Math.max(min, v));

/** Color at `position` by lerping the neighboring stops in RGB. */
function colorAt(stops: GradientStop[], position: number): string {
  const sorted = [...stops].sort(byPosition);
  const after = sorted.find((s) => s.position >= position);
  const before = [...sorted].reverse().find((s) => s.position <= position);
  if (!before) return after?.color ?? "#808080";
  if (!after || after === before) return before.color;
  const a = parseColor(before.color);
  const b = parseColor(after.color);
  if (!a || !b) return before.color;
  const ra = hsvaToRgba(a);
  const rb = hsvaToRgba(b);
  const t =
    after.position === before.position
      ? 0
      : (position - before.position) / (after.position - before.position);
  const mix = (x: number, y: number) => Math.round(x + (y - x) * t);
  const to = (n: number) => clamp(n, 0, 255).toString(16).padStart(2, "0");
  const alpha = ra.a + (rb.a - ra.a) * t;
  const hex = `#${to(mix(ra.r, rb.r))}${to(mix(ra.g, rb.g))}${to(mix(ra.b, rb.b))}`;
  return alpha < 1 ? `${hex}${to(Math.round(alpha * 255))}` : hex;
}

const CHECKER: React.CSSProperties = {
  backgroundImage:
    "conic-gradient(rgba(128,128,128,0.4) 0 25%, transparent 0 50%, rgba(128,128,128,0.4) 0 75%, transparent 0)",
  backgroundSize: "8px 8px",
};

const DEFAULT_VALUE: GradientValue = {
  type: "linear",
  angle: 135,
  stops: [
    { id: "stop-a", color: "#38bdf8", position: 0 },
    { id: "stop-b", color: "#4D80E6", position: 100 },
  ],
};

export interface GradientEditorProps
  extends Omit<React.ComponentProps<"div">, "onChange" | "defaultValue"> {
  /** Controlled gradient. */
  value?: GradientValue;
  /** Initial gradient when uncontrolled. */
  defaultValue?: GradientValue;
  onValueChange?: (value: GradientValue, css: string) => void;
  disabled?: boolean;
}

/**
 * A linear/radial gradient builder: click the stop rail to add a stop at the
 * pointer (seeded with the interpolated color), drag stops with pointer
 * capture or arrow keys, double-click or Delete to remove, recolor through a
 * compact popover picker, aim linear gradients with the angle dial, and copy
 * the live CSS. Emits a typed gradient object plus its css string.
 */
export function GradientEditor({
  value: valueProp,
  defaultValue = DEFAULT_VALUE,
  onValueChange,
  disabled = false,
  className,
  ...props
}: GradientEditorProps) {
  const instanceId = React.useId();
  const counter = React.useRef(0);
  const [uncontrolled, setUncontrolled] = React.useState(defaultValue);
  const value = valueProp ?? uncontrolled;
  const valueRef = React.useRef(value);
  valueRef.current = value;

  const [selectedId, setSelectedId] = React.useState(
    defaultValue.stops[0]?.id ?? "",
  );
  const [positionDraft, setPositionDraft] = React.useState<string | null>(null);
  const railRef = React.useRef<HTMLDivElement>(null);
  const draggingId = React.useRef<string | null>(null);

  const update = React.useCallback(
    (next: GradientValue) => {
      if (valueProp === undefined) setUncontrolled(next);
      onValueChange?.(next, gradientToCss(next));
    },
    [valueProp, onValueChange],
  );

  const selected =
    value.stops.find((stop) => stop.id === selectedId) ?? value.stops[0];

  const updateStop = (id: string, patch: Partial<Omit<GradientStop, "id">>) => {
    const current = valueRef.current;
    update({
      ...current,
      stops: current.stops.map((stop) =>
        stop.id === id ? { ...stop, ...patch } : stop,
      ),
    });
  };

  const removeStop = (id: string) => {
    const current = valueRef.current;
    if (current.stops.length <= 2) return;
    const remaining = current.stops.filter((stop) => stop.id !== id);
    update({ ...current, stops: remaining });
    if (selectedId === id) setSelectedId([...remaining].sort(byPosition)[0].id);
  };

  const positionFromPointer = (event: React.PointerEvent) => {
    const rail = railRef.current;
    if (!rail) return 0;
    const rect = rail.getBoundingClientRect();
    return clamp(
      Math.round(((event.clientX - rect.left) / rect.width) * 100),
      0,
      100,
    );
  };

  /** Click on empty rail: add a stop there and keep dragging it. */
  const onRailPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
    if (disabled || event.button !== 0) return;
    event.preventDefault();
    const position = positionFromPointer(event);
    const current = valueRef.current;
    const id = `${instanceId}-stop-${counter.current++}`;
    update({
      ...current,
      stops: [
        ...current.stops,
        { id, color: colorAt(current.stops, position), position },
      ],
    });
    setSelectedId(id);
    draggingId.current = id;
    event.currentTarget.setPointerCapture(event.pointerId);
  };

  const onRailPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
    if (!event.currentTarget.hasPointerCapture(event.pointerId)) return;
    if (draggingId.current) {
      updateStop(draggingId.current, { position: positionFromPointer(event) });
    }
  };

  const onRailPointerUp = (event: React.PointerEvent<HTMLDivElement>) => {
    if (!event.currentTarget.hasPointerCapture(event.pointerId)) return;
    event.currentTarget.releasePointerCapture(event.pointerId);
    draggingId.current = null;
  };

  const css = gradientToCss(value);
  const sortedStops = [...value.stops].sort(byPosition);
  const railGradient = `linear-gradient(90deg, ${sortedStops
    .map((stop) => `${stop.color} ${stop.position}%`)
    .join(", ")})`;

  const commitPositionDraft = () => {
    if (positionDraft !== null && selected) {
      const parsed = Number.parseFloat(positionDraft);
      if (!Number.isNaN(parsed)) {
        updateStop(selected.id, { position: clamp(Math.round(parsed), 0, 100) });
      }
    }
    setPositionDraft(null);
  };

  return (
    <div
      data-slot="gradient-editor"
      className={cn(
        "flex w-80 flex-col gap-3",
        disabled && "pointer-events-none opacity-50",
        className,
      )}
      {...props}
    >
      {/* Live preview */}
      <div className="relative h-20 overflow-hidden rounded-lg" style={CHECKER}>
        <div aria-hidden className="absolute inset-0" style={{ backgroundImage: css }} />
        <span
          aria-hidden
          className="pointer-events-none absolute inset-0 rounded-lg shadow-[inset_0_0_0_1px_rgba(128,128,128,0.25)]"
        />
      </div>

      {/* Stop rail */}
      <div
        ref={railRef}
        role="group"
        aria-label="Gradient stops — click to add"
        onPointerDown={onRailPointerDown}
        onPointerMove={onRailPointerMove}
        onPointerUp={onRailPointerUp}
        onPointerCancel={onRailPointerUp}
        className="relative mx-1.5 h-6 cursor-copy touch-none rounded-md"
        style={CHECKER}
      >
        <span
          aria-hidden
          className="absolute inset-0 rounded-md shadow-[inset_0_0_0_1px_rgba(128,128,128,0.25)]"
          style={{ backgroundImage: railGradient }}
        />
        {sortedStops.map((stop) => {
          const isSelected = stop.id === selected?.id;
          return (
            <button
              key={stop.id}
              type="button"
              role="slider"
              aria-label={`Color stop ${stop.color}`}
              aria-valuemin={0}
              aria-valuemax={100}
              aria-valuenow={Math.round(stop.position)}
              aria-valuetext={`${Math.round(stop.position)}%`}
              disabled={disabled}
              onPointerDown={(event) => {
                if (event.button !== 0) return;
                event.stopPropagation();
                event.preventDefault();
                event.currentTarget.setPointerCapture(event.pointerId);
                event.currentTarget.focus();
                setSelectedId(stop.id);
                draggingId.current = stop.id;
              }}
              onPointerMove={(event) => {
                if (!event.currentTarget.hasPointerCapture(event.pointerId)) return;
                updateStop(stop.id, { position: positionFromPointer(event) });
              }}
              onPointerUp={(event) => {
                if (!event.currentTarget.hasPointerCapture(event.pointerId)) return;
                event.currentTarget.releasePointerCapture(event.pointerId);
                draggingId.current = null;
              }}
              onPointerCancel={(event) => {
                if (!event.currentTarget.hasPointerCapture(event.pointerId)) return;
                event.currentTarget.releasePointerCapture(event.pointerId);
                draggingId.current = null;
              }}
              onDoubleClick={() => removeStop(stop.id)}
              onKeyDown={(event) => {
                const step = event.shiftKey ? 10 : 1;
                if (event.key === "ArrowRight" || event.key === "ArrowUp") {
                  event.preventDefault();
                  updateStop(stop.id, {
                    position: clamp(stop.position + step, 0, 100),
                  });
                } else if (event.key === "ArrowLeft" || event.key === "ArrowDown") {
                  event.preventDefault();
                  updateStop(stop.id, {
                    position: clamp(stop.position - step, 0, 100),
                  });
                } else if (event.key === "Home") {
                  event.preventDefault();
                  updateStop(stop.id, { position: 0 });
                } else if (event.key === "End") {
                  event.preventDefault();
                  updateStop(stop.id, { position: 100 });
                } else if (event.key === "Delete" || event.key === "Backspace") {
                  event.preventDefault();
                  removeStop(stop.id);
                }
              }}
              onFocus={() => setSelectedId(stop.id)}
              className={cn(
                "absolute top-1/2 size-4 -translate-x-1/2 -translate-y-1/2 cursor-ew-resize touch-none rounded-full",
                "border-2 border-white shadow-[0_0_0_1px_rgba(0,0,0,0.3),0_1px_3px_rgba(0,0,0,0.3)]",
                "transition-[scale] duration-100 ease-out",
                "outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
                "after:absolute after:top-1/2 after:left-1/2 after:size-10 after:-translate-1/2",
                isSelected && "scale-110",
              )}
              style={{
                left: `${stop.position}%`,
                backgroundColor: stop.color,
                zIndex: isSelected ? 2 : 1,
              }}
              title="Drag to move — double-click to remove"
            />
          );
        })}
      </div>

      {/* Type + angle + selected stop */}
      <div className="flex items-center justify-between gap-2">
        <div
          role="radiogroup"
          aria-label="Gradient type"
          className="grid h-8 shrink-0 grid-cols-2 rounded-lg bg-secondary p-0.5"
        >
          {(["linear", "radial"] as const).map((type) => (
            <button
              key={type}
              type="button"
              role="radio"
              aria-checked={value.type === type}
              disabled={disabled}
              onClick={() => update({ ...valueRef.current, type })}
              className={cn(
                "rounded-[7px] px-2.5 text-xs font-medium capitalize",
                "transition-[background-color,color,box-shadow,scale] duration-150 ease-out",
                "outline-none focus-visible:ring-2 focus-visible:ring-ring",
                "active:not-disabled:scale-[0.97]",
                value.type === type
                  ? "bg-background text-foreground shadow-border"
                  : "text-muted-foreground hover:text-foreground",
              )}
            >
              {type}
            </button>
          ))}
        </div>
        {value.type === "linear" ? (
          <AngleDial
            size={32}
            snap={15}
            value={value.angle}
            onValueChange={(angle) => update({ ...valueRef.current, angle })}
            disabled={disabled}
            aria-label="Gradient angle"
          />
        ) : (
          <span className="pr-1 text-[11px] text-muted-foreground">
            from center
          </span>
        )}
      </div>

      {/* Selected stop editor */}
      {selected && (
        <div className="flex items-center gap-2">
          <Popover>
            <PopoverTrigger
              disabled={disabled}
              aria-label={`Edit color of stop at ${Math.round(selected.position)}%`}
              className={cn(
                "pressable relative size-7 shrink-0 rounded-md",
                "outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
                "after:absolute after:top-1/2 after:left-1/2 after:size-10 after:-translate-1/2",
              )}
              style={CHECKER}
            >
              <span
                aria-hidden
                className="absolute inset-0 rounded-md shadow-[inset_0_0_0_1px_rgba(128,128,128,0.3)]"
                style={{ backgroundColor: selected.color }}
              />
            </PopoverTrigger>
            <PopoverContent align="start" className="w-auto p-3">
              <ColorPicker
                size="sm"
                value={selected.color}
                onValueChange={(color) => updateStop(selected.id, { color })}
              />
            </PopoverContent>
          </Popover>
          <code className="min-w-0 flex-1 truncate font-mono text-xs text-foreground/90">
            {selected.color}
          </code>
          <div
            className={cn(
              "flex h-7 shrink-0 items-center rounded-md border border-input pr-1.5 pl-2",
              "transition-[border-color,box-shadow] duration-150 ease-out",
              "focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/25",
            )}
          >
            <input
              type="text"
              inputMode="numeric"
              aria-label="Stop position"
              value={positionDraft ?? String(Math.round(selected.position))}
              disabled={disabled}
              onChange={(event) =>
                setPositionDraft(event.target.value.replace(/[^0-9.]/g, ""))
              }
              onFocus={() => setPositionDraft(String(Math.round(selected.position)))}
              onBlur={commitPositionDraft}
              onKeyDown={(event) => {
                if (event.key === "Enter") {
                  event.preventDefault();
                  commitPositionDraft();
                  event.currentTarget.blur();
                } else if (event.key === "ArrowUp" || event.key === "ArrowDown") {
                  event.preventDefault();
                  const step =
                    (event.key === "ArrowUp" ? 1 : -1) * (event.shiftKey ? 10 : 1);
                  setPositionDraft(null);
                  updateStop(selected.id, {
                    position: clamp(selected.position + step, 0, 100),
                  });
                }
              }}
              className="w-7 bg-transparent text-right text-xs font-medium tabular-nums outline-none"
            />
            <span aria-hidden className="ml-0.5 text-[10px] text-muted-foreground">
              %
            </span>
          </div>
          <button
            type="button"
            aria-label="Remove color stop"
            disabled={disabled || value.stops.length <= 2}
            onClick={() => removeStop(selected.id)}
            className={cn(
              "pressable relative flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground",
              "transition-colors duration-150 ease-out hover:bg-secondary hover:text-foreground",
              "outline-none focus-visible:ring-2 focus-visible:ring-ring",
              "disabled:pointer-events-none disabled:opacity-40",
              "after:absolute after:top-1/2 after:left-1/2 after:size-10 after:-translate-1/2",
            )}
          >
            <X aria-hidden className="size-3.5" />
          </button>
        </div>
      )}

      {/* CSS output */}
      <div className="flex h-8 items-center gap-1 rounded-md bg-secondary/60 pl-2.5">
        <code
          title={css}
          className="min-w-0 flex-1 truncate font-mono text-[11px] text-foreground/90 tabular-nums"
        >
          {css}
        </code>
        <CopyButton value={css} aria-label="Copy gradient CSS" />
      </div>
    </div>
  );
}