@xsolla/xui-gradient-picker
v0.214.0
Published
A cross-platform React gradient picker: a live preview track with draggable colour stops, per-stop colour and opacity editing via ColorPicker, and CSS-compatible gradient output.
Readme
Gradient Picker
A cross-platform React gradient picker: a live preview track with draggable colour stops, per-stop colour and opacity editing via ColorPicker, and CSS-compatible gradient output.
A compound component for defining a CSS-style colour gradient. Combines a visual gradient preview track with draggable stop markers and integrates with ColorPicker to edit the colour and opacity of each stop individually. Supports three gradient types and up to five colour stops.
When to use
- When a user needs to define a multi-colour gradient — background fills, banner overlays, avatar tints, UI theme customisation
- In design tool contexts, theme editors, or advanced colour configuration panels where fine-grained gradient control is required
- When the product supports gradient fills as a first-class input (e.g. a game customisation screen, a chart background picker)
When not to use
- When a solid colour is sufficient — use ColorPicker instead
- When only a simple two-colour linear gradient with fixed endpoints is needed and the user doesn't need to adjust stops — consider a simpler two-swatch input
- In contexts where gradient complexity would overwhelm the user — hide behind an "Advanced" toggle
Content guidelines
- Gradient type labels — use the standard CSS-adjacent names: "Linear", "Radial", "Angular" (or "Conic" if the product uses that term). Do not use internal technical names.
- Stop position display — when a stop is active, show its position as a percentage (e.g. 0%, 50%, 100%) in a read-only or editable label near the marker or in the ColorPicker header. This helps users set precise positions numerically.
- Minimum stops warning — if the user tries to remove the last stop, show a tooltip or brief inline message: "A gradient requires at least one stop" rather than silently blocking the action.
Behaviour guidelines
- Adding stops — clicking on an empty area of the stop track adds a new stop at that position, interpolating its initial colour from the gradient at that point. The new stop immediately becomes Active. Adding is only permitted up to the product-defined maximum (5 in Figma).
- Removing stops — a stop can be removed by dragging it off the track (up or down beyond a threshold) or via a delete action (e.g. pressing Delete / Backspace when the stop is active). A minimum of 1 stop must always remain; do not allow removing the last stop.
- Dragging stops — a stop is dragged horizontally along the track. Position is expressed as a percentage (0%–100%). Stops cannot be dragged past each other — they swap order when they cross. Provide a smooth position update with no snap unless the product explicitly requires snapping to percentage increments.
- Active stop ColorPicker — the ColorPicker panel opens below the component when a stop is activated. If the panel would push the component out of the viewport, position it above the track instead. Closing the ColorPicker (clicking outside, pressing Escape) deactivates the stop.
- Gradient type change — switching Gradient type via the Select applies immediately to the preview without resetting stop positions or colours.
- Angle / centre point — for Linear, the gradient angle should be adjustable (e.g. via a degree input or a rotation handle on the preview). For Radial, the centre point should be configurable. For Angular, the rotation angle and start point should be configurable. These controls are not shown in the Figma component directly but are part of the full implementation.
- Real-time preview — every interaction (dragging a stop, changing a colour, switching gradient type, adjusting angle) must update the gradient preview in real time without requiring a confirm action.
- Output format — the component outputs a CSS-compatible gradient string that can be applied directly as a background or background-image value. Provide a copy action to let the user copy the gradient string.
Accessibility
- The stop track must be operable by keyboard. When a stop has focus, ← / → arrows move it by 1% increments; Shift+← / Shift+→ move it by 10% increments.
- Each .GradientStop must have role="slider" with aria-label identifying the stop (e.g. aria-label="Stop 1 position") and aria-valuenow / aria-valuemin="0" / aria-valuemax="100".
- The gradient type selector must have aria-label="Gradient type".
- When a stop is activated and the ColorPicker opens, focus must move into the ColorPicker. When the ColorPicker closes, focus must return to the stop marker.
- The gradient preview is decorative — it must have aria-hidden="true" and not receive focus.
- Adding a stop via keyboard: provide a button (e.g. "+ Add stop") as a keyboard-accessible alternative to clicking on the track.
- Removing a stop via keyboard: when a stop is active, Delete / Backspace removes it and focus moves to the adjacent stop or the track.
- Do not rely on the visual gradient colour alone to communicate stop positions — always show numeric position values for screen reader users and users who cannot perceive colour differences in the gradient.
Gradient types
The angle can only be controlled for linear gradients and angular gradients.
Stop hover
While dragging a gradient stop, the row of the corresponding stop transitions into the active state.
Installation
npm install @xsolla/xui-gradient-pickerDemo
Basic Gradient Picker
import * as React from "react";
import { GradientPicker } from "@xsolla/xui-gradient-picker";
export default function BasicGradientPicker() {
return <GradientPicker onChange={({ css }) => console.log(css)} />;
}Controlled Gradient Picker
import * as React from "react";
import {
GradientPicker,
DEFAULT_GRADIENT,
toCssGradient,
type GradientValue,
} from "@xsolla/xui-gradient-picker";
export default function ControlledGradientPicker() {
const [gradient, setGradient] = React.useState<GradientValue>(
DEFAULT_GRADIENT
);
return (
<>
<GradientPicker
value={gradient}
onChange={({ value }) => setGradient(value)}
/>
<div style={{ backgroundImage: toCssGradient(gradient) }} />
</>
);
}Radial Gradient
import * as React from "react";
import { GradientPicker } from "@xsolla/xui-gradient-picker";
export default function RadialGradient() {
return (
<GradientPicker
defaultValue={{
type: "radial",
angle: 0,
stops: [
{ id: "stop-1", color: "#D9D9D9", position: 0, opacity: 100 },
{ id: "stop-2", color: "#22A8C3", position: 100, opacity: 100 },
],
}}
/>
);
}Radial gradients have no rotation, so the angle field is hidden for them.
Angular ("Conic") Gradient
import * as React from "react";
import { GradientPicker } from "@xsolla/xui-gradient-picker";
export default function AngularGradient() {
return (
<GradientPicker
typeLabels={{ angular: "Conic gradient" }}
onChange={({ css }) => console.log(css)}
/>
);
}Anatomy
import { GradientPicker } from "@xsolla/xui-gradient-picker";
<GradientPicker
value={gradient} // Controlled gradient value
defaultValue={initial} // Initial value for uncontrolled usage
onChange={handleChange} // ({ value, css }) => void
gradientTypes={["linear", "radial", "angular"]} // Types offered in the selector
typeLabels={{ angular: "Conic gradient" }} // Option label overrides
minStops={1} // Floor for removal
maxStops={5} // Ceiling for adding
disabled={false} // Disable every control
stopsLabel="Stops" // Heading above the stop list
/>;API Reference
GradientPicker
GradientPickerProps:
| Prop | Type | Default | Description |
| :------------- | :---------------------------------------------- | :----------------------------------- | :------------------------------------------------------------------------------------------------------------ |
| testID | string | — | Test ID for testing frameworks. On web this renders as data-testid; on React Native it renders as testID. |
| value | GradientValue | — | Controlled gradient value. Pair with onChange. |
| defaultValue | GradientValue | DEFAULT_GRADIENT | Initial gradient value for uncontrolled usage. |
| onChange | (event: GradientPickerChangeEvent) => void | — | Fired on every interaction. Receives the new value and its CSS string. |
| gradientTypes | GradientType[] | ["linear", "radial", "angular"] | Gradient types offered in the selector, in display order. |
| typeLabels | Partial<Record<GradientType, string>> | — | Overrides for the gradient-type option labels. |
| minStops | number | 1 | Smallest number of stops the user may reduce the gradient to. |
| maxStops | number | 5 | Largest number of stops the user may add. |
| disabled | boolean | false | Disables every control and blocks track interaction. |
| typeAriaLabel | string | "Gradient type" | Accessible name for the gradient-type selector. |
| angleAriaLabel | string | "Gradient angle" | Accessible name for the angle field. |
| stopsLabel | string | "Stops" | Heading rendered above the stop list. |
Types
type GradientType = "linear" | "radial" | "angular";
interface GradientStop {
id: string; // Stable identity, survives reordering
color: string; // "#RRGGBB"
position: number; // 0–100 (percent)
opacity: number; // 0–100 (percent)
}
interface GradientValue {
type: GradientType;
angle: number; // Degrees; ignored for "radial"
stops: GradientStop[];
}
interface GradientPickerChangeEvent {
value: GradientValue;
css: string; // e.g. "linear-gradient(0deg, #D9D9D9 0%, #22A8C3 100%)"
}Exported helpers
Every gradient operation is a pure function, so a consuming surface can render or transform a gradient without mounting the component.
| Helper | Signature | Description |
| :------------------------------------ | :--------------------------------------------------------------- | :-------------------------------------------------------------------------------- |
| toCssGradient(value) | (value: GradientValue) => string | Serialises to linear-gradient() / radial-gradient() / conic-gradient(). |
| toPreviewCssGradient(value) | (value: GradientValue) => string | Left-to-right ramp used by the preview track, regardless of gradient type. |
| interpolateColorAt(stops, position) | (stops: GradientStop[], position: number) => { color, opacity } | Samples the gradient — the colour a stop added at position inherits. |
| addStopAt(stops, position, max) | (…) => GradientStop[] | Adds an interpolated stop; returns the input array unchanged at max. |
| removeStop(stops, id, min) | (…) => GradientStop[] | Removes by id; returns the input array unchanged at min. |
| updateStop(stops, id, patch) | (…) => GradientStop[] | Applies a clamped, normalised patch to one stop. |
| sortStops(stops) | (stops: GradientStop[]) => GradientStop[] | Position order, stable on ties, non-mutating. |
| supportsAngle(type) | (type: GradientType) => boolean | false for radial — the angle field is hidden for it. |
| DEFAULT_GRADIENT | GradientValue | The two-stop #D9D9D9 → #22A8C3 linear ramp from the Figma component. |
CSS output
| Type | Output |
| :-------- | :-------------------------------------------------------------- |
| linear | linear-gradient(<angle>deg, <stops>) |
| radial | radial-gradient(circle, <stops>) |
| angular | conic-gradient(from <angle>deg, <stops>) |
A fully opaque stop serialises to its hex value; a partially transparent stop
serialises to rgba(). A gradient with a single stop is emitted twice (0% and
100%) because CSS gradients require at least two colour stops.
Keyboard navigation
| Key | Action |
| :------------------------ | :------------------------------------------------ |
| Tab / Shift+Tab | Move focus between markers, fields and buttons |
| ← / → (marker focused) | Move the stop by 1% |
| Shift+← / Shift+→ | Move the stop by 10% |
| Home / End | Jump the stop to 0% / 100% |
| Enter / Space | Activate the stop and open its ColorPicker |
| Delete / Backspace | Remove the stop (blocked at minStops) |
| Escape | Close the ColorPicker; focus returns to the marker |
Theme
- The panel background comes from the
layer/floatcolour token (theme.colors.layer.float) so it reads as a floating surface in both light and dark mode, matching ColorPicker. - Corner radius comes from
theme.shape.contextMenu.lg.borderRadius. - Stop markers use
theme.colors.background.primaryfor their ring so the stop colour stays legible against any track fill.
Accessibility
- Each stop marker is a
role="slider"witharia-label="Stop N position",aria-valuenow,aria-valuemin="0",aria-valuemax="100"andaria-valuetext="N%". - The preview track is
aria-hidden="true"and is not focusable — position is always available numerically from the per-stop position field and the marker'saria-valuetext. - The + button is the keyboard-accessible alternative to clicking the track; it drops the new stop in the widest gap.
- Closing the ColorPicker (Escape) returns focus to the marker that opened it.
- The angle field and every per-stop field carry an explicit
aria-label.
Known gaps
These are deliberate v1 boundaries, tracked as follow-ups:
- Type selector naming —
SelectPropshas noaria-label, so the accessible name for the gradient-type selector is carried by a wrappingrole="group"element. Once Select acceptsaria-label, move it onto the combobox itself. - ColorPicker placement — the active-stop ColorPicker renders inline below the stop list. The spec's "flip above the track when the panel would leave the viewport" behaviour needs a floating-popover layer.
- Pointer drag is web-only — keyboard operation works everywhere; marker
dragging uses web mouse events. React Native
PanResponderparity is a follow-up, matching the approachSlidertakes. - Radial centre point / angular start point are not exposed. They are not in the Figma component; the behaviour spec lists them as part of a fuller implementation.
