Position Picker
A 3x3 anchor grid. Nine targets, one chosen, the value a { y, x } pair.
Default
A radio group in two dimensions. The grid draws on its own ground. The value is a { y, x } pair: y is top, center or bottom; x is left, center or right.
Controlled
{"y":"center","x":"center"}On an image
variant="on-image" fills a positioned parent, the photograph's frame, with no ground of its own. Every target is a light mark inside a dark scrim ring, so it keeps 3:1 over both light and dark regions of any photograph. Nothing is applied to the photo (no filter). On a thumbnail smaller than three targets a side, the grid grows to the target size rather than shrinking the targets.
Custom glyphs
renderTarget replaces the drawn dot of any target, for example alignment marks in the occupied row. The glyph is decorative: each target keeps its accessible name.
Disabled
Installation
npx visor add position-pickerThis copies two files into your project:
components/ui/position-picker/position-picker.tsx— the componentcomponents/ui/position-picker/position-picker.module.css— the styles
Usage
import { PositionPicker } from '@/components/ui/position-picker/position-picker';
<PositionPicker
aria-label="Focal point"
defaultValue={{ y: 'center', x: 'center' }}
onValueChange={({ y, x }) => save(y, x)}
/>Theming
The resting edge of the standalone grid is an inset outline on the shared --control-edge-* tokens, so --control-edge-width: 0 turns it off. Focus is not affected by that switch. --position-picker-target-size sets the minimum target size (default --spacing-8 standalone, --spacing-6 floor), and --position-picker-scrim sets the on-image ring.
API Reference
PositionPickerProps
| Prop | Type | Default | Description |
|---|---|---|---|
value | PositionValue | null | — | Controlled value, a { y: 'top' | 'center' | 'bottom', x: 'left' | 'center' | 'right' } pair. null is controlled with nothing chosen. |
defaultValue | PositionValue | null | — | Initial value when uncontrolled. |
onValueChange | (value: PositionValue) => void | — | Called with the chosen { y, x } pair. |
variant | 'default' | 'on-image' | 'default' | default draws the grid on its own ground. on-image fills a positioned parent (the photograph's frame) and gives every target its own scrim ring. |
disabled | boolean | false | Disables all nine targets. |
renderTarget | (position: PositionValue, state: { selected: boolean }) => ReactNode | — | Replaces the drawn dot of one target, for example an alignment icon in the occupied row. Decorative; the target keeps its accessible name. |
...props | React.HTMLAttributes<HTMLDivElement> | — | Standard div attributes are forwarded to the radiogroup. Give it an aria-label or aria-labelledby. |
Accessibility
- The grid is
role="radiogroup"and each target isrole="radio"witharia-checked. Give the group anaria-labeloraria-labelledby(4.1.2) - Every target has an accessible name from its position: "Top left", "Top center", "Center", "Bottom right"
- One tab stop: the chosen target, or the first when nothing is chosen. Arrow keys move in both axes and choose as they move, wrapping at the edges;
HomeandEndgo to the first and last target - Every target's hit area is at least 24×24 at every size, even where the drawn dot is smaller (2.5.8)
- The chosen target is larger and ringed as well as filled, so state is not carried by colour alone (1.4.1)
- Over an image, each target keeps 3:1 against the photograph with its own scrim ring (1.4.11)
- Focus draws a visible ring at the focus-ring tokens, with a scrim halo over an image (2.4.7)