amplo
uigradient-picker-enginesource: ok · refreshed
Amplo Gradient Picker Engine
Dialect-agnostic core of the Amplo gradient picker: CSS gradient parsing/serialization with per-stop format state, the gradient context and hook, and the pointer-driven parts both variants share — Bar (with its stop-editor slot), Overlay, Area, StopList, StopColor, angle/position pads and inputs, ShapeSwitcher, ReverseStops, RepeatingToggle, Presets, CssInput, and the anchor-only stop popover shell. Installed automatically as a dependency of `gradient-picker` (Base UI) and `gradient-picker-radix`; you do not normally add it directly.
npx shadcn add "https://amplo.ale.design/r/gradient-picker-engine.json"free
Where it goes
stats
Visual style
gradient
Interaction
animated-countertooltip
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lightui-primitives@base-ui/react
iconslucide-react
Registry dependencies
utilsbuttontogglehttps://amplo.ale.design/r/color-picker-engine.json
Files27 · source cached 10/8/2026, 2:20:41 AM
registry/new-york/ui/fill-picker/lib/gradient.ts
import type { OklchColor } from "./types";
import { formatColor, parseColor } from "./color";
export type GradientType = "linear" | "radial" | "conic";
export type GradientInterp =
| "oklch"
| "oklab"
| "srgb"
| "hsl"
| "hsl-longer";
export interface GradientStop {
color: OklchColor;
/** 0..1 along the gradient axis. */
position: number;
/** Optional CSS midpoint hint, 0..1. */
hint?: number;
/**
* Optional stable identity, honored in **controlled** mode only.
*
* Without it the picker reconciles an incoming `value` against its own
* state by position and array index, which cannot tell two stops sharing a
* position apart — reorder them externally and the colors swap between the
* existing internal ids while the selection and per-stop format stay put.
*
* Supply an id per stop and reconciliation matches on identity instead, so
* selection, per-stop color format, and colors all follow the right stop
* through any reorder. Ids must be unique within the gradient; duplicates
* are ignored and fall back to a generated id.
*
* Purely opt-in. Omit it and nothing changes — the picker reconciles by
* position exactly as before and never adds an `id` to what it emits.
*
* Once you do supply ids, they round-trip: `onValueChange` echoes them back
* (including on stops added inside the picker), so the ordinary
* `onValueChange={g => setG(g)}` pattern keeps identity instead of losing
* the tags on the first update.
*/
id?: string;
}
export interface LinearGradient {
type: "linear";
/**
* Degrees, 0..360. CSS gradient angle convention: 0deg = up, increases
* clockwise. Always present. When `start` and `end` are set, this field
* is kept in sync with `atan2(end - start)` so consumers reading `angle`
* alone still get a meaningful value.
*/
angle: number;
/**
* When true, `formatGradient` emits `repeating-linear-gradient(...)`. The
* stop ramp repeats every (last - first) position span — typically used
* for stripes / barberpole patterns. Optional: undefined = false.
*/
repeating?: boolean;
/**
* Optional start endpoint of the gradient line, normalized 0..1 of the
* gradient box in each axis. When both `start` and `end` are set, the
* gradient is treated as "positioned" — the line is the segment between
* them, and `formatGradient` emits CSS with stop positions adjusted so
* the visual transition happens between these two points (the CSS
* gradient line itself still passes through the box center because
* `linear-gradient` has no way to express an offset line, but the
* adjusted stop positions preserve the visual offset).
*
* When unset, the gradient behaves the legacy way: line passes through
* the box center, direction determined by `angle` only.
*/
start?: { x: number; y: number };
/** Optional end endpoint. See `start`. */
end?: { x: number; y: number };
stops: GradientStop[];
interp: GradientInterp;
}
/**
* CSS radial-gradient extent keywords — the four values the spec allows in
* the `<size>` slot. They describe how far the gradient ellipse reaches
* from its center, measured to either a side or a corner of the gradient
* box. Spec: https://www.w3.org/TR/css-images-3/#valdef-radial-gradient-extent-keyword
*/
export type RadialSizeKeyword =
| "closest-side"
| "closest-corner"
| "farthest-side"
| "farthest-corner";
export interface RadialGradient {
type: "radial";
shape: "circle" | "ellipse";
/** Normalized 0..1 in each axis. */
center: { x: number; y: number };
size: RadialSizeKeyword;
/**
* Optional explicit radii. When set, takes precedence over `shape`+`size`
* for CSS emission. `x` is a fraction of the gradient box width, `y` a
* fraction of its height — matching the CSS `<length-percentage>{1,2}`
* radial-gradient ending-shape syntax. When unset, the keyword form
* (`shape size`) is emitted instead.
*
* Use this for **ellipse** shapes — pairs of percentages stay
* container-relative on the consumer side.
*/
radii?: { x: number; y: number };
/**
* Optional explicit circle radius in absolute pixels. Only meaningful
* when `shape === "circle"`. When set, takes the highest precedence at
* emit time and produces a `radial-gradient(<px>px at ...)` form — which
* is the **only** way to get a CSS radial gradient that stays visually
* circular regardless of the consumer container's aspect ratio. The
* `<length-percentage>{2}` form always implies ellipse (CSS spec), so
* even rx === ry-by-pixel from the picker box draws an ellipse when
* pasted into a differently-shaped container.
*/
radiusPx?: number;
stops: GradientStop[];
interp: GradientInterp;
/** See `LinearGradient.repeating`. Emits `repeating-radial-gradient(...)`. */
repeating?: boolean;
}
export interface ConicGradient {
type: "conic";
/** Degrees. */
startAngle: number;
/** Normalized 0..1 in each axis. */
center: { x: number; y: number };
stops: GradientStop[];
interp: GradientInterp;
/** See `LinearGradient.repeating`. Emits `repeating-conic-gradient(...)`. */
repeating?: boolean;
}
export type Gradient = LinearGradient | RadialGradient | ConicGradient;
export type ColorFill = { kind: "color"; color: OklchColor };
export type GradientFill = { kind: "gradient"; gradient: Gradient };
export type Fill = ColorFill | GradientFill;
export const DEFAULT_LINEAR: LinearGradient = {
type: "linear",
angle: 90,
interp: "oklch",
stops: [
{ color: { l: 1, c: 0, h: 0, alpha: 1 }, position: 0 },
{ color: { l: 0, c: 0, h: 0, alpha: 1 }, position: 1 },
],
};
export const DEFAULT_RADIAL: RadialGradient = {
type: "radial",
shape: "circle",
center: { x: 0.5, y: 0.5 },
size: "farthest-corner",
interp: "oklch",
stops: [
{ color: { l: 1, c: 0, h: 0, alpha: 1 }, position: 0 },
{ color: { l: 0, c: 0, h: 0, alpha: 1 }, position: 1 },
],
};
export const DEFAULT_CONIC: ConicGradient = {
type: "conic",
startAngle: 0,
center: { x: 0.5, y: 0.5 },
interp: "oklch",
stops: [
{ color: { l: 1, c: 0, h: 0, alpha: 1 }, position: 0 },
{ color: { l: 0, c: 0, h: 0, alpha: 1 }, position: 1 },
],
};
function trim(n: number, digits = 4): string {
return Number(n.toFixed(digits)).toString();
}
const formatStopColor = (c: OklchColor): string => formatColor(c, "oklch");
function formatStops(stops: GradientStop[]): string {
const parts: string[] = [];
stops.forEach((s, i) => {
// A hint on stop i is the midpoint *between* stop i-1 and stop i —
// emit it before this stop's color declaration.
if (s.hint !== undefined && i > 0) {
parts.push(`${trim(s.hint * 100)}%`);
}
parts.push(`${formatStopColor(s.color)} ${trim(s.position * 100)}%`);
});
return parts.join(", ");
}
function formatInterp(interp: GradientInterp): string {
if (interp === "hsl-longer") return "hsl longer hue";
return interp;
}
/**
* Derive a CSS gradient angle (0deg = up, increases clockwise) from two
* box-normalized points. Returns undefined when either point is missing or
* the two coincide.
*/
export function angleFromPoints(
start: { x: number; y: number } | undefined,
end: { x: number; y: number } | undefined,
): number | undefined {
if (!start || !end) return undefined;
const dx = end.x - start.x;
// Box y axis points down in screen coords, but CSS gradient angle is
// measured with 0deg = up. Flip dy so the formula matches the visual.
const dy = -(end.y - start.y);
if (dx === 0 && dy === 0) return undefined;
// atan2(x, y) with CSS convention: angle = arctan2(dx, dy) gives 0 = up,
// increases clockwise. Same convention used by AnglePad and Area.
const deg = (Math.atan2(dx, dy) * 180) / Math.PI;
return ((deg % 360) + 360) % 360;
}
/**
* Project a linear gradient's `start` and `end` endpoints onto the CSS
* gradient line (the centered line implied by the derived angle). Returns
* `null` when the segment has zero length and no projection is meaningful.
*
* The CSS gradient line length depends on the actual box dimensions; this
* projection assumes a square unit box, so the magnitudes of the returned
* `startProj` / `endProj` are an approximation in stretched containers.
* The direction is always correct.
*/
export function gradientLineProjection(
start: { x: number; y: number },
end: { x: number; y: number },
): { startProj: number; endProj: number } | null {
const dx = end.x - start.x;
const dy = end.y - start.y;
const len = Math.hypot(dx, dy);
if (len === 0) return null;
const ux = dx / len;
const uy = dy / len;
const cssLen = Math.abs(ux) + Math.abs(uy);
const project = (p: { x: number; y: number }) =>
0.5 + ((p.x - 0.5) * ux + (p.y - 0.5) * uy) / cssLen;
return { startProj: project(start), endProj: project(end) };
}
/**
* Map an authored stop position (0..1 along the [start, end] segment) into
* its visible position along the CSS gradient line. Identity when the
* segment has zero length or either endpoint is missing.
*/
export function projectStopPosition(
authoredPos: number,
start: { x: number; y: number } | undefined,
end: { x: number; y: number } | undefined,
): number {
if (!start || !end) return authoredPos;
const proj = gradientLineProjection(start, end);
if (!proj) return authoredPos;
return proj.startProj + (proj.endProj - proj.startProj) * authoredPos;
}
/**
* Inverse of `projectStopPosition`: take a position along the CSS gradient
* line and return the corresponding authored position. Useful for
* converting a pointer position on the Bar back into the value that should
* be written to `stop.position`. Returns the input unchanged when there's
* no segment to project onto.
*/
export function reverseProjectStopPosition(
displayedPos: number,
start: { x: number; y: number } | undefined,
end: { x: number; y: number } | undefined,
): number {
if (!start || !end) return displayedPos;
const proj = gradientLineProjection(start, end);
if (!proj) return displayedPos;
const span = proj.endProj - proj.startProj;
if (span === 0) return 0;
return (displayedPos - proj.startProj) / span;
}
/**
* Re-map a list of stop positions so that the gradient transition happens
* between `start` and `end` along the centered CSS gradient line. Used by
* `formatGradient` for the positioned linear case, and by the Bar /
* StopList parts to mirror that projection in their UI.
*/
export function adjustStopsForEndpoints(
stops: GradientStop[],
start: { x: number; y: number },
end: { x: number; y: number },
): GradientStop[] {
const proj = gradientLineProjection(start, end);
if (!proj) return stops;
const { startProj, endProj } = proj;
return stops.map((s) => ({
...s,
position: startProj + (endProj - startProj) * s.position,
hint:
s.hint === undefined
? undefined
: startProj + (endProj - startProj) * s.hint,
}));
}
export function formatGradient(g: Gradient): string {
const interp = `in ${formatInterp(g.interp)}`;
const prefix = g.repeating ? "repeating-" : "";
if (g.type === "linear") {
if (g.start && g.end) {
const derivedAngle = angleFromPoints(g.start, g.end);
// If start and end coincide (zero-length line), there's no meaningful
// direction — fall through to the plain angle form.
if (derivedAngle !== undefined) {
const adjusted = adjustStopsForEndpoints(g.stops, g.start, g.end);
return `${prefix}linear-gradient(${interp} ${trim(derivedAngle)}deg, ${formatStops(adjusted)})`;
}
}
return `${prefix}linear-gradient(${interp} ${trim(g.angle)}deg, ${formatStops(g.stops)})`;
}
if (g.type === "radial") {
const center = `at ${trim(g.center.x * 100)}% ${trim(g.center.y * 100)}%`;
// Precedence at emit time:
// 1. shape=circle + radiusPx → `<px>px` (single length forces circle,
// stays a true circle in any consumer container)
// 2. radii → `<rx>% <ry>%` (always renders as an ellipse, scales with
// container width / height respectively)
// 3. keyword form → `shape size` (CSS default, computed live by the
// renderer relative to the container + center)
let endingShape: string;
if (g.shape === "circle" && g.radiusPx !== undefined) {
endingShape = `${trim(g.radiusPx)}px`;
} else if (g.shape === "ellipse" && g.radii) {
// The `% %` pair always implies ellipse in CSS, so it must never be
// emitted for shape: "circle" — stale radii from an earlier ellipse
// edit would silently flip the rendered shape.
endingShape = `${trim(g.radii.x * 100)}% ${trim(g.radii.y * 100)}%`;
} else {
endingShape = `${g.shape} ${g.size}`;
}
return `${prefix}radial-gradient(${endingShape} ${center} ${interp}, ${formatStops(g.stops)})`;
}
// conic
const center = `at ${trim(g.center.x * 100)}% ${trim(g.center.y * 100)}%`;
return `${prefix}conic-gradient(from ${trim(g.startAngle)}deg ${center} ${interp}, ${formatStops(g.stops)})`;
}
// ---------------------------------------------------------------------------
// parseGradient — inverse of formatGradient
// ---------------------------------------------------------------------------
const FN_RE = /^(repeating-)?(linear|radial|conic)-gradient\((.*)\)$/is;
/** Split on commas that are not nested inside parentheses. */
function splitTopLevel(input: string): string[] {
const out: string[] = [];
let depth = 0;
let buf = "";
for (const ch of input) {
if (ch === "(") depth++;
else if (ch === ")") depth--;
if (ch === "," && depth === 0) {
out.push(buf.trim());
buf = "";
} else {
buf += ch;
}
}
if (buf.trim()) out.push(buf.trim());
return out;
}
/**
* Extract `in <space>[ longer hue]` from anywhere within a string.
* Returns the parsed interp and the string with the `in …` clause removed.
*/
function extractInterp(s: string): { interp: GradientInterp; rest: string } {
// `in <space> [<hue-method> hue]` — only `hsl longer hue` has a model
// value; other hue methods are accepted and dropped rather than left
// in `rest` to poison the header parse.
const m = s.match(
/\bin\s+([a-z0-9-]+)(?:\s+(shorter|longer|increasing|decreasing)\s+hue)?\b/i,
);
if (!m) return { interp: "oklch", rest: s };
const space = m[1].toLowerCase();
const longer = m[2]?.toLowerCase() === "longer";
let interp: GradientInterp = "oklch";
if (space === "hsl") interp = longer ? "hsl-longer" : "hsl";
else if (space === "oklab") interp = "oklab";
else if (space === "srgb") interp = "srgb";
return { interp, rest: s.replace(m[0], "").trim() };
}
function parseStops(parts: string[]): GradientStop[] | null {
const raw: Array<{
color: OklchColor;
position: number | null;
hint?: number;
}> = [];
// A hint belongs to the stop that FOLLOWS it (formatStops emits stop i's
// hint between stop i-1 and stop i), so stash it until the next color stop.
let pendingHint: number | undefined;
for (const p of parts) {
// Bare percentage hint: `30%`
const hintMatch = p.match(/^(-?\d+(?:\.\d+)?)%$/);
if (hintMatch) {
if (raw.length === 0) return null; // hint can't lead
if (pendingHint !== undefined) return null; // two hints in a row
pendingHint = parseFloat(hintMatch[1]) / 100;
continue;
}
// Color + up to two positions: `oklch(…)`, `oklch(…) 50%`, or the
// hard-stop shorthand `red 0% 50%`, which is two stops of one color.
const two = p.match(/^(.*?)\s+(-?\d+(?:\.\d+)?)%\s+(-?\d+(?:\.\d+)?)%$/);
const one = two ? null : p.match(/^(.*?)\s+(-?\d+(?:\.\d+)?)%$/);
const colorStr = (two ?? one)?.[1].trim() ?? p;
const positions: Array<number | null> = two
? [parseFloat(two[2]) / 100, parseFloat(two[3]) / 100]
: [one ? parseFloat(one[2]) / 100 : null];
const color = parseColor(colorStr);
if (!color) return null;
positions.forEach((position, k) => {
raw.push({
color: k === 0 ? color : { ...color },
position,
...(k === 0 && pendingHint !== undefined ? { hint: pendingHint } : {}),
});
});
pendingHint = undefined;
}
if (pendingHint !== undefined) return null; // hint can't trail
if (raw.length === 0) return null;
// CSS Images 3 §3.4.3 position fixup:
// 1. First/last stops without a position default to 0% / 100%.
// 2. Explicit positions may never decrease — clamp to the running max.
// 3. Runs of unpositioned stops are spaced evenly between their
// positioned neighbors.
if (raw[0].position === null) raw[0].position = 0;
if (raw[raw.length - 1].position === null) raw[raw.length - 1].position = 1;
let runningMax = raw[0].position as number;
for (const s of raw) {
if (s.position !== null) {
s.position = Math.max(s.position, runningMax);
runningMax = s.position;
}
}
let i = 0;
while (i < raw.length) {
if (raw[i].position !== null) {
i++;
continue;
}
let j = i;
while (raw[j].position === null) j++;
const prev = raw[i - 1].position as number;
const next = raw[j].position as number;
const count = j - i + 1; // gaps between prev and next
for (let k = i; k < j; k++) {
raw[k].position = prev + ((next - prev) * (k - i + 1)) / count;
}
i = j;
}
return raw.map((s) => ({
color: s.color,
position: s.position as number,
...(s.hint !== undefined ? { hint: s.hint } : {}),
}));
}
/**
* Map a CSS `to <side-or-corner>` linear direction to a gradient angle.
* Corners use the square-box angles (45/135/225/315) — the picker's model
* only stores an angle, so the CSS "magic corner" aspect-dependence is
* intentionally approximated. Returns null for invalid pairs
* (`to left right`).
*/
function sideOrCornerAngle(
a: string,
b: string | undefined,
): number | null {
const set = new Set([a.toLowerCase(), ...(b ? [b.toLowerCase()] : [])]);
if (b && set.size !== 2) return null;
const has = (s: string) => set.has(s);
if ((has("top") && has("bottom")) || (has("left") && has("right"))) {
return null;
}
if (set.size === 1) {
if (has("top")) return 0;
if (has("right")) return 90;
if (has("bottom")) return 180;
return 270; // left
}
if (has("top") && has("right")) return 45;
if (has("bottom") && has("right")) return 135;
if (has("bottom") && has("left")) return 225;
return 315; // top left
}
const NUM_RE_SRC = String.raw`-?\d+(?:\.\d+)?`;
const PCT_RE = new RegExp(`^(${NUM_RE_SRC})%$`);
const PX_RE = new RegExp(`^(${NUM_RE_SRC})px$`, "i");
const ANGLE_RE = new RegExp(`^(${NUM_RE_SRC})(deg|turn|rad|grad)?$`, "i");
const RADIAL_SIZES: readonly string[] = [
"closest-side",
"closest-corner",
"farthest-side",
"farthest-corner",
] satisfies RadialSizeKeyword[];
const wrapDeg = (d: number) => ((d % 360) + 360) % 360;
/** CSS `<angle>` → degrees in [0, 360). Unitless is accepted only for `0`. */
function parseAngle(s: string): number | null {
const m = s.trim().match(ANGLE_RE);
if (!m) return null;
const n = parseFloat(m[1]);
switch (m[2]?.toLowerCase()) {
case "deg":
return wrapDeg(n);
case "turn":
return wrapDeg(n * 360);
case "rad":
return wrapDeg((n * 180) / Math.PI);
case "grad":
return wrapDeg(n * 0.9);
default:
return n === 0 ? 0 : null;
}
}
/**
* CSS `<position>` (one or two tokens: percentages and side keywords) →
* 0..1 fractions. Pixel offsets aren't representable in the model → null.
*/
function parsePosition(s: string): { x: number; y: number } | null {
const toks = s.trim().toLowerCase().split(/\s+/).filter(Boolean);
const X: Record<string, number> = { left: 0, center: 0.5, right: 1 };
const Y: Record<string, number> = { top: 0, center: 0.5, bottom: 1 };
const pct = (t: string) => {
const m = t.match(PCT_RE);
return m ? parseFloat(m[1]) / 100 : undefined;
};
if (toks.length === 1) {
const [t] = toks;
const p = pct(t);
if (p !== undefined) return { x: p, y: 0.5 };
if (t in X) return { x: X[t], y: 0.5 };
if (t in Y) return { x: 0.5, y: Y[t] };
return null;
}
if (toks.length !== 2) return null;
let [a, b] = toks;
// Keyword pairs may come vertical-first (`top right`).
if ((a in Y && !(a in X)) || (b in X && !(b in Y))) [a, b] = [b, a];
const x = pct(a) ?? X[a];
const y = pct(b) ?? Y[b];
return x === undefined || y === undefined ? null : { x, y };
}
interface RadialHeader {
shape: "circle" | "ellipse";
size: RadialSizeKeyword;
center: { x: number; y: number };
radii?: { x: number; y: number };
radiusPx?: number;
}
/**
* Parse a radial header (`circle farthest-corner at 50% 50%`, `48% 30%`,
* `268px at top`, …). Returns null on any unrecognized token so invalid
* input is rejected instead of silently becoming the default gradient.
*/
function parseRadialHeader(head: string): RadialHeader | null {
const [beforeAt, afterAt, ...extra] = head.split(/\bat\b/i);
if (extra.length > 0) return null;
let center = { x: 0.5, y: 0.5 };
if (afterAt !== undefined) {
const pos = parsePosition(afterAt);
if (!pos) return null;
center = pos;
}
let shape: "circle" | "ellipse" | undefined;
let size: RadialSizeKeyword | undefined;
const lengths: string[] = [];
for (const tok of beforeAt.trim().toLowerCase().split(/\s+/).filter(Boolean)) {
if (tok === "circle" || tok === "ellipse") {
if (shape) return null;
shape = tok;
} else if (RADIAL_SIZES.includes(tok)) {
if (size) return null;
size = tok as RadialSizeKeyword;
} else if (PX_RE.test(tok) || PCT_RE.test(tok)) {
lengths.push(tok);
} else {
return null;
}
}
if (lengths.length > 2) return null;
const header: RadialHeader = {
shape: shape ?? "ellipse",
size: size ?? "farthest-corner",
center,
};
if (lengths.length === 1) {
// A single `<length>` is only valid as a circle radius; a lone
// percentage isn't valid CSS.
const px = lengths[0].match(PX_RE);
if (!px) return null;
header.radiusPx = parseFloat(px[1]);
header.shape = "circle";
} else if (lengths.length === 2) {
// Only the percentage pair formatGradient emits maps onto the model;
// raw lengths (`100px 80px`) fall through to the keyword defaults
// rather than being silently rescaled.
const [rx, ry] = lengths.map((t) => t.match(PCT_RE));
if (rx && ry) {
header.radii = { x: parseFloat(rx[1]) / 100, y: parseFloat(ry[1]) / 100 };
}
}
return header;
}
interface ConicHeader {
startAngle: number;
center: { x: number; y: number };
}
/** Parse a conic header (`from 90deg at 50% 50%`). Null on junk. */
function parseConicHeader(head: string): ConicHeader | null {
const [beforeAt, afterAt, ...extra] = head.split(/\bat\b/i);
if (extra.length > 0) return null;
let center = { x: 0.5, y: 0.5 };
if (afterAt !== undefined) {
const pos = parsePosition(afterAt);
if (!pos) return null;
center = pos;
}
let startAngle = 0;
const from = beforeAt.trim();
if (from) {
const m = from.match(/^from\s+(\S+)$/i);
const angle = m ? parseAngle(m[1]) : null;
if (angle === null) return null;
startAngle = angle;
}
return { startAngle, center };
}
export function parseGradient(input: string): Gradient | null {
const trimmed = input.trim();
const m = trimmed.match(FN_RE);
if (!m) return null;
const repeating = !!m[1];
const type = m[2].toLowerCase() as GradientType;
const parts = splitTopLevel(m[3]);
if (parts.length === 0) return null;
if (type === "linear") {
// parts[0] for formatGradient output: "in oklch 90deg"
// For a hand-written gradient without interp: "90deg" or absent (just stops)
const { interp, rest } = extractInterp(parts[0]);
let angle = 180; // CSS default
let stopParts = parts.slice(1);
const parsedAngle = parseAngle(rest);
const toMatch = rest.match(
/^to\s+(top|bottom|left|right)(?:\s+(top|bottom|left|right))?$/i,
);
if (parsedAngle !== null) {
angle = parsedAngle;
} else if (toMatch) {
const dir = sideOrCornerAngle(toMatch[1], toMatch[2]);
if (dir === null) return null; // e.g. "to left right"
angle = dir;
} else if (rest.length > 0) {
// rest wasn't a direction — treat it as the first stop
stopParts = [rest, ...stopParts];
}
const stops = parseStops(stopParts);
if (!stops) return null;
return {
type: "linear",
angle,
interp,
stops,
...(repeating ? { repeating: true } : {}),
};
}
// radial / conic: a header-less gradient (`radial-gradient(red, blue)`)
// starts straight with its stops, so when parts[0] isn't a valid header
// and carried no `in <space>` clause, re-read it as the first stop.
const { interp, rest } = extractInterp(parts[0]);
const hadInterp = rest !== parts[0];
const header =
type === "radial" ? parseRadialHeader(rest) : parseConicHeader(rest);
if (!header && hadInterp) return null;
const stops = parseStops(header ? parts.slice(1) : parts);
if (!stops) return null;
if (type === "radial") {
const h = (header as RadialHeader | null) ?? {
shape: "ellipse",
size: "farthest-corner",
center: { x: 0.5, y: 0.5 },
};
return {
type: "radial",
shape: h.shape,
size: h.size,
center: h.center,
...(h.radii ? { radii: h.radii } : {}),
...(h.radiusPx !== undefined ? { radiusPx: h.radiusPx } : {}),
interp,
stops,
...(repeating ? { repeating: true } : {}),
};
}
const h = (header as ConicHeader | null) ?? {
startAngle: 0,
center: { x: 0.5, y: 0.5 },
};
return {
type: "conic",
startAngle: h.startAngle,
center: h.center,
interp,
stops,
...(repeating ? { repeating: true } : {}),
};
}
// ---------------------------------------------------------------------------
// parseFill / formatFill — discriminated union of Color | Gradient
// ---------------------------------------------------------------------------
export function formatFill(f: Fill): string {
if (f.kind === "color") return formatColor(f.color, "oklch");
return formatGradient(f.gradient);
}
export function parseFill(input: string): Fill | null {
const g = parseGradient(input);
if (g) return { kind: "gradient", gradient: g };
const c = parseColor(input);
if (c) return { kind: "color", color: c };
return null;
}
// ---------------------------------------------------------------------------
// sampleStopsAt — interpolate a stop color at an arbitrary 0..1 position
// ---------------------------------------------------------------------------
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
// Shortest-path hue lerp on a 0..360 circle. Matches what users perceive in
// an OKLCH-rendered gradient between two adjacent stops.
const ACHROMATIC_C = 1e-4;
function lerpHue(a: number, b: number, t: number): number {
let d = b - a;
if (d > 180) d -= 360;
else if (d < -180) d += 360;
return ((a + d * t) % 360 + 360) % 360;
}
/**
* Returns the OKLCH color at `position` (0..1) along a sorted-by-position
* stop list. Used by Bar so click-to-add picks the color the user sees.
* Interpolation is done in OKLCH regardless of the gradient's `interp`
* setting — the stops themselves are canonical OKLCH and that gives a
* perceptually sensible pick even when the visual paint uses sRGB/HSL.
*/
export function sampleStopsAt(
stops: GradientStop[],
position: number,
): OklchColor {
if (stops.length === 0) return { l: 0.5, c: 0, h: 0, alpha: 1 };
const sorted = [...stops].sort((a, b) => a.position - b.position);
if (position <= sorted[0].position) return { ...sorted[0].color };
const last = sorted[sorted.length - 1];
if (position >= last.position) return { ...last.color };
let i = 0;
while (i < sorted.length - 1 && sorted[i + 1].position < position) i++;
const a = sorted[i];
const b = sorted[i + 1];
const span = b.position - a.position;
let t = span === 0 ? 0 : (position - a.position) / span;
// CSS midpoint hint: the hint (stored on the following stop, in absolute
// axis coordinates like `position`) marks where the blend reaches 50%.
// Browsers warp progress with t^(log(.5)/log(h)) so t = h lands at 0.5 —
// apply the same curve so click-to-add picks the color the user sees.
if (b.hint !== undefined && span > 0) {
const h = (b.hint - a.position) / span;
if (h > 0 && h < 1) {
t = Math.pow(t, Math.log(0.5) / Math.log(h));
}
}
// An achromatic stop's hue is powerless; CSS Color 4 treats it as missing
// and borrows the other endpoint's hue instead of sweeping through 0°.
const ha = a.color.c <= ACHROMATIC_C ? b.color.h : a.color.h;
const hb = b.color.c <= ACHROMATIC_C ? a.color.h : b.color.h;
return {
l: lerp(a.color.l, b.color.l, t),
c: lerp(a.color.c, b.color.c, t),
h: lerpHue(ha, hb, t),
alpha: lerp(a.color.alpha, b.color.alpha, t),
};
}
registry/new-york/ui/fill-picker/lib/gradient-options.ts
import type {
GradientInterp,
GradientType,
RadialSizeKeyword,
} from "./gradient";
/**
* Option metadata shared by both switcher variants (classic and Base UI).
* Living here — not copy-pasted into each tree — means a label or tooltip
* edit can't desync the two product surfaces.
*/
export const GRADIENT_TYPE_OPTIONS: {
value: GradientType;
label: string;
}[] = [
{ value: "linear", label: "Linear" },
{ value: "radial", label: "Radial" },
{ value: "conic", label: "Conic" },
];
/**
* Selectable interpolation spaces. Each maps to the CSS Color 4
* `<gradient> in <space>` clause emitted by `formatGradient`. The
* description is surfaced via the ⓘ icon next to each option, so users
* hovering an option in the open menu see *why* they'd pick it.
*/
export const GRADIENT_INTERP_OPTIONS: {
value: GradientInterp;
label: string;
description: string;
}[] = [
{
value: "oklch",
label: "OKLCH",
description:
"Perceptually uniform polar space. Smooth hue arcs, no muddy mid-tones. Recommended default.",
},
{
value: "oklab",
label: "OKLab",
description:
"Perceptual but cartesian — a straight line through OK space. Smooth lightness, no hue rotation.",
},
{
value: "srgb",
label: "sRGB",
description:
"Legacy browser default. Mixes in gamma-encoded sRGB; often grays through the middle of two saturated colors.",
},
{
value: "hsl",
label: "HSL",
description:
"Walks the hue circle the shorter way between the two stops.",
},
{
value: "hsl-longer",
label: "HSL (longer hue)",
description:
"Walks the hue circle the longer way — produces a full rainbow sweep between two stops.",
},
];
/**
* The four CSS `<size>` keywords for radial gradients, each with a
* one-sentence hover explanation surfaced via the ⓘ icon.
*
* https://developer.mozilla.org/en-US/docs/Web/CSS/gradient/radial-gradient#size
*/
export const RADIAL_SIZE_OPTIONS: {
value: RadialSizeKeyword;
description: string;
}[] = [
{
value: "closest-side",
description:
"Ends at the side of the box closest to the center (the shortest reachable edge).",
},
{
value: "closest-corner",
description:
"Ends at the corner of the box closest to the center — passes through the nearest corner.",
},
{
value: "farthest-side",
description:
"Ends at the side of the box farthest from the center (the longest reachable edge).",
},
{
value: "farthest-corner",
description:
"Default. Ends at the corner of the box farthest from the center — gradient covers the entire box.",
},
];
registry/new-york/ui/fill-picker/contexts/gradient.ts
"use client";
import * as React from "react";
import type { GradientPickerState } from "../hooks/use-gradient-picker";
export const GradientPickerContext =
React.createContext<GradientPickerState | null>(null);
/**
* Renders the per-stop color editor for `<GradientPicker.Bar editOnClick>`.
*
* The Bar owns the *interaction* (which stop is being edited, and when a tap
* counts as an edit) but not the editor's UI dialect — that ships per variant
* so the shared Bar can live in a dialect-clean engine item. Each barrel
* injects its own default through `<Root stopEditor>`; consumers can override
* it with the same prop.
*
* `children` is the stop handle: the renderer must render it (that's the
* element the popover anchors to and the element the user drags).
*
* Called as a plain function inside the parts' stop `.map()`, not mounted as
* JSX — so it must *return* a component element and never call hooks itself,
* or the hook order breaks the moment a stop is added or removed.
*/
export type GradientStopEditorRenderer = (props: {
stopId: string;
open: boolean;
onOpenChange: (open: boolean) => void;
/** The stop handle element to anchor/wrap. */
children: React.ReactNode;
}) => React.ReactNode;
/**
* Carried separately from `GradientPickerContext` on purpose: the gradient
* state is also provided by `<FillPicker.Pane mode="gradient">`, which builds
* it straight from `useGradientPicker` and never sees a `stopEditor` prop.
* A sibling context lets a barrel inject the default once, above either
* provider, without threading the renderer through the headless state.
*/
export const GradientStopEditorContext =
React.createContext<GradientStopEditorRenderer | null>(null);
/** The injected stop editor, or `null` when no barrel/consumer provided one. */
export function useGradientStopEditor(): GradientStopEditorRenderer | null {
return React.useContext(GradientStopEditorContext);
}
export function useGradientPickerContext(): GradientPickerState {
const ctx = React.useContext(GradientPickerContext);
if (!ctx) {
throw new Error(
"GradientPicker.* parts must be rendered inside <GradientPicker.Root> " +
"or <FillPicker.Pane mode=\"gradient\">",
);
}
return ctx;
}
registry/new-york/ui/fill-picker/hooks/use-gradient-picker.ts
"use client";
import * as React from "react";
import {
DEFAULT_LINEAR,
DEFAULT_RADIAL,
DEFAULT_CONIC,
angleFromPoints,
formatGradient,
type Gradient,
type GradientInterp,
type GradientStop,
type GradientType,
type LinearGradient,
type RadialGradient,
type RadialSizeKeyword,
type ConicGradient,
} from "../lib/gradient";
import type { ColorFormat, OklchColor } from "../lib/types";
let __idCounter = 0;
const nextId = () => `s${++__idCounter}`;
// ---- Internal types --------------------------------------------------------
interface InternalStop extends GradientStop {
id: string;
}
// We store gradient config and stops separately so TypeScript always sees
// stops as InternalStop[] without needing to narrow through a discriminated union.
interface InternalState {
// The gradient without stops — used as a base for type-specific properties.
// We keep the full gradient object and overlay stops separately.
gradient: Gradient;
stops: InternalStop[];
}
// ---- Helpers ---------------------------------------------------------------
function attachIds(g: Gradient): InternalState {
// Sort on entry. `formatStops` emits stops in array order and CSS requires
// non-decreasing positions, so an out-of-order (but perfectly type-legal)
// stop array arriving via `defaultValue`, a controlled `value`, or
// `setGradient` would emit CSS the browser silently clamps into a flat
// ramp. The mutating setters (addStop / moveStop / reverseStops) already
// route through `sortByPosition` for exactly this reason — the entry points
// did not. `gradient.stops` is never read for stop data (toPublicGradient
// always rebuilds it from `stops`), so sorting the id-bearing array is
// sufficient.
const used = new Set<string>();
return {
gradient: g,
stops: [...g.stops]
.sort((a, b) => a.position - b.position)
.map((s) => {
// Honor a consumer-supplied id so controlled reconciliation can match
// on identity. Duplicates would defeat the point (and could collide
// with a generated id), so anything already taken falls back.
const id = s.id && !used.has(s.id) ? s.id : nextId();
used.add(id);
return { ...s, id };
}),
};
}
/**
* True when the caller is tracking stops by id — i.e. every stop of the
* gradient they handed us carries one. Partial id sets don't count: the
* identity match is all-or-nothing, so half-tagged stops would silently fall
* back to position matching anyway.
*/
function hasStopIds(g: Gradient): boolean {
return g.stops.length > 0 && g.stops.every((s) => !!s.id);
}
/**
* `withIds` echoes ids back out. Off by default so a caller who never opted
* into `GradientStop.id` keeps receiving exactly the object shape they always
* have. Once they *have* opted in, stripping ids would defeat the feature for
* the common `onValueChange={g => setG(g)}` pattern: the stored gradient would
* come back untagged on the next render and fall straight back to position
* matching. Stops added inside the picker are echoed too — a half-tagged set
* is the same as none.
*/
function toPublicGradient(s: InternalState, withIds: boolean): Gradient {
const stops: GradientStop[] = withIds
? s.stops.map((stop) => ({ ...stop }))
: s.stops.map(({ id: _id, ...rest }) => rest);
return { ...s.gradient, stops } as Gradient;
}
function sortByPosition(stops: InternalStop[]): InternalStop[] {
return [...stops].sort((a, b) => a.position - b.position);
}
const clamp01 = (n: number) => Math.max(0, Math.min(1, n));
function defaultsForType(type: GradientType): Gradient {
if (type === "linear") return DEFAULT_LINEAR;
if (type === "radial") return DEFAULT_RADIAL;
return DEFAULT_CONIC;
}
// ---- Public API types -------------------------------------------------------
export interface UseGradientPickerProps {
value?: Gradient;
defaultValue?: Gradient;
onValueChange?: (gradient: Gradient, css: string) => void;
/**
* Initial display format applied to every stop on mount. Each stop
* tracks its **own** format from then on — changing format in one
* stop's popover (or in `<GradientPicker.StopColor>`'s FormatSwitcher,
* which is bound to the selected stop) only updates that stop's row
* text. Defaults to `oklch` (lossless across the full P3/Rec.2020
* gamut stop colors can occupy).
*/
defaultStopColorFormat?: ColorFormat;
}
export interface GradientPickerState {
gradient: Gradient;
stops: InternalStop[];
selectedStopId: string;
selectedStop: InternalStop | null;
setGradient: (next: Gradient) => void;
setType: (type: GradientType) => void;
setAngle: (angleDeg: number) => void;
/**
* Set the start endpoint of a linear gradient (normalized 0..1 of the
* gradient box). Promotes the gradient to "positioned" mode — when both
* `start` and `end` are set, the gradient is treated as a line between
* them rather than an angle-only construct. `angle` is kept in sync with
* the derived direction. Passing `undefined` clears the override and
* returns the gradient to the legacy angle-only behavior.
*/
setLinearStart: (xy: { x: number; y: number } | undefined) => void;
/** Set the end endpoint of a linear gradient. See `setLinearStart`. */
setLinearEnd: (xy: { x: number; y: number } | undefined) => void;
setStartAngle: (angleDeg: number) => void;
setCenter: (xy: { x: number; y: number }) => void;
setInterp: (interp: GradientInterp) => void;
/**
* Toggle the `repeating` flag on the active gradient. When true,
* `formatGradient` emits `repeating-<type>-gradient(...)`. Stops are
* unaffected — the stop ramp simply tiles instead of stretching to fill
* the box.
*/
setRepeating: (repeating: boolean) => void;
setRadialShape: (shape: "circle" | "ellipse") => void;
setRadialSize: (size: RadialSizeKeyword) => void;
/**
* Set explicit numeric radii on the active radial gradient. Each value is
* a 0..1 fraction of the gradient box (x→width, y→height). Passing
* `undefined` clears the explicit override and falls back to the
* keyword-based size from `setRadialShape` / `setRadialSize`. Use this
* for ellipse-shape radials.
*/
setRadii: (radii: { x: number; y: number } | undefined) => void;
/**
* Set the explicit circle radius in absolute pixels. Only meaningful
* when `shape === "circle"` — produces a CSS `radial-gradient(<px>px
* at ...)` form, which is the only way to keep the gradient visually
* circular in any consumer container (the percentage pair form always
* implies ellipse). Passing `undefined` clears the override.
*/
setRadiusPx: (px: number | undefined) => void;
/**
* Latest reported width of the visual gradient box (px). Set by
* `<GradientPicker.Area>` via its `ResizeObserver`. Used by parts that
* want to display radius as a percentage of the picker box (e.g. the
* radius input on `<GradientPicker.RadiusInput>`) — they read this to
* convert between the absolute-px storage and a friendlier % display.
* `null` when no Area is currently mounted.
*/
containerWidth: number | null;
setContainerWidth: (width: number | null) => void;
selectStop: (id: string) => void;
addStop: (position: number, color?: OklchColor) => string;
removeStop: (id: string) => void;
moveStop: (id: string, position: number) => void;
setStopColor: (id: string, color: OklchColor) => void;
setStopHint: (id: string, hint: number | undefined) => void;
reverseStops: () => void;
/**
* Per-stop display format. Each row tracks its own — changing format
* in one stop's popover (or in `<GradientPicker.StopColor>`'s
* FormatSwitcher, which is bound to the selected stop) only updates
* that stop. Stops without an explicit entry fall back to
* `defaultStopColorFormat` (default `oklch`).
*/
getStopColorFormat: (id: string) => ColorFormat;
setStopColorFormat: (id: string, format: ColorFormat) => void;
}
// ---- Hook ------------------------------------------------------------------
export function useGradientPicker(
props: UseGradientPickerProps = {},
): GradientPickerState {
const { value, defaultValue, onValueChange, defaultStopColorFormat } = props;
const isControlled = value !== undefined;
const fallbackFormat: ColorFormat = defaultStopColorFormat ?? "oklch";
const [stopColorFormats, setStopColorFormats] = React.useState<
Record<string, ColorFormat>
>({});
const getStopColorFormat = React.useCallback(
(id: string): ColorFormat => stopColorFormats[id] ?? fallbackFormat,
[stopColorFormats, fallbackFormat],
);
const setStopColorFormat = React.useCallback(
(id: string, format: ColorFormat) =>
setStopColorFormats((prev) =>
prev[id] === format ? prev : { ...prev, [id]: format },
),
[],
);
const [internal, setInternal] = React.useState<InternalState>(() =>
attachIds(value ?? defaultValue ?? DEFAULT_LINEAR),
);
// Whether the caller opted into id tracking. State drives render output;
// the mirrored ref lets `apply` handle chained mutations synchronously.
const initialIdTracking = hasStopIds(
value ?? defaultValue ?? DEFAULT_LINEAR,
);
const [idTracked, setIdTracked] = React.useState(initialIdTracking);
const idTrackedRef = React.useRef(initialIdTracking);
const [selectedStopId, setSelectedStopId] = React.useState<string>(
() => internal.stops[0]?.id ?? "",
);
// Track the last gradient we emitted upward so the controlled-sync path can
// ignore echoes. Seed with the initial controlled value so the *first* sync
// is treated as an echo of our own initial state.
const [lastEmitted, setLastEmitted] = React.useState<Gradient | null>(
value ?? null,
);
// stateRef mirrors the last committed `internal` value so setters can
// compute and emit synchronously. `apply` also advances it immediately so
// chained setters in one event handler each see the previous result.
const stateRef = React.useRef(internal);
// Latest-callback ref so a fresh arrow each render doesn't re-create setters.
const onValueChangeRef = React.useRef(onValueChange);
// Per-shape override stashes. The radial gradient can carry either an
// ellipse override (`radii`) or a circle override (`radiusPx`), never
// both at once — `setRadii` / `setRadiusPx` cross-clear. But emit logic
// in `formatGradient` checks the override fields before the `shape`
// flag, so a stale override left over from the previous shape would
// silently keep drawing the old ending shape. We stash whichever
// override belongs to the shape we're leaving, strip both off the
// gradient, then restore the target shape's stash. Toggling back gives
// the user their last numeric value instead of nothing.
const radiiStashRef = React.useRef<{ x: number; y: number } | undefined>(
undefined,
);
const radiusPxStashRef = React.useRef<number | undefined>(undefined);
const [stashResetVersion, setStashResetVersion] = React.useState(0);
React.useLayoutEffect(() => {
stateRef.current = internal;
idTrackedRef.current = idTracked;
onValueChangeRef.current = onValueChange;
}, [idTracked, internal, onValueChange]);
React.useLayoutEffect(() => {
if (stashResetVersion === 0) return;
radiiStashRef.current = undefined;
radiusPxStashRef.current = undefined;
}, [stashResetVersion]);
// Sync controlled value during render (the "adjusting state during render"
// pattern from React docs) instead of an effect. An effect would commit a
// render with stale `internal`, then schedule a second render — children
// would see one frame with the previous gradient. Doing the reconcile here
// means the same render that observes a new `value` also renders with the
// reconciled internal state.
const [prevControlledValue, setPrevControlledValue] = React.useState<
Gradient | undefined
>(value);
if (isControlled && value !== prevControlledValue) {
setPrevControlledValue(value);
// An echo is consumed exactly once: a parent that later hands back the
// same retained object (undo/redo) is describing a real change.
if (lastEmitted !== null) setLastEmitted(null);
if (value !== lastEmitted) {
const prev = internal;
// `prev.stops` is always position-sorted (attachIds and every mutating
// setter sort), so the incoming array must be sorted the same way
// before the element-wise compare. Without this, a controlled consumer
// who keeps their stops in insertion order rather than position order
// fails the match on *every* update and gets fresh ids each time —
// which orphans `selectedStopId` and any per-stop color format.
//
// Without ids, stops are re-paired by index, so two sharing a position
// are disambiguated only by array order: swap two coincident stops and
// the ids stay put while the colors move between them. That is
// long-standing behavior (unchanged by the sort) and is pinned by the
// `stop identity with duplicate positions` tests. Consumers who need
// exactness opt into `GradientStop.id` and take the identity path
// below.
const incoming = [...value.stops].sort(
(a, b) => a.position - b.position,
);
// Identity path: when every incoming stop carries an id and that set is
// exactly what we hold, pair on the id. This is the only way to follow
// stops through a reorder that position+index cannot describe — two
// stops sharing a position, most obviously.
const prevIds = new Set(prev.stops.map((s) => s.id));
const sameLength = prev.stops.length === incoming.length;
// Ids, when supplied, are authoritative: a changed id set means the
// consumer is describing different stops, even if the positions happen
// to line up. Falling back to the position path there would ignore the
// identity the consumer just handed us.
const allHaveIds = incoming.length > 0 && incoming.every((s) => !!s.id);
// Duplicated ids can't identify anything — treat them as un-tagged
// rather than aliasing two stops onto one id.
const uniqueIds =
allHaveIds && new Set(incoming.map((s) => s.id)).size === incoming.length;
const byId =
uniqueIds && sameLength && incoming.every((s) => prevIds.has(s.id!));
const sameShape = uniqueIds
? byId
: sameLength &&
prev.stops.every((s, i) => s.position === incoming[i].position);
setIdTracked(uniqueIds);
const structuralMatch = prev.gradient.type === value.type && sameShape;
const next: InternalState = structuralMatch
? {
gradient: value,
stops: byId
? incoming.map((s) => ({ ...s, id: s.id as string }))
: prev.stops.map((s, i) => ({ ...incoming[i], id: s.id })),
}
: attachIds(value);
if (!structuralMatch) {
setStashResetVersion((version) => version + 1);
// Fresh ids orphan the selection; keep the same slot selected.
if (!next.stops.some((s) => s.id === selectedStopId)) {
const index = prev.stops.findIndex((s) => s.id === selectedStopId);
const fallback =
next.stops[Math.min(Math.max(index, 0), next.stops.length - 1)];
if (fallback) setSelectedStopId(fallback.id);
}
}
setInternal(next);
}
}
// Apply a state transition + emit in one shot. Read-modify-write through
// stateRef so chained calls in one event handler each see the previous
// result. Returning `prev` (or null) is a no-op signal.
const apply = React.useCallback(
(compute: (prev: InternalState) => InternalState | null) => {
const prev = stateRef.current;
const next = compute(prev);
if (next === null || next === prev) return;
stateRef.current = next;
setInternal(next);
const clean = toPublicGradient(next, idTrackedRef.current);
setLastEmitted(clean);
onValueChangeRef.current?.(clean, formatGradient(clean));
},
[],
);
// ---- Gradient-level setters ----------------------------------------------
const setGradient = React.useCallback(
(next: Gradient) => {
// Wholesale gradient replacement invalidates per-shape stashes — the
// user is handing us a brand-new gradient, not toggling the current one.
radiiStashRef.current = undefined;
radiusPxStashRef.current = undefined;
const tracksIds = hasStopIds(next);
idTrackedRef.current = tracksIds;
setIdTracked(tracksIds);
apply(() => attachIds(next));
setSelectedStopId((prev) => stateRef.current.stops[0]?.id ?? prev);
},
[apply],
);
const setType = React.useCallback(
(type: GradientType) =>
apply((prev) => {
if (prev.gradient.type === type) return prev;
radiiStashRef.current = undefined;
radiusPxStashRef.current = undefined;
const fallback = defaultsForType(type);
return {
gradient: { ...fallback, interp: prev.gradient.interp } as Gradient,
stops: prev.stops,
};
}),
[apply],
);
const setAngle = React.useCallback(
(angleDeg: number) =>
apply((prev) => {
if (prev.gradient.type !== "linear") return prev;
const cur = prev.gradient as LinearGradient;
// Setting an angle directly clears any free-position endpoints — the
// angle-only model can't represent both. Callers that want to
// preserve positioned mode should `setLinearStart` / `setLinearEnd`
// (which keep `angle` in sync with the derived direction).
const { start: _s, end: _e, ...rest } = cur;
const next: LinearGradient = { ...rest, angle: angleDeg };
return { gradient: next, stops: prev.stops };
}),
[apply],
);
const setLinearStart = React.useCallback(
(xy: { x: number; y: number } | undefined) =>
apply((prev) => {
if (prev.gradient.type !== "linear") return prev;
const cur = prev.gradient as LinearGradient;
const next: LinearGradient = xy
? {
...cur,
start: { x: clamp01(xy.x), y: clamp01(xy.y) },
angle: angleFromPoints(
{ x: clamp01(xy.x), y: clamp01(xy.y) },
cur.end,
) ?? cur.angle,
}
: (() => {
const { start: _drop, ...rest } = cur;
return rest as LinearGradient;
})();
return { gradient: next, stops: prev.stops };
}),
[apply],
);
const setLinearEnd = React.useCallback(
(xy: { x: number; y: number } | undefined) =>
apply((prev) => {
if (prev.gradient.type !== "linear") return prev;
const cur = prev.gradient as LinearGradient;
const next: LinearGradient = xy
? {
...cur,
end: { x: clamp01(xy.x), y: clamp01(xy.y) },
angle: angleFromPoints(
cur.start,
{ x: clamp01(xy.x), y: clamp01(xy.y) },
) ?? cur.angle,
}
: (() => {
const { end: _drop, ...rest } = cur;
return rest as LinearGradient;
})();
return { gradient: next, stops: prev.stops };
}),
[apply],
);
const setStartAngle = React.useCallback(
(angleDeg: number) =>
apply((prev) => {
if (prev.gradient.type !== "conic") return prev;
return {
gradient: { ...(prev.gradient as ConicGradient), startAngle: angleDeg },
stops: prev.stops,
};
}),
[apply],
);
const setCenter = React.useCallback(
(xy: { x: number; y: number }) =>
apply((prev) => {
if (prev.gradient.type === "linear") return prev;
return {
gradient: {
...(prev.gradient as RadialGradient | ConicGradient),
center: { x: clamp01(xy.x), y: clamp01(xy.y) },
},
stops: prev.stops,
};
}),
[apply],
);
const setInterp = React.useCallback(
(interp: GradientInterp) =>
apply((prev) => ({
gradient: { ...prev.gradient, interp } as Gradient,
stops: prev.stops,
})),
[apply],
);
const setRepeating = React.useCallback(
(repeating: boolean) =>
apply((prev) => {
const cur = prev.gradient;
// Strip the flag entirely when toggled off so the gradient stays
// structurally identical to one that never had it — keeps equality
// checks and serialization minimal.
if (!repeating) {
if (!cur.repeating) return prev;
const { repeating: _drop, ...rest } = cur;
return { gradient: rest as Gradient, stops: prev.stops };
}
if (cur.repeating) return prev;
return {
gradient: { ...cur, repeating: true } as Gradient,
stops: prev.stops,
};
}),
[apply],
);
const setRadialShape = React.useCallback(
(shape: "circle" | "ellipse") =>
apply((prev) => {
if (prev.gradient.type !== "radial") return prev;
const cur = prev.gradient as RadialGradient;
if (cur.shape === shape) return prev;
if (cur.shape === "circle") {
radiusPxStashRef.current = cur.radiusPx;
} else {
radiiStashRef.current = cur.radii;
}
const { radii: _r, radiusPx: _px, ...rest } = cur;
const next: RadialGradient =
shape === "circle"
? {
...rest,
shape,
...(radiusPxStashRef.current !== undefined
? { radiusPx: radiusPxStashRef.current }
: {}),
}
: {
...rest,
shape,
...(radiiStashRef.current
? { radii: radiiStashRef.current }
: {}),
};
return { gradient: next, stops: prev.stops };
}),
[apply],
);
const setRadialSize = React.useCallback(
(size: RadialSizeKeyword) =>
apply((prev) => {
if (prev.gradient.type !== "radial") return prev;
const base = prev.gradient as RadialGradient;
// The keyword form and the explicit `radii` / `radiusPx` overrides
// are mutually exclusive at emit time — `formatGradient` reads the
// overrides first and never falls through to `shape size`. Picking
// a size keyword is the user explicitly opting back into the
// keyword path, so drop the numeric overrides (and the stash) in
// the same commit. Without this, the dropdown changes the model
// field but the rendered CSS doesn't move.
radiiStashRef.current = undefined;
radiusPxStashRef.current = undefined;
const { radii: _r, radiusPx: _px, ...rest } = base;
return {
gradient: { ...rest, size } as RadialGradient,
stops: prev.stops,
};
}),
[apply],
);
const setRadii = React.useCallback(
(radii: { x: number; y: number } | undefined) =>
apply((prev) => {
if (prev.gradient.type !== "radial") return prev;
const base = prev.gradient as RadialGradient;
// Setting `radii` is the ellipse path. Three jobs:
// 1. Clear `radiusPx` so the two override fields never coexist.
// 2. Enforce `shape: "ellipse"` — `formatGradient` emits
// `ellipse <rx> <ry>` regardless of `shape` when `radii` is
// present, so anything else would desync the model from CSS
// (and from the ShapeSwitcher / radius input UI).
// 3. If the previous shape was circle, stash its `radiusPx` so the
// user can flip back via ShapeSwitcher without losing it (the
// stash matches `setRadialShape`'s own preservation behavior).
if (base.shape === "circle" && base.radiusPx !== undefined) {
radiusPxStashRef.current = base.radiusPx;
}
const { radii: _dropRadii, radiusPx: _dropPx, ...rest } = base;
const next: RadialGradient = radii
? {
...rest,
shape: "ellipse",
radii: { x: Math.max(0, radii.x), y: Math.max(0, radii.y) },
}
: ({ ...rest, shape: "ellipse" } as RadialGradient);
return { gradient: next, stops: prev.stops };
}),
[apply],
);
const [containerWidth, setContainerWidth] =
React.useState<number | null>(null);
const setRadiusPx = React.useCallback(
(px: number | undefined) =>
apply((prev) => {
if (prev.gradient.type !== "radial") return prev;
const base = prev.gradient as RadialGradient;
// Setting `radiusPx` is the circle path. Mirror `setRadii`:
// clear `radii`, enforce `shape: "circle"` (so the model can't
// disagree with CSS / the radius input), and stash any previous
// ellipse radii so ShapeSwitcher → ellipse restores them.
if (base.shape === "ellipse" && base.radii) {
radiiStashRef.current = base.radii;
}
const { radii: _dropRadii, radiusPx: _dropPx, ...rest } = base;
const next: RadialGradient =
px !== undefined
? { ...rest, shape: "circle", radiusPx: Math.max(0, px) }
: ({ ...rest, shape: "circle" } as RadialGradient);
return { gradient: next, stops: prev.stops };
}),
[apply],
);
// ---- Stop setters --------------------------------------------------------
const selectStop = React.useCallback(
(id: string) => setSelectedStopId(id),
[setSelectedStopId],
);
const addStop = React.useCallback(
(position: number, color?: OklchColor): string => {
const id = nextId();
const fallback: OklchColor = { l: 0.5, c: 0, h: 0, alpha: 1 };
apply((prev) => ({
gradient: prev.gradient,
stops: sortByPosition([
...prev.stops,
{ id, position, color: color ?? fallback },
]),
}));
setSelectedStopId(id);
return id;
},
[apply, setSelectedStopId],
);
const removeStop = React.useCallback(
(id: string) => {
let nextSelId: string | null = null;
apply((prev) => {
if (prev.stops.length <= 1) return prev;
const idx = prev.stops.findIndex((s) => s.id === id);
if (idx === -1) return prev;
const filtered = prev.stops.filter((s) => s.id !== id);
nextSelId = filtered[Math.min(idx, filtered.length - 1)].id;
return { gradient: prev.gradient, stops: filtered };
});
if (nextSelId !== null) setSelectedStopId(nextSelId);
},
[apply, setSelectedStopId],
);
const moveStop = React.useCallback(
(id: string, position: number) => {
// Intentionally unclamped: on a positioned linear gradient, authored
// positions outside 0..1 extrapolate beyond the [start, end] segment
// (legal CSS). UI inputs clamp their own *displayed* coordinates.
apply((prev) => {
// Move the target stop to the end before sorting so that in a position
// tie it sorts after existing stops at that position (stable sort).
const others = prev.stops.filter((s) => s.id !== id);
const moved = prev.stops.find((s) => s.id === id);
if (!moved) return prev;
return {
gradient: prev.gradient,
stops: sortByPosition([...others, { ...moved, position }]),
};
});
},
[apply],
);
const setStopColor = React.useCallback(
(id: string, color: OklchColor) =>
apply((prev) => ({
gradient: prev.gradient,
stops: prev.stops.map((s) => (s.id === id ? { ...s, color } : s)),
})),
[apply],
);
const setStopHint = React.useCallback(
(id: string, hint: number | undefined) =>
apply((prev) => ({
gradient: prev.gradient,
stops: prev.stops.map((s) => (s.id === id ? { ...s, hint } : s)),
})),
[apply],
);
const reverseStops = React.useCallback(
() =>
apply((prev) => {
if (prev.stops.length < 2) return prev;
// Mirror each stop's position around 0.5 so the visual order flips
// while ids stay attached to their colors. A hint lives on the stop
// that *follows* its segment, so after the flip the mirrored hint
// must move to the segment's other stop (the one that follows in
// the new order) — keeping it in place would strand it on a stop
// that now leads, where formatStops silently drops it.
const movedHints = new Map<string, number>();
prev.stops.forEach((s, i) => {
if (s.hint !== undefined && i > 0) {
movedHints.set(prev.stops[i - 1].id, 1 - s.hint);
}
});
const flipped = prev.stops.map((s) => ({
...s,
position: 1 - s.position,
hint: movedHints.get(s.id),
}));
return { gradient: prev.gradient, stops: sortByPosition(flipped) };
}),
[apply],
);
// ---- Derived values ------------------------------------------------------
const selectedStop = React.useMemo(
() => internal.stops.find((s) => s.id === selectedStopId) ?? null,
[internal.stops, selectedStopId],
);
const cleanGradient = React.useMemo(
() => toPublicGradient(internal, idTracked),
[idTracked, internal],
);
return {
gradient: cleanGradient,
stops: internal.stops,
selectedStopId,
selectedStop,
setGradient,
setType,
setAngle,
setLinearStart,
setLinearEnd,
setStartAngle,
setCenter,
setInterp,
setRepeating,
setRadialShape,
setRadialSize,
setRadii,
setRadiusPx,
containerWidth,
setContainerWidth,
selectStop,
addStop,
removeStop,
moveStop,
setStopColor,
setStopHint,
reverseStops,
getStopColorFormat,
setStopColorFormat,
};
}
registry/new-york/ui/fill-picker/parts/gradient/root.tsx
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import {
GradientPickerContext,
GradientStopEditorContext,
useGradientStopEditor,
type GradientStopEditorRenderer,
} from "../../contexts/gradient";
import {
useGradientPicker,
type UseGradientPickerProps,
} from "../../hooks/use-gradient-picker";
export interface RootProps
extends UseGradientPickerProps,
Omit<React.HTMLAttributes<HTMLDivElement>, "defaultValue" | "onChange"> {
/**
* Editor mounted around each `<Bar editOnClick>` stop handle. Each barrel
* (`GradientPicker` / `GradientPickerBase`) injects its own variant's editor
* as the default; pass this to substitute your own. Omitted, the editor is
* inherited from an enclosing provider — and with none (a deep-imported
* engine `Root`), the Bar renders bare handles.
*/
stopEditor?: GradientStopEditorRenderer;
}
export const Root = React.forwardRef<HTMLDivElement, RootProps>(function Root(
{
value,
defaultValue,
onValueChange,
defaultStopColorFormat,
stopEditor,
className,
children,
...rest
},
ref,
) {
const state = useGradientPicker({
value,
defaultValue,
onValueChange,
defaultStopColorFormat,
});
// Omitting the prop inherits whatever editor an outer provider (a barrel's
// `FillPicker.Pane`, say) already injected, rather than blanking it — a
// deep-imported engine Root nested in a wired-up tree keeps its editor.
const inherited = useGradientStopEditor();
const editor = stopEditor ?? inherited;
return (
<GradientStopEditorContext.Provider value={editor}>
<GradientPickerContext.Provider value={state}>
<div
ref={ref}
data-slot="gradient-picker"
className={cn(
"flex w-full max-w-[280px] flex-col gap-3 rounded-lg border border-border bg-popover p-3 text-popover-foreground shadow-sm",
className,
)}
{...rest}
>
{children}
</div>
</GradientPickerContext.Provider>
</GradientStopEditorContext.Provider>
);
});
registry/new-york/ui/fill-picker/parts/gradient/bar.tsx
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import {
useGradientPickerContext,
useGradientStopEditor,
} from "../../contexts/gradient";
import {
adjustStopsForEndpoints,
formatGradient,
projectStopPosition,
reverseProjectStopPosition,
sampleStopsAt,
type LinearGradient,
} from "../../lib/gradient";
import { formatColor } from "../../lib/color";
import { focusNeighborIn } from "./stop-list-shared";
import { useLiveAnnounce } from "../use-live-announce";
export interface BarProps extends React.HTMLAttributes<HTMLDivElement> {
/** Track height in px. Defaults to 12 to match `<ColorPicker.Hue>`. */
height?: number;
/** Handle (stop) diameter in px. Defaults to 16 to match the Hue thumb. */
handleSize?: number;
/**
* Open the stop-color editor popover when a handle is clicked without
* being dragged. Drag is still the canonical way to reposition a stop —
* the click-vs-drag distinction is movement-based, so flicking the
* handle even a pixel keeps the popover closed. Defaults to `false`.
*/
editOnClick?: boolean;
}
function buildPreviewGradient(
g: ReturnType<typeof useGradientPickerContext>["gradient"],
): string {
// For the bar preview we always render the stops as a horizontal linear
// gradient regardless of the live gradient type — the bar is the editing
// surface for stop positions. When the live gradient is a positioned
// linear with `start`/`end`, mirror the same stop projection used at CSS
// emit time so the Bar shows the *visible* stop positions.
const stops =
g.type === "linear" && g.start && g.end
? adjustStopsForEndpoints(g.stops, g.start, g.end)
: g.stops;
const linear: LinearGradient = {
type: "linear",
angle: 90,
interp: g.interp,
stops,
};
return formatGradient(linear);
}
export const Bar = React.forwardRef<HTMLDivElement, BarProps>(function Bar(
{ className, height = 12, handleSize = 16, editOnClick = false, ...rest },
ref,
) {
// Which stop's editor popover is open (null = none). Only meaningful
// when `editOnClick` is true.
const [openStopId, setOpenStopId] = React.useState<string | null>(null);
const ctx = useGradientPickerContext();
const stopEditor = useGradientStopEditor();
const wrapperRef = React.useRef<HTMLDivElement | null>(null);
const [liveText, announce] = useLiveAnnounce();
const trackRef = React.useRef<HTMLDivElement | null>(null);
// Cleanup hook for an active drag's document-level listeners. Lets us tear
// them down on unmount so a mid-drag remove of the Bar doesn't leave them
// attached to `document` and firing `ctx.moveStop` on a stale context.
const activeDragCleanupRef = React.useRef<(() => void) | null>(null);
React.useEffect(
() => () => {
activeDragCleanupRef.current?.();
activeDragCleanupRef.current = null;
},
[],
);
// Forward the consumer's ref to the outer wrapper (the element that carries
// `data-slot="gradient-bar"`) so `wrapper.querySelector('[data-slot="gradient-bar"]')`
// works as expected; pointer math still uses the inner trackRef.
React.useImperativeHandle(ref, () => wrapperRef.current as HTMLDivElement);
// When the live gradient is positioned-linear, stop positions are projected
// onto the CSS gradient line for display + pointer math. Authored values
// (what's written to `stop.position`) stay 0..1 along the segment.
const linear = ctx.gradient.type === "linear" ? ctx.gradient : null;
const start = linear?.start;
const end = linear?.end;
const toDisplay = (authored: number) =>
projectStopPosition(authored, start, end);
const fromDisplay = (displayed: number) =>
reverseProjectStopPosition(displayed, start, end);
const displayedPositionFromEvent = React.useCallback(
(clientX: number): number => {
const el = trackRef.current;
if (!el) return 0;
const rect = el.getBoundingClientRect();
if (rect.width <= 0) return 0;
return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
},
[],
);
// A stop added by clicking the track becomes the selected stop, but the
// click landed on the track — which isn't focusable — so focus stays on
// <body> and the new stop can't be nudged or deleted from the keyboard.
// Its handle doesn't exist until the next render, hence the deferral.
const pendingFocusIdRef = React.useRef<string | null>(null);
React.useLayoutEffect(() => {
const id = pendingFocusIdRef.current;
if (!id) return;
pendingFocusIdRef.current = null;
// Matched by walking the handles rather than an attribute selector:
// stop ids can come from the consumer now, so they aren't guaranteed
// to be selector-safe.
const handles = wrapperRef.current?.querySelectorAll<HTMLElement>(
'[role="slider"]',
);
handles?.forEach((h) => {
if (h.dataset.stopId === id) h.focus();
});
});
const onTrackPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (e.target !== trackRef.current) return; // handles handle their own drag
// Canceling pointerdown suppresses the compatibility mousedown, and with
// it the browser's default focus handling. Without this the layout effect
// below focuses the new handle and the browser immediately takes it back
// — clicking a non-focusable element blurs to <body> — so the stop you
// just placed still wouldn't take Delete or the arrow keys.
e.preventDefault();
const displayed = displayedPositionFromEvent(e.clientX);
// No clamp on the authored value: on a positioned linear, clicks outside
// the projected [start, end] segment extrapolate (< 0 / > 1 are legal
// CSS stop positions), so the stop lands where the user clicked instead
// of stacking on the first/last stop. Plain mode is unaffected —
// fromDisplay is the identity and `displayed` is already 0..1.
const authored = fromDisplay(displayed);
pendingFocusIdRef.current = ctx.addStop(
authored,
sampleStopsAt(ctx.stops, authored),
);
};
const startStopDrag = (id: string) => (e: React.PointerEvent<HTMLDivElement>) => {
e.stopPropagation();
// preventDefault keeps the browser from starting a text selection or a
// native drag mid-gesture, but it also suppresses the focus a pointerdown
// would normally give the handle. Focus it explicitly: without this a
// clicked stop looks selected yet keyboard keys (nudge, Delete) go to
// <body> and do nothing.
e.preventDefault();
e.currentTarget.focus();
ctx.selectStop(id);
// Listen on document, not the handle DOM node. moveStop reorders the
// stops array, and React reconciles the per-stop <div>s in their new
// order; if we captured/listened on the original handle element, a
// sibling handle could end up under the pointer and steal the gesture.
// Document-level listeners + an `id` captured in closure keep the drag
// bound to the originally-clicked stop regardless of reordering.
let pendingRemove = false;
// Track pointer movement so pointerUp can tell a tap from a drag —
// 3px slack absorbs the jitter of a deliberate click.
const startX = e.clientX;
const startY = e.clientY;
// Document-level listeners see every pointer; only the one that started
// this drag may steer or end it (multi-touch).
const dragPointerId = e.pointerId;
let moved = false;
const onMove = (ev: PointerEvent) => {
if (ev.pointerId !== dragPointerId) return;
// Self-heal a missed release: without pointer capture, letting go of
// the button outside the browser window never delivers pointerup, so
// the first re-entry move arrives with no buttons pressed. End the
// drag instead of letting the stop chase the cursor.
if (ev.buttons === 0) {
cleanup();
if (pendingRemove) ctx.removeStop(id);
return;
}
if (!moved) {
const dx = ev.clientX - startX;
const dy = ev.clientY - startY;
if (dx * dx + dy * dy > 9) moved = true;
}
const rect = trackRef.current?.getBoundingClientRect();
if (!rect) return;
const dy = ev.clientY - rect.bottom;
// Mark for removal while dragged below the bar by more than 24px,
// but only commit on release so the user can drag back up to cancel.
pendingRemove = dy > 24 && ctx.stops.length > 1;
if (!pendingRemove && moved) {
const displayed = displayedPositionFromEvent(ev.clientX);
ctx.moveStop(id, fromDisplay(displayed));
}
};
const cleanup = () => {
document.removeEventListener("pointermove", onMove);
document.removeEventListener("pointerup", onUp);
document.removeEventListener("pointercancel", onUp);
activeDragCleanupRef.current = null;
};
const onUp = (ev: PointerEvent) => {
if (ev.pointerId !== dragPointerId) return;
cleanup();
if (pendingRemove) ctx.removeStop(id);
else if (!moved && editOnClick) setOpenStopId(id);
};
document.addEventListener("pointermove", onMove);
document.addEventListener("pointerup", onUp);
document.addEventListener("pointercancel", onUp);
activeDragCleanupRef.current = cleanup;
};
const onStopKeyDown =
(id: string, position: number) => (e: React.KeyboardEvent<HTMLDivElement>) => {
const step = e.shiftKey ? 0.05 : 0.01;
// Nudge in *displayed* space so a 1% press visibly moves the stop the
// same distance on screen regardless of whether the gradient is
// positioned-linear or angle-only.
const displayed = toDisplay(position);
// Clamp in displayed space: keyboard nudges stay on the visible track
// (moveStop itself no longer clamps, to allow extrapolated stops).
const clamp01 = (n: number) => Math.max(0, Math.min(1, n));
if (e.key === "ArrowLeft") {
e.preventDefault();
ctx.moveStop(id, fromDisplay(clamp01(displayed - step)));
} else if (e.key === "ArrowRight") {
e.preventDefault();
ctx.moveStop(id, fromDisplay(clamp01(displayed + step)));
} else if (e.key === "Delete" || e.key === "Backspace") {
e.preventDefault();
// A gradient needs at least two stops; `removeStop` refuses below
// that. The StopList's mouse affordance conveys this by rendering
// its remove button `disabled` — the Bar handle has no such visual,
// so announce instead of failing silently.
if (ctx.stops.length <= 1) {
announce("Cannot remove the last stop");
return;
}
// Move focus to a neighboring handle *before* removal, mirroring
// StopList: the focused node is about to unmount, and without this
// focus falls to <body> and keyboard navigation is lost.
focusNeighborIn(wrapperRef.current, e.currentTarget, '[role="slider"]');
ctx.removeStop(id);
announce("Stop removed");
}
};
return (
<div
ref={wrapperRef}
data-slot="gradient-bar"
className={cn("relative w-full select-none", className)}
style={{ height }}
{...rest}
>
<div
ref={trackRef}
onPointerDown={onTrackPointerDown}
className="absolute inset-0 touch-none rounded-full border border-border"
style={{
background: `${buildPreviewGradient(ctx.gradient)}, repeating-conic-gradient(#bbb 0 25%, #fff 0 50%) 0 0/8px 8px`,
}}
/>
{ctx.stops.map((s) => {
const selected = s.id === ctx.selectedStopId;
const displayed = Math.max(0, Math.min(1, toDisplay(s.position)));
const displayedPct = Math.round(displayed * 100);
const handle = (
<div
role="slider"
data-stop-id={s.id}
aria-label={`Stop at ${displayedPct}%`}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={displayedPct}
aria-valuetext={`${displayedPct} percent`}
aria-orientation="horizontal"
aria-current={selected ? "true" : undefined}
tabIndex={0}
onPointerDown={startStopDrag(s.id)}
onFocus={() => ctx.selectStop(s.id)}
onKeyDown={onStopKeyDown(s.id, s.position)}
style={{
// Match the Hue thumb's positioning math: inset the centerline
// by handleSize/2 so pos=0/1 sit flush with the track edges.
left: `calc(${displayed} * (100% - ${handleSize}px) + ${handleSize / 2}px)`,
width: handleSize,
height: handleSize,
background: formatColor(s.color, "oklch"),
}}
className={cn(
"absolute top-1/2 -translate-x-1/2 -translate-y-1/2 cursor-grab touch-none rounded-full border-2 border-white shadow-[0_0_0_1.5px_rgba(0,0,0,0.6)]",
// WCAG 2.5.8: small visual dot, ≥24px pointer target.
"before:absolute before:-inset-1.5 before:content-['']",
selected && "outline-2 outline-offset-1 outline-ring",
)}
/>
);
// The editor is a slot, not an import: the Bar is shared by both
// variants, so the dialect-specific editor is injected by the barrel
// (see `GradientStopEditorRenderer`). Without one — or without
// `editOnClick` — the handle renders bare.
if (!editOnClick || !stopEditor)
return <React.Fragment key={s.id}>{handle}</React.Fragment>;
return (
<React.Fragment key={s.id}>
{stopEditor({
stopId: s.id,
open: openStopId === s.id,
onOpenChange: (o) => setOpenStopId(o ? s.id : null),
children: handle,
})}
</React.Fragment>
);
})}
<span aria-live="polite" className="sr-only">
{liveText}
</span>
</div>
);
});
registry/new-york/ui/fill-picker/parts/gradient/overlay.tsx
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { useGradientPickerContext } from "../../contexts/gradient";
import { type RadialSizeKeyword } from "../../lib/gradient";
import { formatColor } from "../../lib/color";
import { CHECKERBOARD_LG } from "../../lib/constants";
import { useLiveAnnounce } from "../use-live-announce";
import { trackPointerDrag } from "./pointer-drag";
export interface OverlayProps extends React.HTMLAttributes<HTMLDivElement> {
/**
* Pixel radius of the conic "from-angle" dial handle measured from the
* center handle. Locked on this ring; drag rotates `startAngle`.
*/
conicDialRadius?: number;
}
interface XY {
x: number;
y: number;
}
const HANDLE_PX = 14;
// Re-exported so `<GradientPicker.Area>` can paint its own checkerboard
/**
* Convert a CSS gradient angle (0deg = up, increases clockwise) to a unit
* direction vector in screen coordinates (y goes down).
* 0deg → ( 0, -1) up
* 90deg → ( 1, 0) right
* 180deg → ( 0, 1) down
* 270deg → (-1, 0) left
*/
function angleToDir(angleDeg: number): XY {
const r = (angleDeg * Math.PI) / 180;
return { x: Math.sin(r), y: -Math.cos(r) };
}
/**
* Inverse of `angleToDir`. Returns a CSS gradient angle in [0, 360) given a
* non-zero direction vector in screen coordinates.
*/
function dirToAngle(dx: number, dy: number): number {
// atan2(dx, -dy): 0 = up, increases clockwise — matches AnglePad.
const deg = (Math.atan2(dx, -dy) * 180) / Math.PI;
return (deg + 360) % 360;
}
/**
* Project a ray from the box center along `dir` and return the distance to
* the box edge. Works for any aspect ratio. Caller must ensure |dir| > 0.
*/
function edgeExtent(dir: XY, halfW: number, halfH: number): number {
const ex = dir.x === 0 ? Infinity : halfW / Math.abs(dir.x);
const ey = dir.y === 0 ? Infinity : halfH / Math.abs(dir.y);
return Math.min(ex, ey);
}
/** Snap an angle to the nearest `step` degrees, normalized to [0, 360). */
function snapDeg(deg: number, step: number): number {
const s = Math.round(deg / step) * step;
return ((s % 360) + 360) % 360;
}
/**
* Seed numeric radii from the radial gradient's keyword form (`shape` +
* `size`) so the edge handle has a sensible starting position before the
* user has touched it. Returns radii as fractions of the box width/height
* matching the CSS `<length-percentage>{1,2}` convention.
*
* For corner-targeting keywords on ellipse we deliberately approximate by
* stretching each axis to the appropriate side rather than implementing the
* exact CSS spec — once the user drags, they own the radii anyway.
*/
export function keywordToRadii(
shape: "circle" | "ellipse",
size: RadialSizeKeyword,
center: { x: number; y: number },
w: number,
h: number,
): { x: number; y: number } {
const cx = center.x * w;
const cy = center.y * h;
const dxClose = Math.min(cx, w - cx);
const dxFar = Math.max(cx, w - cx);
const dyClose = Math.min(cy, h - cy);
const dyFar = Math.max(cy, h - cy);
if (shape === "circle") {
let rPx: number;
switch (size) {
case "closest-side":
rPx = Math.min(dxClose, dyClose);
break;
case "closest-corner":
rPx = Math.hypot(dxClose, dyClose);
break;
case "farthest-side":
rPx = Math.max(dxFar, dyFar);
break;
case "farthest-corner":
rPx = Math.hypot(dxFar, dyFar);
break;
}
return { x: rPx / w, y: rPx / h };
}
switch (size) {
case "closest-side":
return { x: dxClose / w, y: dyClose / h };
case "closest-corner":
// Ellipse closest-corner: stretch each axis to the *closest* side along
// that axis (approximation — the spec scales to actually touch the
// closest corner, but visually this is close enough as a seed).
return { x: dxClose / w, y: dyClose / h };
case "farthest-side":
return { x: dxFar / w, y: dyFar / h };
case "farthest-corner":
return { x: dxFar / w, y: dyFar / h };
}
}
/**
* Transparent handle-only overlay that surfaces the *geometry* of the
* active gradient on top of any element. Designed to be dropped over a
* consumer's own object (a canvas frame, a div on a design surface,
* etc.) so the user can edit the gradient in place — same interactions
* as `<GradientPicker.Area>` but without painting the gradient itself.
* The host container must establish a positioning context (e.g.
* `position: relative`) and represent the same coordinate space the
* gradient will be applied to.
*
* Handle behavior by gradient type (identical to the in-Area version):
*
* - **Linear** — start / end endpoint handles tinted with the first / last
* stop colors. Dragging endpoints rotates the angle (and, on first
* drag, promotes to free-position mode). Middle stops are edited on
* `<GradientPicker.Bar>`, never on the canvas — the overlay only edits
* *geometry*. Consumers mounting an Overlay by itself should pair it
* with a `<GradientPicker.Bar>` or `<GradientPicker.StopList>` somewhere
* in the same Root, or inner stops won't be editable by pointer at all.
* - **Radial** — center handle + edge handle. Edge handle drives radius
* (px for circles, normalized for ellipses); center moves the
* gradient and leaves the keyword `size` alone — keyword extents
* (`farthest-corner` etc.) naturally recompute against the new center.
* - **Conic** — center handle + dial handle for `startAngle`.
*
* The root div is `pointer-events-none` so empty regions pass clicks
* through to whatever sits beneath; the handle buttons themselves
* intercept events normally. Pair with `<GradientPicker.Area>` for a
* self-contained preview, or with any custom container for in-place
* editing on the consumer's canvas.
*/
export const Overlay = React.forwardRef<HTMLDivElement, OverlayProps>(
function Overlay({ className, conicDialRadius, style, ...rest }, ref) {
const ctx = useGradientPickerContext();
const { gradient } = ctx;
const padRef = React.useRef<HTMLDivElement | null>(null);
React.useImperativeHandle(ref, () => padRef.current as HTMLDivElement);
// Observe the host's dimensions (which the overlay fills via `inset-0`)
// so handle geometry stays correct at any aspect ratio.
const [dims, setDims] = React.useState<{ w: number; h: number }>({
w: 0,
h: 0,
});
const setContainerWidth = ctx.setContainerWidth;
React.useEffect(() => {
const el = padRef.current;
if (!el) return;
const ro = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) return;
const { width, height: h } = entry.contentRect;
setDims({ w: width, h });
// Publish width to context so sibling parts (e.g. the radius input
// on `<GradientPicker.RadiusInput>`) can convert between absolute
// px and a percentage display.
setContainerWidth(width);
});
ro.observe(el);
// Intentionally do NOT clear `containerWidth` on unmount: if multiple
// Overlays / an <Area> + an Overlay are mounted simultaneously (e.g.
// the playground's "Area + Overlay (demo)" toggle), the surviving
// publisher should keep ownership of the value. The next mount will
// overwrite it; until then a stale-but-non-null last-known width is
// strictly better than null (which would force percentage inputs to
// fall back to px and break the user's display unit mid-session).
return () => ro.disconnect();
}, [setContainerWidth]);
// Compute handle positions in *box-local* pixels. Center of the box is
// (w/2, h/2). All handle math lives in this coordinate space so we can
// reason about it without thinking about page offsets.
const handles = React.useMemo(() => {
const { w, h } = dims;
if (w === 0 || h === 0) return null;
const cx = w / 2;
const cy = h / 2;
if (gradient.type === "linear") {
if (gradient.start && gradient.end) {
// Positioned linear: endpoints can sit anywhere inside the box.
// Visually clamp by HANDLE_PX/2 so the handle dot stays fully inside
// the `overflow-hidden` container even when the user drags to a
// corner. The stored `gradient.start` / `gradient.end` keep the
// true pointer position — only the rendered handle + dashed line
// endpoint are inset.
const inset = HANDLE_PX / 2;
const clampX = (px: number) => Math.max(inset, Math.min(w - inset, px));
const clampY = (px: number) => Math.max(inset, Math.min(h - inset, px));
return {
a: {
x: clampX(gradient.start.x * w),
y: clampY(gradient.start.y * h),
},
b: {
x: clampX(gradient.end.x * w),
y: clampY(gradient.end.y * h),
},
showConnector: true,
};
}
const dir = angleToDir(gradient.angle);
// Inset the edge by half a handle so the dot stays fully inside the box
// and doesn't get clipped by `overflow-hidden` on the container. The
// gradient itself still paints to the true edge — only the handle
// visualization is inset.
const inset = HANDLE_PX / 2;
const t = edgeExtent(
dir,
Math.max(0, w / 2 - inset),
Math.max(0, h / 2 - inset),
);
return {
a: { x: cx - dir.x * t, y: cy - dir.y * t },
b: { x: cx + dir.x * t, y: cy + dir.y * t },
showConnector: true,
};
}
if (gradient.type === "radial") {
const ax = gradient.center.x * w;
const ay = gradient.center.y * h;
// Edge handle position resolves in three precedence tiers, matching
// formatGradient's emit logic:
// 1. circle + radiusPx → place the handle on the ring at (rPx, 0).
// Single-axis since the gradient is pixel-circular.
// 2. radii (ellipse) → place the handle at the +x +y corner of the
// ellipse bounding box.
// 3. keyword form → seed both from `keywordToRadii` and treat the
// handle as ellipse-style for the visual until the user
// promotes (drag promotes per shape).
let bx: number;
let by: number;
if (gradient.shape === "circle" && gradient.radiusPx !== undefined) {
bx = ax + gradient.radiusPx;
by = ay;
} else if (gradient.radii) {
bx = ax + gradient.radii.x * w;
by = ay + gradient.radii.y * h;
} else {
const seeded = keywordToRadii(
gradient.shape,
gradient.size,
gradient.center,
w,
h,
);
bx = ax + seeded.x * w;
by = ay + seeded.y * h;
}
return {
a: { x: ax, y: ay },
b: { x: bx, y: by } as XY,
showConnector: false,
};
}
// conic
const a = { x: gradient.center.x * w, y: gradient.center.y * h };
const dir = angleToDir(gradient.startAngle);
const dialR = conicDialRadius ?? Math.min(w, h) / 2 - HANDLE_PX;
return {
a,
b: { x: a.x + dir.x * dialR, y: a.y + dir.y * dialR },
showConnector: true,
};
}, [dims, gradient, conicDialRadius]);
// Drag helpers ------------------------------------------------------------
const localFromEvent = (clientX: number, clientY: number): XY => {
const el = padRef.current;
if (!el) return { x: 0, y: 0 };
const r = el.getBoundingClientRect();
return { x: clientX - r.left, y: clientY - r.top };
};
type HandleKind =
| "linear-a"
| "linear-b"
| "center"
| "conic-dial"
| "radial-edge";
const handleAt = (kind: HandleKind, p: XY, shiftKey: boolean) => {
const { w, h } = dims;
const cx = w / 2;
const cy = h / 2;
if (kind === "linear-a" || kind === "linear-b") {
if (gradient.type !== "linear") return;
// Free position: drag puts the endpoint exactly where the pointer is.
// Promote the gradient to "positioned" mode by ensuring both `start`
// and `end` are populated — seed the other endpoint from the angle if
// this is the first drag.
const nx = Math.max(0, Math.min(1, p.x / w));
const ny = Math.max(0, Math.min(1, p.y / h));
const ensureOther = (existing: { x: number; y: number } | undefined) => {
if (existing) return existing;
// Seed from the current angle so the line keeps its visual direction
// for the very first drag — no visible jump on promotion.
const dir = angleToDir(gradient.angle);
const inset = HANDLE_PX / 2;
const t = edgeExtent(
dir,
Math.max(0, w / 2 - inset),
Math.max(0, h / 2 - inset),
);
// The "other" endpoint is opposite the one being dragged.
const sign = kind === "linear-a" ? 1 : -1;
const ox = cx + dir.x * t * sign;
const oy = cy + dir.y * t * sign;
return { x: ox / w, y: oy / h };
};
if (kind === "linear-a") {
const other = ensureOther(gradient.end);
if (!gradient.end) ctx.setLinearEnd(other);
ctx.setLinearStart({ x: nx, y: ny });
} else {
const other = ensureOther(gradient.start);
if (!gradient.start) ctx.setLinearStart(other);
ctx.setLinearEnd({ x: nx, y: ny });
}
return;
}
if (kind === "center") {
const nx = Math.max(0, Math.min(1, p.x / w));
const ny = Math.max(0, Math.min(1, p.y / h));
ctx.setCenter({ x: nx, y: ny });
return;
}
if (kind === "radial-edge") {
if (gradient.type !== "radial") return;
const ax = gradient.center.x * w;
const ay = gradient.center.y * h;
// Edge handle lives in the +x +y quadrant relative to the center —
// drags into other quadrants are mirrored via abs() so radii stay
// strictly non-negative (CSS forbids negative gradient radii).
const dxPx = Math.abs(p.x - ax);
const dyPx = Math.abs(p.y - ay);
// Honor the declared shape, Shift inverts (and flips shape in the
// model — `setRadiusPx` / `setRadii` enforce `shape: "circle"` /
// `"ellipse"` respectively, so the data, the CSS output, and the
// ShapeSwitcher all stay in sync after a Shift-drag).
// shape=circle + no shift → stays circle, writes radiusPx
// shape=circle + shift → flips to ellipse, writes radii
// shape=ellipse + no shift → stays ellipse, writes radii
// shape=ellipse + shift → flips to circle, writes radiusPx
const renderAsCircle = (gradient.shape === "circle") !== shiftKey;
if (renderAsCircle) {
// True pixel circle: pick the larger pointer distance as the
// radius. Stored in absolute pixels so the consumer-side CSS
// (`circle <px>px`) renders a real circle in any container.
const rPx = Math.max(dxPx, dyPx);
ctx.setRadiusPx(rPx);
} else {
ctx.setRadii({
x: Math.max(0, Math.min(2, dxPx / w)),
y: Math.max(0, Math.min(2, dyPx / h)),
});
}
return;
}
// conic-dial — rotate around current center, distance is ignored.
if (gradient.type !== "conic") return;
const ax = gradient.center.x * w;
const ay = gradient.center.y * h;
let deg = dirToAngle(p.x - ax, p.y - ay);
if (shiftKey) deg = snapDeg(deg, 15);
ctx.setStartAngle(deg);
};
// Keyboard nudge helpers --------------------------------------------------
const rotate = (current: number, e: React.KeyboardEvent): number | null => {
const step = e.shiftKey ? 15 : 1;
let next = current;
if (e.key === "ArrowRight" || e.key === "ArrowUp") next = current + step;
else if (e.key === "ArrowLeft" || e.key === "ArrowDown")
next = current - step;
else if (e.key === "Home") next = 0;
else if (e.key === "End") next = 359;
else return null;
return ((next % 360) + 360) % 360;
};
// 2D handles (center, free-positioned endpoints) use role="application",
// which has no value semantics — announce keyboard moves in a polite live
// region rendered at the overlay root.
const [liveText, announce] = useLiveAnnounce();
/**
* Stack three background layers so a handle dot shows the stop's actual
* color (alpha intact) while still fully occluding the dashed gradient
* line behind it: stop-color on top, checkerboard underneath so
* transparency reads as transparency, and the Handle's own solid
* `bg-background` at the bottom for the final opacity guarantee.
*/
const stopSwatchStyle = (color: typeof ctx.stops[number]["color"]) => {
const css = formatColor(color, "oklch");
return {
backgroundImage: `linear-gradient(${css}, ${css}), ${CHECKERBOARD_LG}`,
backgroundSize: "auto, 6px 6px",
} as const;
};
const onKeyDownAngle = (e: React.KeyboardEvent<HTMLButtonElement>) => {
if (gradient.type !== "linear") return;
const next = rotate(gradient.angle, e);
if (next === null) return;
e.preventDefault();
ctx.setAngle(next);
};
const onKeyDownLinearEndpoint = (
which: "linear-a" | "linear-b",
e: React.KeyboardEvent<HTMLButtonElement>,
) => {
if (gradient.type !== "linear") return;
const point =
which === "linear-a" ? gradient.start : gradient.end;
if (!point) {
// Fall back to angle rotation when this handle hasn't been promoted
// to free-position mode yet — keeps the angle-only behavior intact.
onKeyDownAngle(e);
return;
}
const step = e.shiftKey ? 0.05 : 0.01;
let { x, y } = point;
if (e.key === "ArrowLeft") x -= step;
else if (e.key === "ArrowRight") x += step;
else if (e.key === "ArrowUp") y -= step;
else if (e.key === "ArrowDown") y += step;
else return;
e.preventDefault();
const next = {
x: Math.max(0, Math.min(1, x)),
y: Math.max(0, Math.min(1, y)),
};
if (which === "linear-a") ctx.setLinearStart(next);
else ctx.setLinearEnd(next);
announce(
`${which === "linear-a" ? "Start" : "End"} x ${Math.round(next.x * 100)}%, y ${Math.round(next.y * 100)}%`,
);
};
const onKeyDownConicDial = (e: React.KeyboardEvent<HTMLButtonElement>) => {
if (gradient.type !== "conic") return;
const next = rotate(gradient.startAngle, e);
if (next === null) return;
e.preventDefault();
ctx.setStartAngle(next);
};
const onKeyDownRadii = (e: React.KeyboardEvent<HTMLButtonElement>) => {
if (gradient.type !== "radial") return;
const { w, h } = dims;
if (w === 0 || h === 0) return;
// Same shape-lock convention as the pointer drag.
const renderAsCircle = (gradient.shape === "circle") !== e.shiftKey;
const stepRatio = e.shiftKey ? 0.05 : 0.01;
if (renderAsCircle) {
// Resolve the current radius in pixels regardless of which field is
// populated, then nudge in pixel space.
const currentPx =
gradient.radiusPx ??
(gradient.radii
? Math.max(gradient.radii.x * w, gradient.radii.y * h)
: keywordToRadii(
gradient.shape,
gradient.size,
gradient.center,
w,
h,
).x * w);
const stepPx = stepRatio * Math.min(w, h);
let next = currentPx;
if (e.key === "ArrowLeft" || e.key === "ArrowDown") next -= stepPx;
else if (e.key === "ArrowRight" || e.key === "ArrowUp") next += stepPx;
else return;
e.preventDefault();
ctx.setRadiusPx(Math.max(0, next));
return;
}
// Ellipse path: per-axis nudge in normalized space, same as before.
const current =
gradient.radii ??
keywordToRadii(gradient.shape, gradient.size, gradient.center, w, h);
let { x, y } = current;
if (e.key === "ArrowLeft") x -= stepRatio;
else if (e.key === "ArrowRight") x += stepRatio;
else if (e.key === "ArrowUp") y -= stepRatio;
else if (e.key === "ArrowDown") y += stepRatio;
else return;
e.preventDefault();
ctx.setRadii({
x: Math.max(0, Math.min(2, x)),
y: Math.max(0, Math.min(2, y)),
});
};
const onKeyDownCenter = (e: React.KeyboardEvent<HTMLButtonElement>) => {
if (gradient.type === "linear") return;
const center = gradient.center;
const step = e.shiftKey ? 0.05 : 0.01;
let { x, y } = center;
if (e.key === "ArrowLeft") x -= step;
else if (e.key === "ArrowRight") x += step;
else if (e.key === "ArrowUp") y -= step;
else if (e.key === "ArrowDown") y += step;
else if (e.key === "Home") {
x = 0.5;
y = 0.5;
} else return;
e.preventDefault();
const next = {
x: Math.max(0, Math.min(1, x)),
y: Math.max(0, Math.min(1, y)),
};
ctx.setCenter(next);
announce(
`Center x ${Math.round(next.x * 100)}%, y ${Math.round(next.y * 100)}%`,
);
};
const beginDrag = (
kind: HandleKind,
e: React.PointerEvent<HTMLButtonElement>,
) => {
e.preventDefault();
e.stopPropagation();
handleAt(kind, localFromEvent(e.clientX, e.clientY), e.shiftKey);
trackPointerDrag(e.currentTarget, e.pointerId, (ev) =>
handleAt(kind, localFromEvent(ev.clientX, ev.clientY), ev.shiftKey),
);
};
// Radius handle ARIA. The pixel radius of a circle is unbounded (it can
// exceed the box), and keyword circles normalized to width can pass 200%
// on tall boxes — so the max grows with the value instead of letting
// aria-valuenow overshoot a fixed aria-valuemax.
const radiusAria = (() => {
if (gradient.type !== "radial" || dims.w === 0 || dims.h === 0) return null;
if (gradient.shape === "circle" && gradient.radiusPx !== undefined) {
const now = Math.round(gradient.radiusPx);
return {
now,
max: Math.max(now, Math.round(Math.hypot(dims.w, dims.h))),
text: `circle radius ${now} pixels`,
};
}
const r =
gradient.radii ??
keywordToRadii(
gradient.shape,
gradient.size,
gradient.center,
dims.w,
dims.h,
);
const now = Math.round(r.x * 100);
return {
now,
max: Math.max(200, now),
text: `radius x ${now}%, y ${Math.round(r.y * 100)}%`,
};
})();
// Render ------------------------------------------------------------------
return (
<div
ref={padRef}
data-slot="gradient-overlay"
style={style}
className={cn(
// Fills the host container; pointer-events:none so empty regions
// fall through to whatever sits beneath (consumer's canvas, the
// <Area>'s painted bg, etc.). The handle buttons explicitly set
// pointer-events:auto so they still receive clicks.
"pointer-events-none absolute inset-0",
className,
)}
{...rest}
>
<span aria-live="polite" className="sr-only">
{liveText}
</span>
{handles && (
<>
{handles.showConnector && handles.b && (
<svg
aria-hidden
className="pointer-events-none absolute inset-0"
width={dims.w}
height={dims.h}
>
<line
x1={handles.a.x}
y1={handles.a.y}
x2={handles.b.x}
y2={handles.b.y}
stroke="white"
strokeWidth={1.5}
strokeDasharray="4 4"
strokeOpacity={0.85}
style={{
filter: "drop-shadow(0 0 1px rgba(0,0,0,0.55))",
}}
/>
</svg>
)}
{gradient.type === "radial" && handles.b && (
<svg
aria-hidden
className="pointer-events-none absolute inset-0"
width={dims.w}
height={dims.h}
>
{(() => {
const ax = handles.a.x;
const ay = handles.a.y;
const rx = Math.abs(handles.b.x - ax);
const ry = Math.abs(handles.b.y - ay);
// shape="circle" always renders a true circle outline so
// radiusPx-circles (where the edge handle sits on the x
// axis and ry === 0) don't collapse to a flat line. For
// the keyword-circle path, rx === ry already.
const isCircle = gradient.shape === "circle";
const stroke = {
fill: "none",
stroke: "white",
strokeWidth: 1.5,
strokeDasharray: "4 4",
strokeOpacity: 0.85,
style: { filter: "drop-shadow(0 0 1px rgba(0,0,0,0.55))" },
} as const;
return isCircle ? (
<circle cx={ax} cy={ay} r={Math.max(rx, ry)} {...stroke} />
) : (
<ellipse cx={ax} cy={ay} rx={rx} ry={ry} {...stroke} />
);
})()}
</svg>
)}
{gradient.type === "linear" ? (
<>
{/* Free-positioned endpoints move in 2D (x/y), so they must
not claim role="slider" — its 1D value model would keep
announcing the angle while arrows change position. They
become labelled 2D pads like the center handle; angle-only
mode keeps the true slider semantics. */}
<Handle
label={
gradient.start
? `Gradient start, x ${Math.round(gradient.start.x * 100)}%, y ${Math.round(gradient.start.y * 100)}%`
: "Gradient start"
}
position={handles.a}
onPointerDown={(event) => beginDrag("linear-a", event)}
onKeyDown={(e) => onKeyDownLinearEndpoint("linear-a", e)}
{...(gradient.start
? {
role: "application" as const,
"aria-roledescription": "2D pad for gradient endpoint",
}
: {
role: "slider" as const,
"aria-valuemin": 0,
"aria-valuemax": 360,
"aria-valuenow": Math.round(gradient.angle),
"aria-valuetext": `${Math.round(gradient.angle)} degrees`,
})}
// Endpoint handles are always anchored to the first / last
// stop, so they render in that stop's color.
style={
ctx.stops[0] ? stopSwatchStyle(ctx.stops[0].color) : undefined
}
/>
{handles.b && (
<Handle
label={
gradient.end
? `Gradient end, x ${Math.round(gradient.end.x * 100)}%, y ${Math.round(gradient.end.y * 100)}%`
: "Gradient end"
}
position={handles.b}
onPointerDown={(event) => beginDrag("linear-b", event)}
onKeyDown={(e) => onKeyDownLinearEndpoint("linear-b", e)}
{...(gradient.end
? {
role: "application" as const,
"aria-roledescription": "2D pad for gradient endpoint",
}
: {
role: "slider" as const,
"aria-valuemin": 0,
"aria-valuemax": 360,
"aria-valuenow": Math.round(gradient.angle),
"aria-valuetext": `${Math.round(gradient.angle)} degrees`,
})}
style={(() => {
const last = ctx.stops[ctx.stops.length - 1];
return last ? stopSwatchStyle(last.color) : undefined;
})()}
/>
)}
{/* Middle stops are intentionally NOT rendered on the canvas:
the Bar is the editing surface for inner stops. The overlay
surfaces only the gradient *geometry* — the two endpoints
(tinted with the first / last stop colors) and the line. */}
</>
) : (
<>
<Handle
label={`Gradient center, x ${Math.round(gradient.center.x * 100)}%, y ${Math.round(gradient.center.y * 100)}%`}
position={handles.a}
onPointerDown={(event) => beginDrag("center", event)}
onKeyDown={onKeyDownCenter}
role="application"
aria-roledescription="2D pad for gradient center"
/>
{handles.b && gradient.type === "conic" && (
<Handle
label="Gradient start angle"
position={handles.b}
onPointerDown={(event) => beginDrag("conic-dial", event)}
onKeyDown={onKeyDownConicDial}
role="slider"
aria-valuemin={0}
aria-valuemax={360}
aria-valuenow={Math.round(gradient.startAngle)}
aria-valuetext={`${Math.round(gradient.startAngle)} degrees`}
/>
)}
{handles.b && radiusAria && (
<Handle
label="Gradient radius"
position={handles.b}
onPointerDown={(event) => beginDrag("radial-edge", event)}
onKeyDown={onKeyDownRadii}
role="slider"
aria-valuemin={0}
aria-valuemax={radiusAria.max}
aria-valuenow={radiusAria.now}
aria-valuetext={radiusAria.text}
/>
)}
</>
)}
</>
)}
</div>
);
});
interface HandleProps
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "aria-label"> {
label: string;
position: XY;
}
function Handle({ label, position, className, style, ...rest }: HandleProps) {
return (
<button
type="button"
aria-label={label}
tabIndex={0}
style={{
left: position.x,
top: position.y,
width: HANDLE_PX,
height: HANDLE_PX,
...style,
}}
className={cn(
"absolute -translate-x-1/2 -translate-y-1/2 cursor-grab touch-none rounded-full",
// WCAG 2.5.8: 14px visual dot, ≥24px pointer target.
"before:absolute before:-inset-1.5 before:content-['']",
// Solid background so the dashed gradient line behind the dot is
// fully hidden by it (a translucent fill would let the line bleed
// through visibly). `pointer-events-auto` so the buttons keep
// receiving clicks even when their parent overlay is set to
// `pointer-events-none` (the default for `<GradientPicker.Overlay>`).
"pointer-events-auto border-2 border-white bg-background shadow-[0_0_0_1px_rgba(0,0,0,0.55),0_2px_6px_rgba(0,0,0,0.35)]",
"outline-none motion-safe:transition-transform",
"motion-safe:hover:scale-110 active:cursor-grabbing motion-safe:active:scale-95",
"focus-visible:ring-2 focus-visible:ring-ring",
className,
)}
{...rest}
/>
);
}
registry/new-york/ui/fill-picker/parts/gradient/area.tsx
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { useGradientPickerContext } from "../../contexts/gradient";
import { formatGradient } from "../../lib/gradient";
import { CHECKERBOARD_LG } from "../../lib/constants";
import { Overlay, type OverlayProps } from "./overlay";
export interface AreaProps extends React.HTMLAttributes<HTMLDivElement> {
/** Fixed height in px. Defaults to 120. Width always fills the container. */
height?: number;
/** Forwarded to the inner `<GradientPicker.Overlay>` for conic dial sizing. */
conicDialRadius?: OverlayProps["conicDialRadius"];
}
/**
* Self-contained 2D pad: paints the live gradient (over a transparency
* checkerboard) and overlays the handle layer on top. Essentially
* `<GradientPicker.Overlay>` plus a painted background — use this when you
* want the picker to own its own preview, and use `<GradientPicker.Overlay>`
* directly when you want to drop the handles onto a consumer-owned object
* on a canvas.
*/
export const Area = React.forwardRef<HTMLDivElement, AreaProps>(function Area(
{ className, height = 120, conicDialRadius, style, ...rest },
ref,
) {
const ctx = useGradientPickerContext();
const cssBackground = React.useMemo(
() => formatGradient(ctx.gradient),
[ctx.gradient],
);
return (
<div
ref={ref}
data-slot="gradient-area"
style={{ height, ...style }}
className={cn(
"relative w-full overflow-hidden rounded-md border border-border bg-muted",
className,
)}
{...rest}
>
{/* Transparency checker behind the gradient so alpha is legible. */}
<div
aria-hidden
className="absolute inset-0"
style={{ backgroundImage: CHECKERBOARD_LG }}
/>
<div
aria-hidden
className="absolute inset-0"
style={{ background: cssBackground }}
/>
<Overlay conicDialRadius={conicDialRadius} />
</div>
);
});
registry/new-york/ui/fill-picker/parts/gradient/pointer-drag.ts
"use client";
/**
* Capture-based drag loop shared by the overlay handles and the angle /
* position pads. Besides the usual pointerup/pointercancel cleanup it
* defends against the two ways a drag gets "stuck" chasing the cursor:
*
* - a move arriving with no buttons pressed (the release happened where
* we couldn't see it — e.g. capture silently dropped, button released
* during an OS gesture) ends the drag instead of dragging on;
* - `lostpointercapture` (window blur / cmd-tab mid-drag revokes capture
* without any pointerup) tears the listeners down immediately.
*/
export function trackPointerDrag(
target: HTMLElement,
pointerId: number,
onMove: (ev: PointerEvent) => void,
): void {
try {
target.setPointerCapture(pointerId);
} catch {
// inactive pointer id — the drag still works while the pointer stays
// over the target; the buttons check below handles missed releases.
}
const move = (ev: PointerEvent) => {
// Only the dragging pointer steers or ends the gesture — a second
// touch point wandering over the captured element must not.
if (ev.pointerId !== pointerId) return;
if (ev.buttons === 0) {
end(ev);
return;
}
onMove(ev);
};
const end = (ev?: Event) => {
if (ev instanceof PointerEvent && ev.pointerId !== pointerId) return;
if (ev instanceof PointerEvent) {
try {
target.releasePointerCapture(ev.pointerId);
} catch {
// pointer may already be released on cancel
}
}
target.removeEventListener("pointermove", move);
target.removeEventListener("pointerup", end);
target.removeEventListener("pointercancel", end);
target.removeEventListener("lostpointercapture", end);
};
target.addEventListener("pointermove", move);
target.addEventListener("pointerup", end);
target.addEventListener("pointercancel", end);
target.addEventListener("lostpointercapture", end);
}
registry/new-york/ui/fill-picker/parts/gradient/stop-list.tsx
"use client";
import * as React from "react";
import { Plus, Minus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import {
useGradientPickerContext,
useGradientStopEditor,
} from "../../contexts/gradient";
import {
projectStopPosition,
reverseProjectStopPosition,
} from "../../lib/gradient";
import {
focusNeighborOption,
insertStopAfterSelected,
isEventFromRowControl,
stopListKeyNav,
} from "./stop-list-shared";
import { formatColor, parseColor } from "../../lib/color";
import { CHECKERBOARD_SM } from "../../lib/constants";
import {
FieldDraftInput,
FieldInput,
FieldInputGroup,
FieldShell,
FieldSuffix,
} from "../field";
export interface StopListProps extends React.HTMLAttributes<HTMLDivElement> {
/**
* Render a trailing "+ Add stop" row that inserts a new stop in the
* largest gap between adjacent stops, sampling the gradient's existing
* ramp at that position so the new color blends in. Defaults to `true`.
* Set `false` to hide it when `<GradientPicker.Bar>` (the canonical
* add-stop UI) is visible and the trailing row would be redundant.
*/
showAddStop?: boolean;
}
export const StopList = React.forwardRef<HTMLDivElement, StopListProps>(
function StopList(
{ className, showAddStop = true, ...rest },
ref,
) {
const ctx = useGradientPickerContext();
// Each row mounts its own copy of the injected stop editor (bound to that
// row's stop) so opening any popover edits the right stop directly — no
// need for a list-wide ColorPicker context.
// Mirror the Bar's projection: when the gradient is a positioned linear,
// show + edit the *visible* position so this list matches what the user
// sees in the Area and Bar. Authored positions still live in 0..1 of the
// [start, end] segment; we convert at the edges.
const linear = ctx.gradient.type === "linear" ? ctx.gradient : null;
const start = linear?.start;
const end = linear?.end;
const toDisplay = (authored: number) =>
projectStopPosition(authored, start, end);
const fromDisplay = (displayed: number) =>
reverseProjectStopPosition(displayed, start, end);
const handleAddStop = () => {
const { position, color } = insertStopAfterSelected(
ctx.stops,
ctx.selectedStopId,
);
const id = ctx.addStop(position, color);
ctx.selectStop(id);
};
return (
<div
ref={ref}
data-slot="gradient-stop-list"
role="listbox"
aria-label="Gradient stops"
onKeyDown={stopListKeyNav}
className={cn("flex flex-col gap-1", className)}
{...rest}
>
{ctx.stops.map((s) => {
const selected = s.id === ctx.selectedStopId;
return (
<StopRow
key={s.id}
stop={s}
selected={selected}
toDisplay={toDisplay}
fromDisplay={fromDisplay}
formatted={formatColor(s.color, ctx.getStopColorFormat(s.id))}
/>
);
})}
{showAddStop && (
<Button
type="button"
variant="outline"
onClick={handleAddStop}
aria-label="Add stop"
className="h-8 cursor-pointer font-mono text-xs tracking-wide shadow-xs"
>
<Plus aria-hidden className="size-3.5" />
Add stop
</Button>
)}
</div>
);
});
function StopRow({
stop: s,
selected,
toDisplay,
fromDisplay,
formatted,
}: {
stop: ReturnType<typeof useGradientPickerContext>["stops"][number];
selected: boolean;
toDisplay: (n: number) => number;
fromDisplay: (n: number) => number;
formatted: string;
}) {
const ctx = useGradientPickerContext();
// Same slot the Bar uses: the editor's UI dialect ships per variant and is
// injected by the barrel, so this list stays importable from either tree
// without dragging one dialect's primitives in.
const stopEditor = useGradientStopEditor();
const [open, setOpen] = React.useState(false);
const [draft, setDraft] = React.useState(formatted);
const focusedRef = React.useRef(false);
// Escape reverts by blurring, and that blur would otherwise commit the
// still-typed DOM value — the reverted draft hasn't rendered yet.
const cancelBlurCommitRef = React.useRef(false);
React.useEffect(() => {
if (!focusedRef.current) setDraft(formatted);
}, [formatted]);
const commitDraft = (raw: string) => {
const parsed = parseColor(raw.trim());
if (parsed) ctx.setStopColor(s.id, parsed);
else setDraft(formatted);
};
const swatch = (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
ctx.selectStop(s.id);
setOpen((o) => !o);
}}
aria-label="Edit stop color"
style={{
backgroundImage: `linear-gradient(${formatColor(s.color, "oklch")}, ${formatColor(s.color, "oklch")}), ${CHECKERBOARD_SM}`,
backgroundSize: "auto, 6px 6px",
}}
className="size-7 shrink-0 rounded-xs border border-border outline-none transition-shadow hover:ring-2 hover:ring-ring focus-visible:ring-2 focus-visible:ring-ring"
/>
);
return (
<div
role="option"
aria-selected={selected}
tabIndex={selected ? 0 : -1}
data-selected={selected}
onClick={() => ctx.selectStop(s.id)}
onKeyDown={(e) => {
// Only when the option row itself is focused — keys typed into the
// nested inputs (e.g. Backspace while editing the color text) must
// never trigger row shortcuts.
if (isEventFromRowControl(e)) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
ctx.selectStop(s.id);
} else if (e.key === "Delete" || e.key === "Backspace") {
e.preventDefault();
// Focus the neighbor before removal so focus stays in the list.
focusNeighborOption(e.currentTarget);
ctx.removeStop(s.id);
}
}}
className={cn(
"flex cursor-pointer items-center gap-2 rounded-md border p-1 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring",
selected ? "border-foreground" : "border-border",
)}
>
{stopEditor
? stopEditor({
stopId: s.id,
open,
onOpenChange: setOpen,
children: swatch,
})
: swatch}
<FieldShell className="h-7 w-fit">
<FieldInputGroup>
<span className="sr-only">Stop position</span>
<FieldDraftInput
inputMode="decimal"
nudge={1}
value={String(Math.round(toDisplay(s.position) * 100))}
onCommit={(raw) => {
const v = parseFloat(raw);
// Clamp the typed *displayed* percent to the track; moveStop
// itself no longer clamps (extrapolated stops are legal).
if (Number.isFinite(v))
ctx.moveStop(
s.id,
fromDisplay(Math.max(0, Math.min(1, v / 100))),
);
}}
aria-label="Stop position"
className="w-10"
/>
<FieldSuffix>%</FieldSuffix>
</FieldInputGroup>
</FieldShell>
<FieldShell className="h-7 min-w-0 flex-1">
<FieldInputGroup>
<span className="sr-only">Stop color (paste hex / css)</span>
<FieldInput
value={draft}
spellCheck={false}
onFocus={() => {
focusedRef.current = true;
}}
onClick={(e) => e.stopPropagation()}
onChange={(e) => setDraft(e.target.value)}
onBlur={(e) => {
focusedRef.current = false;
if (cancelBlurCommitRef.current) {
cancelBlurCommitRef.current = false;
return;
}
commitDraft(e.target.value);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
commitDraft((e.target as HTMLInputElement).value);
(e.target as HTMLInputElement).blur();
} else if (e.key === "Escape") {
e.preventDefault();
setDraft(formatted);
cancelBlurCommitRef.current = true;
(e.target as HTMLInputElement).blur();
}
}}
aria-label="Stop color value"
className="text-left"
/>
</FieldInputGroup>
</FieldShell>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
const row = e.currentTarget.closest<HTMLElement>('[role="option"]');
if (row) focusNeighborOption(row);
ctx.removeStop(s.id);
}}
disabled={ctx.stops.length <= 1}
className="inline-flex size-7 items-center justify-center rounded-md border border-input text-muted-foreground shadow-xs hover:text-foreground disabled:opacity-30"
aria-label="Remove stop"
>
<Minus className="size-3.5" />
</button>
</div>
);
}
registry/new-york/ui/fill-picker/parts/gradient/stop-list-shared.ts
"use client";
/**
* Behavior shared by the two StopList variants (classic and Base UI) so
* keyboard/list logic can't drift between them: listbox arrow navigation,
* focus retention on delete, and the insert-after-selected placement math.
*/
import * as React from "react";
import { sampleStopsAt } from "../../lib/gradient";
import type { OklchColor } from "../../lib/types";
/**
* True when the key event originated inside a form control nested in the
* option row (position/color inputs, popover trigger, remove button) rather
* than on the option itself. List/row shortcuts must ignore those — e.g.
* Backspace while editing the color text must edit text, not delete the
* stop; ArrowUp/Down must nudge the numeric field, not move list focus.
*/
export function isEventFromRowControl(
e: React.KeyboardEvent<HTMLElement>,
): boolean {
return e.target !== e.currentTarget;
}
/**
* Listbox keyboard navigation for the stop list container: ArrowUp/Down
* move to the neighboring option, Home/End jump to the edges. Selection
* follows focus — activation goes through `.click()` so the row's own
* onClick (selectStop) runs.
*/
export function stopListKeyNav(e: React.KeyboardEvent<HTMLElement>): void {
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(e.key)) return;
// Only navigate when the key originated on an option row itself. Keys
// from nested controls (position/color inputs — where ArrowUp/Down mean
// "nudge the value") must never be stolen by list navigation.
if (!isOption(e.target)) return;
const options = Array.from(
e.currentTarget.querySelectorAll<HTMLElement>('[role="option"]'),
);
if (options.length === 0) return;
const current = options.indexOf(document.activeElement as HTMLElement);
let next: number;
if (e.key === "Home") next = 0;
else if (e.key === "End") next = options.length - 1;
else {
const delta = e.key === "ArrowDown" ? 1 : -1;
next = Math.max(0, Math.min(Math.max(current, 0) + delta, options.length - 1));
}
e.preventDefault();
options[next].focus();
options[next].click();
}
function isOption(target: EventTarget | null): boolean {
return (
target instanceof HTMLElement && target.getAttribute("role") === "option"
);
}
/**
* Move focus to the row's nearest option sibling. Call *before* removing
* the stop — the neighbor's DOM node survives the re-render, so focus
* stays in the list instead of being ejected to <body>.
*/
export function focusNeighborOption(row: HTMLElement): void {
const neighbor =
findOptionSibling(row, "nextElementSibling") ??
findOptionSibling(row, "previousElementSibling");
neighbor?.focus();
}
/**
* Same intent as `focusNeighborOption`, but scans a container by selector
* instead of walking siblings — the `<GradientPicker.Bar>` handles are
* `role="slider"` elements that may each sit inside a popover anchor wrapper,
* so they are not reliably DOM siblings of one another.
*
* Call *before* removing the focused element. Returns true when focus moved.
*/
export function focusNeighborIn(
container: HTMLElement | null,
current: HTMLElement,
selector: string,
): boolean {
if (!container) return false;
const items = Array.from(container.querySelectorAll<HTMLElement>(selector));
const idx = items.indexOf(current);
if (idx === -1) return false;
const neighbor = items[idx + 1] ?? items[idx - 1];
if (!neighbor) return false;
neighbor.focus();
return true;
}
function findOptionSibling(
el: HTMLElement,
dir: "nextElementSibling" | "previousElementSibling",
): HTMLElement | null {
let node = el[dir];
while (node) {
if (node instanceof HTMLElement && node.getAttribute("role") === "option") {
return node;
}
node = node[dir];
}
return null;
}
/**
* Placement for the "+ Add stop" row: insert immediately after the selected
* stop — halfway to its next neighbor, or halfway back to the previous one
* when the selected stop is last (so a quick add lands somewhere visible
* instead of squeezing into a near-zero end gap). Samples the existing ramp
* so the inserted color blends in.
*/
export function insertStopAfterSelected<
T extends { id: string; position: number; color: OklchColor },
>(stops: readonly T[], selectedStopId: string | null): {
position: number;
color: OklchColor;
} {
const sorted = [...stops].sort((a, b) => a.position - b.position);
// A stop-less gradient is degenerate but type-legal (`Gradient.stops` has no
// non-empty constraint), so a controlled consumer can hand us one. There is
// no anchor to place relative to — drop the first stop at 0 and let
// sampleStopsAt supply its neutral fallback color.
if (sorted.length === 0) return { position: 0, color: sampleStopsAt(sorted, 0) };
const selectedIdx = sorted.findIndex((x) => x.id === selectedStopId);
const anchorIdx = selectedIdx === -1 ? sorted.length - 1 : selectedIdx;
const anchor = sorted[anchorIdx];
const next = sorted[anchorIdx + 1];
const prev = sorted[anchorIdx - 1];
const position = next
? (anchor.position + next.position) / 2
: prev
? (prev.position + anchor.position) / 2
: Math.min(1, (anchor.position + 1) / 2);
return { position, color: sampleStopsAt(sorted, position) };
}
registry/new-york/ui/fill-picker/parts/gradient/stop-color.tsx
"use client";
import * as React from "react";
import { ColorPickerContext } from "../../context";
import { useGradientPickerContext } from "../../contexts/gradient";
import { useColorPicker } from "../../hooks/use-color-picker";
import type { OklchColor } from "../../lib/types";
/**
* Mounts ColorPickerContext bound to the selected stop's color. Children are
* normal ColorPicker.* parts (Area, Hue, ChannelInput, ...) — they Just Work
* because they only depend on ColorPickerContext.
*
* The inner Bound component is keyed on the selected stop id, so a fresh
* useColorPicker instance (and a fresh `lastGoodHueRef`) is created per
* stop. This isolates hue preservation per stop: editing stop A then
* switching to stop B and back to A doesn't smear hue history across stops.
*/
export const StopColor: React.FC<React.PropsWithChildren> = ({ children }) => {
const grad = useGradientPickerContext();
const stop = grad.selectedStop;
if (!stop) return null;
return (
<Bound key={stop.id} stopId={stop.id} initialColor={stop.color}>
{children}
</Bound>
);
};
const Bound: React.FC<
React.PropsWithChildren<{ stopId: string; initialColor: OklchColor }>
> = ({ stopId, initialColor, children }) => {
const grad = useGradientPickerContext();
// Re-read the live color on every render so external moves (e.g. preset
// applied) flow into the picker. Memoize on the channel values (not on
// grad.stops identity) so the controlled value handed to useColorPicker is
// ref-stable across renders that don't actually change this stop's color.
// Without this, every gradient update — even ones that don't touch this
// stop — would hand the inner ColorPicker a fresh OklchColor object, which
// turns Area's `[color]` effect into a per-render trigger and can spiral
// into Maximum-update-depth under React 19's stricter detection.
const found = grad.stops.find((s) => s.id === stopId);
const l = found?.color.l ?? initialColor.l;
const c = found?.color.c ?? initialColor.c;
const h = found?.color.h ?? initialColor.h;
const alpha = found?.color.alpha ?? initialColor.alpha;
const liveColor = React.useMemo<OklchColor>(
() => ({ l, c, h, alpha }),
[l, c, h, alpha],
);
const setStopColor = grad.setStopColor;
const onValueChange = React.useCallback(
(color: OklchColor) => setStopColor(stopId, color),
[setStopColor, stopId],
);
const format = grad.getStopColorFormat(stopId);
const onFormatChange = React.useCallback(
(f: typeof format) => grad.setStopColorFormat(stopId, f),
[grad, stopId],
);
const state = useColorPicker({
value: liveColor,
onValueChange,
format,
onFormatChange,
});
return (
<ColorPickerContext.Provider value={state}>
{children}
</ColorPickerContext.Provider>
);
};
registry/new-york/ui/fill-picker/parts/gradient/stop-popover.tsx
"use client";
import * as React from "react";
import { Popover } from "@base-ui/react/popover";
import { cn } from "@/lib/utils";
export interface StopPopoverProps {
/** Controlled open — the caller owns it; this is never a self-triggering popover. */
open: boolean;
onOpenChange: (open: boolean) => void;
/**
* The element to position against, rendered in place. It is NOT a trigger:
* these anchors (Bar drag handles, StopList swatches) own their own pointer
* handling, so open is driven externally and we anchor via Base UI's
* Positioner `anchor` prop rather than a Radix-style `PopoverAnchor`.
*/
anchor: React.ReactElement;
side?: "top" | "bottom" | "left" | "right";
align?: "start" | "center" | "end";
sideOffset?: number;
className?: string;
children: React.ReactNode;
onContentClick?: React.MouseEventHandler<HTMLDivElement>;
}
/**
* Small self-contained popover built directly on `@base-ui/react/popover`.
*
* Deliberately does NOT depend on the consumer's `@/components/ui/popover`:
* shadcn's Base UI popover omits an anchor/asChild surface, so the
* anchor-without-trigger pattern this editor needs isn't expressible through
* it. Owning this thin shell keeps the stop editor identical across the Radix
* and Base UI variants.
*/
export function StopPopover({
open,
onOpenChange,
anchor,
side = "bottom",
align = "center",
sideOffset = 8,
className,
children,
onContentClick,
}: StopPopoverProps) {
const [anchorEl, setAnchorEl] = React.useState<HTMLElement | null>(null);
const child = React.Children.only(anchor);
return (
<>
{/* Render the anchor in place; capture its DOM node to position against.
These anchors carry no ref of their own, so a plain override is safe. */}
{React.cloneElement(child as React.ReactElement<{ ref?: React.Ref<HTMLElement> }>, {
ref: setAnchorEl,
})}
<Popover.Root open={open} onOpenChange={(next) => onOpenChange(next)}>
<Popover.Portal>
<Popover.Positioner
anchor={anchorEl}
side={side}
align={align}
sideOffset={sideOffset}
className="z-50"
>
<Popover.Popup
onClick={onContentClick}
className={cn(
"origin-[var(--transform-origin)] rounded-md border border-border bg-popover p-3 text-popover-foreground shadow-md outline-none",
className,
)}
>
{children}
</Popover.Popup>
</Popover.Positioner>
</Popover.Portal>
</Popover.Root>
</>
);
}
registry/new-york/ui/fill-picker/parts/gradient/stop-editor-shared.ts
"use client";
import * as React from "react";
import { useGradientPickerContext } from "../../contexts/gradient";
import {
useColorPicker,
type ColorPickerState,
} from "../../hooks/use-color-picker";
/**
* Tooltip/popover-agnostic half of the per-stop color editor: a
* `useColorPicker` bound to one gradient stop's color and per-stop format.
*
* Shared by both variants' stop editors (`stop-editor-popover.tsx` here and
* `fill-picker-base/parts/gradient/stop-editor.tsx`) so the memoization
* subtleties below can't drift between them — mirrors the
* `*-shared` split used by GamutBadge / ContrastReadout.
*/
export function useStopColorPickerState(stopId: string): ColorPickerState {
const grad = useGradientPickerContext();
const stop = grad.stops.find((s) => s.id === stopId) ?? grad.stops[0];
const format = grad.getStopColorFormat(stopId);
// Memoize on the channel values, not on `stop` identity — same reasoning as
// `<GradientPicker.StopColor>`'s Bound. The gradient hook allocates fresh
// stop objects on every controlled sync (even a structural match), so
// passing `stop?.color` straight through would hand the inner picker a new
// OklchColor identity on each parent render, turning Area's `[color]`
// effect into a per-render trigger.
const stopL = stop?.color.l;
const stopC = stop?.color.c;
const stopH = stop?.color.h;
const stopAlpha = stop?.color.alpha;
const liveColor = React.useMemo(
() =>
stopL === undefined ||
stopC === undefined ||
stopH === undefined ||
stopAlpha === undefined
? undefined
: { l: stopL, c: stopC, h: stopH, alpha: stopAlpha },
[stopL, stopC, stopH, stopAlpha],
);
return useColorPicker({
value: liveColor,
onValueChange: (c) => {
if (stop) grad.setStopColor(stop.id, c);
},
format,
onFormatChange: (f) => grad.setStopColorFormat(stopId, f),
});
}
registry/new-york/ui/fill-picker/parts/gradient/reverse-stops.tsx
"use client";
import * as React from "react";
import { ArrowLeftRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useGradientPickerContext } from "../../contexts/gradient";
/**
* Icon button that mirrors every stop's position around 0.5, flipping the
* gradient's visual order while preserving stop ids and colors. Disabled
* when there are fewer than 2 stops (nothing meaningful to reverse).
*
* Styled with the shared shadcn `<Button variant="outline" size="icon-sm">`
* so it matches `<ColorPicker.EyeDropper>` and the rest of the new-york
* icon-button family.
*
* Reads from `<GradientPicker.Root>` context — throws if rendered outside.
*/
export const ReverseStops = React.forwardRef<
HTMLButtonElement,
React.ButtonHTMLAttributes<HTMLButtonElement>
>(function ReverseStops({ className, ...rest }, ref) {
const ctx = useGradientPickerContext();
const disabled = ctx.stops.length < 2;
return (
<Button
ref={ref}
type="button"
variant="outline"
size="icon-sm"
data-slot="gradient-reverse-stops"
onClick={ctx.reverseStops}
disabled={disabled}
aria-label="Reverse stop order"
className={cn("cursor-pointer", className)}
{...rest}
>
<ArrowLeftRight aria-hidden="true" className="size-4" />
</Button>
);
});
registry/new-york/ui/fill-picker/parts/gradient/repeating-toggle.tsx
"use client";
import * as React from "react";
import { Repeat } from "lucide-react";
import { Toggle } from "@/components/ui/toggle";
import { cn } from "@/lib/utils";
import { useGradientPickerContext } from "../../contexts/gradient";
/**
* Icon toggle that flips the active gradient between its standard and
* `repeating-*` CSS form. The stop ramp tiles instead of stretching to
* fill the box — useful for stripes, barberpoles.
*
* Built on shadcn `<Toggle variant="outline" size="sm">` so the pressed
* state is wired by Base UI (`data-pressed`) and the visual treatment
* matches every other toggle the consumer might already have. We tighten
* the lit-border affordance over the base variant.
*
* Hidden when `gradient.type === "conic"` — conic already sweeps a full
* 360°, so `repeating-conic-gradient` only matters for niche pie-slice
* patterns; not worth a top-level toggle.
*
* Reads from `<GradientPicker.Root>` context — throws if rendered outside.
*/
export const RepeatingToggle = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Toggle>
>(function RepeatingToggle({ className, ...rest }, ref) {
const ctx = useGradientPickerContext();
if (ctx.gradient.type === "conic") return null;
const on = !!ctx.gradient.repeating;
return (
<Toggle
ref={ref}
variant="outline"
size="sm"
data-slot="gradient-repeating-toggle"
pressed={on}
onPressedChange={ctx.setRepeating}
aria-label={on ? "Disable repeating gradient" : "Enable repeating gradient"}
className={cn(
"cursor-pointer text-muted-foreground data-[pressed]:border-foreground/70 data-[pressed]:bg-foreground/10 data-[pressed]:text-foreground",
className,
)}
{...rest}
>
<Repeat aria-hidden="true" className="size-4" />
</Toggle>
);
});
registry/new-york/ui/fill-picker/parts/gradient/presets.tsx
"use client";
import * as React from "react";
import { Plus } from "lucide-react";
import { cn } from "@/lib/utils";
import { useGradientPickerContext } from "../../contexts/gradient";
import {
formatGradient,
parseGradient,
type Gradient,
} from "../../lib/gradient";
import { SAMPLE_EDGE } from "../../lib/constants";
export const BUILTIN_GRADIENT_PRESETS: string[] = [
"linear-gradient(in oklch 90deg, oklch(0.9 0.15 60) 0%, oklch(0.6 0.2 30) 100%)",
"linear-gradient(in oklch 135deg, oklch(0.8 0.18 200) 0%, oklch(0.7 0.2 320) 100%)",
"linear-gradient(in oklch 0deg, oklch(0.95 0.04 90) 0%, oklch(0.5 0.18 30) 100%)",
"radial-gradient(circle farthest-corner at 50% 50% in oklch, oklch(0.9 0.2 280) 0%, oklch(0.3 0.12 260) 100%)",
"conic-gradient(from 0deg at 50% 50% in oklch, oklch(0.7 0.18 0) 0%, oklch(0.7 0.18 90) 25%, oklch(0.7 0.18 180) 50%, oklch(0.7 0.18 270) 75%, oklch(0.7 0.18 360) 100%)",
];
export interface PresetsProps extends React.HTMLAttributes<HTMLDivElement> {
/** Override or extend the built-in preset list. */
presets?: string[];
/**
* When provided, renders a "+" tile after the presets that calls this with
* the current gradient and its serialized CSS. The consumer owns state
* (lift `presets` and update it here, persist to localStorage / a server /
* a store / whatever).
*/
onAdd?: (gradient: Gradient, css: string) => void;
}
export const Presets = React.forwardRef<HTMLDivElement, PresetsProps>(
function Presets(
{ presets = BUILTIN_GRADIENT_PRESETS, onAdd, className, ...rest },
ref,
) {
const ctx = useGradientPickerContext();
const parsed = React.useMemo(
() =>
presets
.map((css) => ({ css, gradient: parseGradient(css) }))
.filter((p): p is { css: string; gradient: Gradient } => p.gradient !== null),
[presets],
);
return (
<div
ref={ref}
data-slot="gradient-presets"
className={cn("flex flex-wrap gap-1.5", className)}
{...rest}
>
{parsed.map(({ css, gradient }) => (
<button
key={css}
type="button"
onClick={() => ctx.setGradient(gradient)}
className={cn(
"relative size-8 cursor-pointer rounded-md outline-none transition-shadow",
SAMPLE_EDGE,
"focus-visible:ring-2 focus-visible:ring-ring hover:scale-110",
)}
style={{ background: formatGradient(gradient) }}
aria-label="Apply gradient preset"
/>
))}
{onAdd && (
<button
type="button"
aria-label="Save current gradient as preset"
onClick={() => onAdd(ctx.gradient, formatGradient(ctx.gradient))}
className={cn(
"inline-flex size-8 cursor-pointer items-center justify-center rounded-md border border-dashed border-border text-muted-foreground outline-none transition-colors",
"hover:border-foreground hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring",
)}
>
<Plus className="size-4" />
</button>
)}
</div>
);
},
);
registry/new-york/ui/fill-picker/parts/gradient/css-input.tsx
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { useGradientPickerContext } from "../../contexts/gradient";
import { formatGradient, parseGradient } from "../../lib/gradient";
import { FieldInput, FieldShell } from "../field";
export const CssInput = React.forwardRef<
HTMLInputElement,
React.InputHTMLAttributes<HTMLInputElement>
>(function CssInput({ className, ...rest }, ref) {
const ctx = useGradientPickerContext();
const inputRef = React.useRef<HTMLInputElement | null>(null);
React.useImperativeHandle(ref, () => inputRef.current as HTMLInputElement);
const [draft, setDraft] = React.useState(() => formatGradient(ctx.gradient));
const [invalid, setInvalid] = React.useState(false);
// "The user has typed since the last sync/commit." Distinct from "the draft
// differs from the current gradient": an external update while the field is
// merely *focused* also makes those differ, and treating that as an edit is
// what let a stale draft silently overwrite the newer value on blur.
const [dirty, setDirty] = React.useState(false);
// Adjust state during render rather than via useEffect — same pattern as the
// color `CssInput`, `HexField`, and `ChannelField`. Re-sync the draft unless
// there is an actual in-progress edit to clobber.
const [prevGradient, setPrevGradient] = React.useState(ctx.gradient);
if (ctx.gradient !== prevGradient) {
setPrevGradient(ctx.gradient);
const inputEl = inputRef.current;
const focused =
typeof document !== "undefined" &&
inputEl !== null &&
document.activeElement === inputEl;
if (!focused || !dirty) {
setDraft(formatGradient(ctx.gradient));
setInvalid(false);
setDirty(false);
}
}
const commit = () => {
// Nothing typed — the draft is just a mirror of the current gradient, so
// committing it would at best be a no-op and at worst re-assert a value
// that an external update has since replaced.
if (!dirty) {
setInvalid(false);
return;
}
const parsed = parseGradient(draft);
if (parsed) {
ctx.setGradient(parsed);
setInvalid(false);
setDirty(false);
} else {
setInvalid(true);
}
};
return (
<FieldShell
data-slot="gradient-css-input"
className={cn(
"w-full",
invalid && "border-destructive focus-within:ring-destructive",
className,
)}
>
<FieldInput
ref={inputRef}
value={draft}
onChange={(e) => {
setDraft(e.target.value);
setDirty(true);
}}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") commit();
}}
aria-invalid={invalid}
aria-label="Gradient CSS"
className="flex-1 px-2 text-left text-[10px]"
{...rest}
/>
</FieldShell>
);
});
registry/new-york/ui/fill-picker/parts/gradient/position-pad.tsx
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { useGradientPickerContext } from "../../contexts/gradient";
import { trackPointerDrag } from "./pointer-drag";
import { useLiveAnnounce } from "../use-live-announce";
export interface PositionPadProps extends React.HTMLAttributes<HTMLDivElement> {
size?: number;
}
export const PositionPad = React.forwardRef<HTMLDivElement, PositionPadProps>(
function PositionPad({ className, size = 32, ...rest }, ref) {
const ctx = useGradientPickerContext();
const padRef = React.useRef<HTMLDivElement | null>(null);
React.useImperativeHandle(ref, () => padRef.current as HTMLDivElement);
// role="application" is silent under keyboard — announce moves politely.
const [liveText, announce] = useLiveAnnounce();
if (ctx.gradient.type === "linear") return null;
const center = ctx.gradient.center;
const fromEvent = (clientX: number, clientY: number) => {
const el = padRef.current;
if (!el) return center;
const r = el.getBoundingClientRect();
if (r.width === 0 || r.height === 0) return { x: 0, y: 0 };
return {
x: Math.max(0, Math.min(1, (clientX - r.left) / r.width)),
y: Math.max(0, Math.min(1, (clientY - r.top) / r.height)),
};
};
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
ctx.setCenter(fromEvent(e.clientX, e.clientY));
trackPointerDrag(e.currentTarget, e.pointerId, (ev) =>
ctx.setCenter(fromEvent(ev.clientX, ev.clientY)),
);
};
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
const step = e.shiftKey ? 0.05 : 0.01;
const clamp = (n: number) => Math.max(0, Math.min(1, n));
let next: { x: number; y: number };
if (e.key === "ArrowLeft") {
next = { x: clamp(center.x - step), y: center.y };
} else if (e.key === "ArrowRight") {
next = { x: clamp(center.x + step), y: center.y };
} else if (e.key === "ArrowUp") {
next = { x: center.x, y: clamp(center.y - step) };
} else if (e.key === "ArrowDown") {
next = { x: center.x, y: clamp(center.y + step) };
} else {
return;
}
e.preventDefault();
ctx.setCenter(next);
announce(
`x ${Math.round(next.x * 100)}%, y ${Math.round(next.y * 100)}%`,
);
};
return (
<div
ref={padRef}
data-slot="gradient-position-pad"
onPointerDown={onPointerDown}
onKeyDown={onKeyDown}
role="application"
aria-label={`Gradient position, x ${Math.round(center.x * 100)}%, y ${Math.round(center.y * 100)}%`}
aria-roledescription="2D pad for gradient center"
tabIndex={0}
style={{ width: size, height: size }}
className={cn(
"relative shrink-0 cursor-crosshair touch-none rounded-md border border-border bg-muted outline-none focus-visible:ring-2 focus-visible:ring-ring",
className,
)}
{...rest}
>
<span aria-live="polite" className="sr-only">
{liveText}
</span>
<div
aria-hidden
className="absolute size-2 -translate-x-1/2 -translate-y-1/2 rounded-full border border-background bg-foreground"
style={{ left: `${center.x * 100}%`, top: `${center.y * 100}%` }}
/>
</div>
);
},
);
registry/new-york/ui/fill-picker/parts/gradient/position-input.tsx
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { useGradientPickerContext } from "../../contexts/gradient";
import {
FieldDivider,
FieldDraftInput,
FieldInputGroup,
FieldShell,
FieldSuffix,
} from "../field";
export const PositionInput = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(function PositionInput({ className, ...rest }, ref) {
const ctx = useGradientPickerContext();
if (ctx.gradient.type === "linear") return null;
const center = ctx.gradient.center;
const commit = (axis: "x" | "y", raw: string) => {
if (raw === "") return;
const n = parseFloat(raw);
if (!Number.isFinite(n)) return;
const clamp = (v: number) => Math.max(0, Math.min(1, v / 100));
ctx.setCenter(
axis === "x"
? { x: clamp(n), y: center.y }
: { x: center.x, y: clamp(n) },
);
};
return (
<FieldShell
ref={ref}
data-slot="gradient-position-input"
className={cn("min-w-0 flex-1", className)}
{...rest}
>
<FieldInputGroup>
<span className="sr-only">Gradient center x</span>
<FieldDraftInput
inputMode="decimal"
nudge={1}
value={String(Math.round(center.x * 100))}
onCommit={(raw) => commit("x", raw)}
aria-label="Gradient center x percent"
className="w-10"
/>
<FieldSuffix>%</FieldSuffix>
</FieldInputGroup>
<FieldDivider />
<FieldInputGroup>
<span className="sr-only">Gradient center y</span>
<FieldDraftInput
inputMode="decimal"
nudge={1}
value={String(Math.round(center.y * 100))}
onCommit={(raw) => commit("y", raw)}
aria-label="Gradient center y percent"
className="w-10"
/>
<FieldSuffix>%</FieldSuffix>
</FieldInputGroup>
</FieldShell>
);
});
registry/new-york/ui/fill-picker/parts/gradient/position-group.tsx
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { useGradientPickerContext } from "../../contexts/gradient";
export const PositionGroup = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(function PositionGroup({ className, children, ...rest }, ref) {
const ctx = useGradientPickerContext();
if (ctx.gradient.type === "linear") return null;
return (
<div
ref={ref}
data-slot="gradient-position-group"
className={cn("flex w-full items-center gap-2", className)}
{...rest}
>
{children}
</div>
);
});
registry/new-york/ui/fill-picker/parts/gradient/angle-pad.tsx
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { useGradientPickerContext } from "../../contexts/gradient";
import { trackPointerDrag } from "./pointer-drag";
export interface AnglePadProps extends React.HTMLAttributes<HTMLDivElement> {
size?: number;
}
export const AnglePad = React.forwardRef<HTMLDivElement, AnglePadProps>(
function AnglePad({ className, size = 32, ...rest }, ref) {
const ctx = useGradientPickerContext();
const padRef = React.useRef<HTMLDivElement | null>(null);
React.useImperativeHandle(ref, () => padRef.current as HTMLDivElement);
if (ctx.gradient.type === "radial") return null;
const angle =
ctx.gradient.type === "linear"
? ctx.gradient.angle
: ctx.gradient.startAngle;
const setAngle =
ctx.gradient.type === "linear" ? ctx.setAngle : ctx.setStartAngle;
const fromEvent = (clientX: number, clientY: number): number => {
const el = padRef.current;
if (!el) return angle;
const r = el.getBoundingClientRect();
const cx = r.left + r.width / 2;
const cy = r.top + r.height / 2;
const dx = clientX - cx;
const dy = clientY - cy;
const deg = (Math.atan2(dx, -dy) * 180) / Math.PI;
return (deg + 360) % 360;
};
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
setAngle(fromEvent(e.clientX, e.clientY));
trackPointerDrag(e.currentTarget, e.pointerId, (ev) =>
setAngle(fromEvent(ev.clientX, ev.clientY)),
);
};
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
const step = e.shiftKey ? 15 : 1;
let next = angle;
if (e.key === "ArrowLeft" || e.key === "ArrowDown") next = angle - step;
else if (e.key === "ArrowRight" || e.key === "ArrowUp") next = angle + step;
else if (e.key === "Home") next = 0;
else if (e.key === "End") next = 360;
else return;
e.preventDefault();
setAngle(((next % 360) + 360) % 360);
};
return (
<div
ref={padRef}
onPointerDown={onPointerDown}
onKeyDown={onKeyDown}
role="slider"
aria-label="Gradient angle"
aria-valuemin={0}
aria-valuemax={360}
aria-valuenow={Math.round(angle)}
aria-valuetext={`${Math.round(angle)} degrees`}
tabIndex={0}
data-slot="gradient-angle-pad"
className={cn(
"relative shrink-0 cursor-grab touch-none rounded-full border border-border bg-muted outline-none focus-visible:ring-2 focus-visible:ring-ring",
className,
)}
style={{ width: size, height: size }}
{...rest}
>
<div
aria-hidden
className="absolute left-1/2 top-1/2 size-0 origin-center"
style={{ transform: `translate(-50%, -50%) rotate(${angle}deg)` }}
>
<div
className="absolute left-1/2 size-1.5 -translate-x-1/2 rounded-full bg-foreground shadow-sm ring-1 ring-background/40"
style={{ top: `calc(-${size / 2}px + 4px)` }}
/>
</div>
</div>
);
},
);
registry/new-york/ui/fill-picker/parts/gradient/angle-input.tsx
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { useGradientPickerContext } from "../../contexts/gradient";
import {
FieldDraftInput,
FieldInputGroup,
FieldShell,
FieldSuffix,
} from "../field";
export const AngleInput = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(function AngleInput({ className, ...rest }, ref) {
const ctx = useGradientPickerContext();
if (ctx.gradient.type === "radial") return null;
const angle =
ctx.gradient.type === "linear"
? ctx.gradient.angle
: ctx.gradient.startAngle;
const setAngle =
ctx.gradient.type === "linear" ? ctx.setAngle : ctx.setStartAngle;
const commit = (raw: string) => {
const n = parseFloat(raw);
if (Number.isFinite(n)) setAngle(((n % 360) + 360) % 360);
};
return (
<FieldShell
ref={ref}
data-slot="gradient-angle-input"
className={cn("min-w-0 flex-1", className)}
{...rest}
>
<FieldInputGroup>
<span className="sr-only">Gradient angle</span>
<FieldDraftInput
inputMode="decimal"
nudge={1}
value={String(Math.round(angle))}
onCommit={commit}
aria-label="Gradient angle in degrees"
className="w-10"
/>
<FieldSuffix>°</FieldSuffix>
</FieldInputGroup>
</FieldShell>
);
});
registry/new-york/ui/fill-picker/parts/gradient/angle-group.tsx
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { useGradientPickerContext } from "../../contexts/gradient";
export const AngleGroup = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(function AngleGroup({ className, children, ...rest }, ref) {
const ctx = useGradientPickerContext();
if (ctx.gradient.type === "radial") return null;
return (
<div
ref={ref}
data-slot="gradient-angle-group"
className={cn("flex w-full items-center gap-2", className)}
{...rest}
>
{children}
</div>
);
});
registry/new-york/ui/fill-picker/parts/gradient/shape-switcher.tsx
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { useGradientPickerContext } from "../../contexts/gradient";
const OPTIONS = [
{ value: "circle", label: "Circle" },
{ value: "ellipse", label: "Ellipse" },
] as const;
export const ShapeSwitcher = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(function ShapeSwitcher({ className, ...rest }, ref) {
const ctx = useGradientPickerContext();
if (ctx.gradient.type !== "radial") return null;
const current = ctx.gradient.shape;
// role="group" + aria-pressed buttons rather than role="tablist" / "tab":
// the WAI-ARIA tab pattern requires arrow-key navigation and tabpanels,
// neither of which apply here. This is a segmented toggle, and shadcn's
// own Toggle uses aria-pressed for the same reason. Each button is
// independently Tab-reachable; activation is via Space/Enter (native
// <button> behavior).
return (
<div
ref={ref}
role="group"
aria-label="Radial gradient shape"
data-slot="gradient-shape-switcher"
className={cn(
"inline-flex w-full rounded-md bg-muted p-0.75 gap-0.5",
className,
)}
{...rest}
>
{OPTIONS.map(({ value, label }) => {
const active = current === value;
return (
<button
key={value}
type="button"
aria-pressed={active}
onClick={() => ctx.setRadialShape(value)}
className={cn(
"flex-1 rounded-sm px-3 py-1 text-xs font-medium outline-none transition-colors",
"focus-visible:ring-2 focus-visible:ring-ring",
active
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{label}
</button>
);
})}
</div>
);
});
registry/new-york/ui/fill-picker/parts/gradient/radius-input.tsx
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { useGradientPickerContext } from "../../contexts/gradient";
import {
FieldDraftInput,
FieldInputGroup,
FieldShell,
FieldSuffix,
} from "../field";
export const RadiusInput = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(function RadiusInput({ className, ...rest }, ref) {
const ctx = useGradientPickerContext();
if (ctx.gradient.type !== "radial") return null;
if (ctx.gradient.shape !== "circle") return null;
const g = ctx.gradient;
const usePercent = !!ctx.containerWidth;
const display = (() => {
if (g.radiusPx === undefined) return "";
return String(
usePercent
? Math.round((g.radiusPx / (ctx.containerWidth as number)) * 100)
: Math.round(g.radiusPx),
);
})();
const commit = (raw: string) => {
const v = raw.trim();
if (v === "") {
ctx.setRadiusPx(undefined);
return;
}
const n = parseFloat(v);
if (!Number.isFinite(n)) return;
if (!usePercent) {
ctx.setRadiusPx(Math.max(0, n));
return;
}
ctx.setRadiusPx(Math.max(0, (n / 100) * (ctx.containerWidth as number)));
};
return (
<FieldShell
ref={ref}
data-slot="gradient-radius-input"
className={cn("min-w-0 shrink-0", className)}
{...rest}
>
<FieldInputGroup>
<span className="sr-only">Circle radius</span>
<FieldDraftInput
inputMode="decimal"
nudge={1}
value={display}
placeholder="auto"
onCommit={commit}
aria-label={
usePercent
? "Circle radius as percent of Area width"
: "Circle radius in pixels"
}
className="w-12"
/>
<FieldSuffix>{usePercent ? "%" : "px"}</FieldSuffix>
</FieldInputGroup>
</FieldShell>
);
});
registry/new-york/ui/fill-picker/parts/gradient/ellipse-radii-input.tsx
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { useGradientPickerContext } from "../../contexts/gradient";
import {
FieldDivider,
FieldDraftInput,
FieldInputGroup,
FieldShell,
FieldSuffix,
} from "../field";
import { keywordToRadii } from "./overlay";
export const EllipseRadiiInput = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(function EllipseRadiiInput({ className, ...rest }, ref) {
const ctx = useGradientPickerContext();
if (ctx.gradient.type !== "radial") return null;
if (ctx.gradient.shape !== "ellipse") return null;
const g = ctx.gradient;
const commit = (axis: "x" | "y", raw: string) => {
if (raw.trim() === "") {
ctx.setRadii(undefined);
return;
}
const n = parseFloat(raw);
if (!Number.isFinite(n)) return;
// Keep the untouched axis at its *effective* extent. For an ellipse the
// keyword seed is already normalized per axis, so the box size cancels
// out (1 × 1) — the same seed the Overlay's edge handle starts from.
const current =
g.radii ?? keywordToRadii("ellipse", g.size, g.center, 1, 1);
ctx.setRadii(
axis === "x"
? { x: Math.max(0, n / 100), y: current.y }
: { x: current.x, y: Math.max(0, n / 100) },
);
};
return (
<FieldShell
ref={ref}
data-slot="gradient-ellipse-radii-input"
className={cn("min-w-0 shrink-0", className)}
{...rest}
>
<FieldInputGroup>
<span className="sr-only">Ellipse horizontal radius</span>
<FieldDraftInput
inputMode="decimal"
nudge={1}
value={g.radii ? String(Math.round(g.radii.x * 100)) : ""}
placeholder="auto"
onCommit={(raw) => commit("x", raw)}
aria-label="Ellipse horizontal radius percent"
className="w-12"
/>
</FieldInputGroup>
<FieldDivider />
<FieldInputGroup>
<span className="sr-only">Ellipse vertical radius</span>
<FieldDraftInput
inputMode="decimal"
nudge={1}
value={g.radii ? String(Math.round(g.radii.y * 100)) : ""}
placeholder="auto"
onCommit={(raw) => commit("y", raw)}
aria-label="Ellipse vertical radius percent"
className="w-12"
/>
<FieldSuffix>%</FieldSuffix>
</FieldInputGroup>
</FieldShell>
);
});