facebook / facebook/astryx

Slider2D: a two-axis value control with a paintable background

Open
#6,300 1 comment 0 reactions 0 assignees View on GitHub
component design research
Dominant language
TypeScript
Stars
13k
Forks
1.1k
Avg merge
1d 15h
Merged PRs (30d)
690

Description

## Summary

Astryx has no two-axis control. `Slider` covers one number, or a range of two, along a single axis; there is nothing for the case where two numbers are read together as a **position** — a saturation/brightness plane, an image focal point, an effort/impact matrix.

This issue is the specification record for adding one: the research behind it, the naming arbitration that settled its API, and a first implementation in `@astryxdesign/lab` for review.

Following [Component Lifecycle](https://github.com/facebook/astryx/wiki/Component-Lifecycle) → [Component Specification Protocol](https://github.com/facebook/astryx/wiki/Component-Specification-Protocol) → [API Arbitration](https://github.com/facebook/astryx/wiki/API-Arbitration).

## Triage: is this actually a new component?

| Question | Answer |
|---|---|
| Can existing components compose to solve this? | **No.** See the evidence below — the Canvas Editor template hand-rolls ~120 lines of pointer capture, keyboard nudging and ARIA to get a saturation/value plane. |
| Is the need domain-specific to one product? | **No.** Colour picking alone appears in 15+ Nest apps, the sandbox colour studio, and a shipped template. |
| Is it a visual variation of something that exists? | **No.** A second axis is new behaviour, not new styling. |
| Does it add behaviour to an existing component? | **No** for the plane. A related but separable question about painting `Slider`'s track is split out below. |

## The evidence

**The strongest signal is inside this repo.** The Canvas Editor template (#6237) builds a full HSV picker by hand, and its own source says why:

> The plane and the rail are painted rather than composed: a two-dimensional gradient and a rainbow track are not controls the system ships, and a Slider styled into a hue rail would still only give one of the two axes.
>
> — `packages/cli/assets/templates/pages/canvas-editor/page.tsx`

A *template* — the thing we ship as the reference for building with Astryx — could not build this out of Astryx.

| Where | What it had to build |
|---|---|
| Canvas Editor template (#6237) | Saturation/value plane with `role="slider"`, pointer capture, arrow-key nudging; hue rail as a `Slider` under a scoped theme; hex field; checkerboard swatch |
| `apps/sandbox/.../color-studio/` | 1,200 lines of colour math driven entirely by `Slider` + `NumberInput` + hex `TextInput`. No plane at all — colour is decomposed into numeric fields to work around the gap |
| 15+ Nest apps | Each hand-rolls its own `ColorPicker.tsx`, each with a different API and a different accessibility story |
| ADO-AMT → Astryx migration | Filed "hue panels" as an upstream gap alongside the Sheet/Drawer primitive |

Longstanding asks internally, unanswered since 2019 ("Slider & Color Picker?", for annotator tooling), and a 2023 vertical-slider request that became a community contribution and is now core.

**No open issue existed for a colour picker, a 2D slider, an eyedropper or a swatch** before this one.

## External research

| System | 2D area | Channel slider | Full picker |
|---|---|---|---|
| React Aria / Spectrum | `ColorArea` (`xChannel`/`yChannel`, `colorSpace`, `xName`/`yName`, `onChange` + `onChangeEnd`) | `ColorSlider`, `ColorWheel` | `ColorPicker`, `ColorField`, `ColorSwatch` |
| Ark UI / Zag | `ColorPicker.Area` | `ColorPicker.ChannelSlider`, `AngleSlider` | `ColorPicker` |
| Mantine | `Saturation` | `HueSlider`, `AlphaSlider` | `ColorPicker`, `ColorInput` |
| Ant Design | inside the panel | — | `ColorPicker` |
| Radix / shadcn / MUI | ✗ | ✗ | ✗ |

Two conclusions:

1. **Where the industry converged:** a rectangular area with per-axis configuration, `onChange` during drag + `onChangeEnd` on release (which `Slider` already matches), a separate thumb part, and channel sliders as siblings rather than sub-components.
2. **Where nobody went:** a *general-purpose* two-axis control. Every system that solves this ships a colour-named component.

That second point is the design decision this issue takes, and the risk the arbitration below measures.

## The decision: general-purpose, not colour-specific

Ship a control for **two numbers with a paintable background**, so a colour picker becomes a composition rather than a component. This follows the system's own principle — compose, don't rebuild — and the value in the plane is genuinely channel-agnostic: gradients, image focal points, scatter/quadrant targets, position pads.

The risk is discoverability: if people asking for this are asking for a colour picker, will they find a generally-named component?

## API arbitration (run 1, complete)

Four name arms — `Slider2D`, `XYPad`, `ColorArea`, `PointPicker` — machine-generated from a single doc template so that only the name varies (all four hash identically once the name is masked; 398 words each). 29 isolated agents, blank-slate, across three probes. Four prompts: a colour picker, an image focal point, an effort/impact matrix, and a one-axis volume control as a negative control. No prompt names a component, a prop, or the phrase "2D".

Full design, findings and caveats: `internal/vibe-tests/slider2d-naming-test/PLAN.md`.

| Probe | Result |
|---|---|
| **Recall** — behaviour described, name and value shape withheld | **5/5 `Slider2D`**, **5/5 `{x, y}`**. Zero colour-derived names proposed, at any rank |
| **Discovery** — component list only, no props | **8/8 found it** — every arm, on both the colour prompt and a non-colour one |
| **Negative control** — a genuinely one-axis control | **3/4 correctly reached for `Slider`**. `XYPad` did not: it pinned `yMin`/`yMax` to 0 to fake a 1D control |
| **Generation** — 16 cells | 16/16 correct. Escape hatches were near-identical across arms, so the name decided nothing here |

Three findings that matter more than the tally:

- **The general-purpose framing is vindicated.** Told what the control *does*, not one recall sample reached for a colour name — despite `ColorArea` being the strongest prior available. It was a familiar name for this, never a natural one.
- **`ColorArea` is out on doubt, not discovery.** Asked to use it for a roadmap plot, the agent found it and then hesitated: *"its name and likely origin (a color picker) worries me… I'd ask a human whether reusing it is intended anyway, or fall back to building a custom 2D drag-pad by hand."* That fallback is exactly what this component exists to delete.
- **The description is load-bearing, not the name.** The 8/8 came from the one-liner — *"picks two numbers at once by dragging a thumb around a rectangular area"* — which every agent quoted back. Shortening it to "a two-axis slider" in the docs would forfeit that result.

`XYPad` is out on over-reach: a component used for jobs it shouldn't have is worse than one occasionally missed. The likely mechanism is in the name — `Slider2D` says *this is the two-axis Slider*, which makes the one-axis sibling the obvious neighbour; a "pad" is a free-standing object with no sibling to fall back to.

**Decision: `Slider2D`, with `value: {x: number; y: number}`.** The tuple alternative was rejected 5/5 in recall, and collides with `Slider`'s existing `[number, number]`, which already means *a range on one axis* — reusing the shape would give two structurally different meanings one type.

## Proposed API

```tsx
setHsv({...hsv, s: next.x, v: next.y})}
onChangeEnd={save}
background={}
/>
```

| Prop | Type | Notes |
|---|---|---|
| `label` | `string`, required | Names the group. `Slider` makes it required; the family contract holds |
| `value` | `{x: number, y: number}` | `y` increases upward, so `yMax` is the top edge |
| `onChange` / `onChangeEnd` | `(value) => void` | Same pair and same semantics as `Slider` |
| `xMin`/`xMax`/`xStep`, `yMin`/`yMax`/`yStep` | `number` | Flat, per axis. Defaults 0/100/1 |
| `xLabel` / `yLabel` | `string` | Each axis is separately announced, so each needs its own name |
| `formatValue` | `(value, axis) => string` | One function serves both axes |
| `background` | `ReactNode` | Decorative, `aria-hidden`, pointer-events none |
| `aspectRatio` | `number` | The area fills its width, so this sets its height |
| `htmlName` | `{x: string, y: string}` | One control submits two values — a deliberate divergence from `Slider`'s single `htmlName` |
| `isDisabled`, `status`, `description`, `labelTooltip`, `isRequired`, `isOptional`, `isLabelHidden`, `width`, `xstyle` | | The input-family set |

Theming targets: `astryx-slider-2d`, `astryx-slider-2d-thumb`.

### Accessibility

A two-axis control has no ARIA role of its own. Two candidates were considered:

- **One `role="slider"` carrying both axes** — what #6237 does. Simpler, but `aria-valuenow` can only hold one number, so that prototype announces saturation and silently drops brightness.
- **One visually-hidden `input[type=range]` per axis**, inside the thumb — what React Aria's ColorArea does. Real semantics, real form participation, each axis announced.

**Went with the second.** Arrow keys are intercepted rather than left to the native inputs: a range input moves its own value on all four arrows, so a focused x input would travel horizontally on ArrowUp. Left/right route to x and up/down to y whichever input holds focus.

Also in scope and implemented: the half-thumb travel inset (the geometry `input[type=range]` uses, and the fix #5051 applied to `Slider`), RTL on the x axis for both pointer and keyboard, pointer capture so a fast drag leaving the area keeps tracking, `touch-action: none`, and `prefers-reduced-motion`.

## Implementation for review

In `@astryxdesign/lab`, per the lab → core promotion gate:

- `packages/lab/src/Slider2D/` — component, scoped hover marker, typed `.doc.mjs`, 25 tests
- `apps/storybook/stories/Slider2D.stories.tsx` — six stories under `Lab/Slider2D`: Default, ColorPicker, EffortImpactMatrix, FocalPoint, FieldStates, Keyboard

The ColorPicker story composes the plane with a real `Slider` whose track is repainted through a scoped theme — nothing in it is hand-rolled, which is the acceptance test for this component. Getting #6237 to delete its local `ColorPicker` is the other one.

Tests, lint, lab typecheck, storybook typecheck, `check:sync` and `check:use-client` are green. Visual design review has not happened yet — that is the next step, and the reason this is in lab rather than core.

## Open questions

1. **Axis prop names.** Recall split: `minX`/`maxX`/`stepX` (2), `xMin`/`xMax`/`xStep` (1), both (1), a grouped `yAxis` object (1). Shipped as `xMin`/`xMax`/`xStep` on the tie-break that typing `x` groups `xMin`/`xMax`/`xStep`/`xLabel` in autocomplete — decided on a tie-break, not on evidence. Worth a narrow re-probe.
2. **The y-inversion tax.** Every agent that put an image or a CSS gradient behind the area hand-wrote `100 - y`, because the area measures y upward while CSS positions downward. Filed separately.
3. **Painting a `Slider` track.** Today this works via `defineTheme` on the `slider-track` target, but it has to be wrapped in `` so sibling sliders on the same panel don't inherit the gradient — #6237 concedes the awkwardness in its own comment. Whether that should be a per-instance prop is an instance of #1900 (base vs theme styling boundary) and should be argued there rather than around it. Filed separately.
4. **Where colour math lives**, if a `ColorPicker` composition follows. `packages/core/src/utils/color.ts` owns parse/format, the sandbox owns OKLCH/HCT, and #6237 now owns its own `hsvToHex`/`hexToHsv` — three copies with no owner.

## Not proposed here

A `ColorPicker` component. With the plane in place it is a composition — plane + hue `Slider` + hex `TextInput` + a swatch row built from `Item`/`Token` — and the surface-area audit should challenge every part of it before any of it becomes a new export.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.