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.
npx shadcn@latest add @paragon/gradient-editorAlso installs: angle-dial, color-picker, copy-button, popover
"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>
);
}