VisorVisor
ComponentsForm

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-picker

This copies two files into your project:

  • components/ui/position-picker/position-picker.tsx — the component
  • components/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

PropTypeDefaultDescription
valuePositionValue | null—Controlled value, a { y: 'top' | 'center' | 'bottom', x: 'left' | 'center' | 'right' } pair. null is controlled with nothing chosen.
defaultValuePositionValue | 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.
disabledbooleanfalseDisables 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.
...propsReact.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 is role="radio" with aria-checked. Give the group an aria-label or aria-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; Home and End go 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)