Move and resize objects, reveal swipe actions, press and hold, pick points, draw ranges, and preview choices.

createMove moves objects, createResize changes their bounds, createSwipeActions reveals actions behind a row, bindHold responds to a press that's held, and bindRepeat keeps stepping while a button is held. createArea picks two-dimensional values, createSpans edits ranges on tracks, and createPreview lets people try a value before choosing it.

Import these helpers from @ui-sdk/core. When a helper asks for a set callback, connect it to your change. For sorting items into an order, see Drag and Drop.

Moving in Two Dimensions

Place Anything

Keep positions in a Record<string, MovePoint> and start a MoveDraft | null field at null. In api, connect them to createMove:

const change = createChange(context)
const move = createMove(context, {
  key: "positions",
  draft: "moving",
  set: (value, details) => change("positions", value, details),
  step: () => 4,
  bounds: () => ({ minX: 0, minY: 0, maxX: 264, maxY: 144 }),
})

Return move from api. The positioned slot uses the committed coordinates for its base position. Add moving to the primitive's live fields and let a live slot binding write the drag offset:

attrs: (state, { key }) => ({
  style: css({
    position: "absolute",
    left: `${state.positions[key].x}px`,
    top: `${state.positions[key].y}px`,
    "touch-action": "none",
  }),
}),
live: live((state, { api, key }) => ({
  "data-dragging": api.move.dragging(state, key) ? "" : undefined,
  style: api.move.style(state, key),
})),
on: {
  keydown: (event, { api, key }) => api.move.keydown(event, key),
  pointerdown: (event, { el, api, key }) => api.move.pointerdown(event, key, el!),
},

Render that slot as a focusable element with an accessible name, inside a positioned container. The live binding updates translate, --move-x, and --move-y without a framework render for each pointer move.

Console
import * as React from "react"
import {
  createChange,
  createMove,
  createRovingFocus,
  css,
  definePrimitive,
  live,
  spatialMove,
  type ChangeDetails,
  type MoveDraft,
  type MovePoint,
  type MoveReason,
} from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"
import { useLog } from "./log"

const emoji: Record<string, string> = {
  Cactus: "๐ŸŒต",
  Lemon: "๐Ÿ‹",
  Rocket: "๐Ÿš€",
}

type StickersState = {
  positions: Record<string, MovePoint>
  focused: string | null
  moving: MoveDraft | null
}

type StickersOptions = {
  defaultPositions: Record<string, MovePoint>
  onPositionsChange?: (
    positions: Record<string, MovePoint>,
    details: ChangeDetails<MoveReason>
  ) => void
}

const stickers = definePrimitive({
  name: "stickers",
  options: { defaultPositions: "value", onPositionsChange: "callback" },
  state: (options: StickersOptions): StickersState => ({
    positions: options.defaultPositions,
    focused: null,
    moving: null,
  }),
  live: ["moving"],
  api: (context) => {
    const change = createChange(context)
    return {
      roving: createRovingFocus<StickersState, { value: string }>(context, {
        slot: "sticker",
        key: "focused",
        move: spatialMove(),
      }),
      move: createMove<StickersState>(context, {
        key: "positions",
        draft: "moving",
        set: (value, details) => change("positions", value, details),
        step: () => 4,
        bounds: () => ({ minX: 0, minY: 0, maxX: 216, maxY: 120 }),
      }),
    }
  },
  slots: (slot) => ({
    root: slot({
      attrs: () => ({ role: "group", style: "position:relative" }),
    }),
    sticker: slot<{ value: string }>({
      props: { value: "string" },
      attrs: (state, part) => {
        const at = state.positions[part.key] ?? { x: 0, y: 0 }
        return {
          tabindex: part.api.roving.tabIndex(state, part),
          style: css({
            position: "absolute",
            left: `${at.x}px`,
            top: `${at.y}px`,
            "touch-action": "none",
          }),
        }
      },
      live: live<StickersState, any, { value: string }>(
        (state, { api, key }) => ({
          "data-dragging": api.move.dragging(state, key) ? "" : undefined,
          style: api.move.style(state, key),
        })
      ),
      on: {
        keydown: (event, { el, api, key }) =>
          api.move.keydown(event, key) || api.roving.keydown(event, el!),
        pointerdown: (event, { el, api, key }) =>
          api.move.pointerdown(event, key, el!),
      },
      bind: (el, part) => part.api.roving.bind(el, part),
    }),
  }),
})

const parts = createParts(stickers, { root: "div", sticker: "div" })

const Stickers = parts.Root

const StickersItem = parts.Sticker

function Example() {
  const log = useLog()
  const positions = React.useRef<Record<string, MovePoint>>({
    Cactus: { x: 24, y: 28 },
    Lemon: { x: 108, y: 96 },
    Rocket: { x: 192, y: 20 },
  })
  return (
    <Stickers
      aria-label="Stickers"
      defaultPositions={positions.current}
      onPositionsChange={(next, details) => {
        for (const [sticker, at] of Object.entries(next)) {
          const was = positions.current[sticker]
          if (was.x !== at.x || was.y !== at.y) {
            log(`${sticker} moved to ${at.x}, ${at.y} (${details.reason})`)
          }
        }
        positions.current = next
      }}
    >
      {Object.keys(positions.current).map((sticker) => (
        <StickersItem key={sticker} value={sticker} aria-label={sticker}>
          {emoji[sticker]}
        </StickersItem>
      ))}
    </Stickers>
  )
}

export { Example }
Drag a sticker, or focus it and press Alt with an arrow to move it 4 pixels. Add Shift to move ten times as far. Escape during a drag puts it back.

step is the grid a dropped position lands on, and how far Alt with an arrow moves it; Shift moves largeStep, ten steps by default. bounds limits committed coordinates; the drag preview can go outside them before release. A drop reports drag, a keyboard nudge reports move-key, and screen readers hear the new position.

Several at Once

Add group to move a selection together:

group: (key) => {
  const { selected } = context.store.get()
  return selected.includes(key) ? selected : [key]
},
step: () => 20,
disabled: (key) => key === "ship",
label: (key) => titles[key],

step sets the grid and the distance for Alt with an arrow, 1 by default. disabled blocks pointer and keyboard moves and removes that item from a moving group.

Console
import * as React from "react"
import {
  createChange,
  createMove,
  createRovingFocus,
  css,
  definePrimitive,
  live,
  spatialMove,
  type MoveDraft,
  type MovePoint,
} from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"
import { PinIcon } from "lucide-react"
import { useLog } from "./log"

type BoardState = {
  positions: Record<string, MovePoint>
  selected: string[]
  focused: string | null
  moving: MoveDraft | null
}

const titles: Record<string, string> = {
  plan: "Plan",
  draft: "Draft",
  ship: "Ship",
}

const board = definePrimitive({
  name: "notes",
  options: { defaultPositions: "value", onPositionsChange: "callback" },
  state: (options: {
    defaultPositions: Record<string, MovePoint>
    onPositionsChange?: (positions: Record<string, MovePoint>) => void
  }): BoardState => ({
    positions: options.defaultPositions,
    selected: [],
    focused: null,
    moving: null,
  }),
  live: ["moving"],
  api: (context) => {
    const { store } = context
    const change = createChange(context)
    const move = createMove<BoardState>(context, {
      key: "positions",
      draft: "moving",
      set: (value, details) => change("positions", value, details),
      group: (key) =>
        store.get().selected.includes(key) ? store.get().selected : [key],
      step: () => 20,
      bounds: () => ({ minX: 0, minY: 0, maxX: 180, maxY: 140 }),
      disabled: (key) => key === "ship",
      label: (key) => titles[key],
      message: ({ keys, label, at }) =>
        keys.length > 1
          ? `Moved ${keys.length} notes.`
          : `${label} is at ${at.x}, ${at.y}.`,
    })
    return {
      roving: createRovingFocus<BoardState, { value: string }>(context, {
        slot: "note",
        key: "focused",
        move: spatialMove(),
      }),
      move,
      select(key: string, add: boolean) {
        const { selected } = store.get()
        if (add) {
          store.set({
            selected: selected.includes(key)
              ? selected.filter((other) => other !== key)
              : [...selected, key],
          })
        } else if (!selected.includes(key)) {
          store.set({ selected: [key] })
        }
      },
      clear: () => store.set({ selected: [] }),
    }
  },
  slots: (slot) => ({
    root: slot({
      attrs: () => ({
        role: "listbox",
        "aria-multiselectable": "true",
        "aria-roledescription": "board",
        style: "position:relative",
      }),
      on: {
        pointerdown: (event, { el, api }) => event.target === el && api.clear(),
      },
    }),
    note: slot<{ value: string }>({
      props: { value: "string" },
      attrs: (state, part) => {
        const at = state.positions[part.key]
        return {
          role: "option",
          "aria-selected": String(state.selected.includes(part.key)),
          "aria-disabled": part.key === "ship" ? "true" : undefined,
          tabindex: part.api.roving.tabIndex(state, part),
          style: css({
            position: "absolute",
            left: `${at.x}px`,
            top: `${at.y}px`,
            "touch-action": "none",
          }),
        }
      },
      live: live<BoardState, any, { value: string }>((state, { api, key }) => ({
        "data-dragging": api.move.dragging(state, key) ? "" : undefined,
        style: api.move.style(state, key),
      })),
      on: {
        keydown: (event, { el, api, key }) => {
          if (event.key === " ") {
            event.preventDefault()
            return api.select(key, true)
          }
          api.move.keydown(event, key) || api.roving.keydown(event, el!)
        },
        pointerdown: (event, { el, api, key }) => {
          api.select(key, event.shiftKey)
          api.move.pointerdown(event, key, el!)
        },
      },
      bind: (el, part) => part.api.roving.bind(el, part),
    }),
  }),
})

const parts = createParts(board, { root: "div", note: "div" })

const Board = parts.Root

const BoardNote = parts.Note

function Example() {
  const log = useLog()
  const positions = React.useRef<Record<string, MovePoint>>({
    plan: { x: 20, y: 20 },
    draft: { x: 100, y: 60 },
    ship: { x: 180, y: 120 },
  })
  return (
    <Board
      aria-label="Notes"
      defaultPositions={positions.current}
      onPositionsChange={(next) => {
        for (const [key, at] of Object.entries(next)) {
          const was = positions.current[key]
          if (was.x !== at.x || was.y !== at.y) {
            log(`${titles[key]} moved to ${at.x}, ${at.y}`)
          }
        }
        positions.current = next
      }}
    >
      {Object.entries(titles).map(([key, title]) => (
        <BoardNote key={key} value={key}>
          {title}
          {key === "ship" && <PinIcon aria-hidden="true" />}
        </BoardNote>
      ))}
    </Board>
  )
}

export { Example }
Click a note to select it, or Shift-click to add one. Drag a selected note to move the group. Alt with an arrow nudges it 20 pixels; the pinned note stays put.

Select on pointerdown before calling move.pointerdown. Keep the selection when the pressed note is already in it. The demo combines this with spatial focus, so plain arrows move between notes.

label supplies the name for announcements. message can customize the sentence using the moved keys, the first item's label, and its new at point. moveMessage supplies the default wording.

Zoom and Moving from Code

On a zoomed canvas, tell the behavior how many screen pixels represent one unit:

scale: () => context.store.get().zoom,

The pointer distance is divided by that scale. Programmatic moves still use the canvas's own units:

move.moveTo("circle", { x: 100, y: 60 })
move.moveBy(["circle"], 10, 0)
Console
import * as React from "react"
import {
  createChange,
  createMove,
  css,
  definePrimitive,
  live,
  type MoveDraft,
  type MovePoint,
} from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"
import { useLog } from "./log"

type CanvasState = {
  positions: Record<string, MovePoint>
  zoom: number
  moving: MoveDraft | null
}

type CanvasOptions = {
  zoom?: number
  onPositionsChange?: (positions: Record<string, MovePoint>) => void
}

const canvas = definePrimitive({
  name: "canvas",
  options: { zoom: "number", onPositionsChange: "callback" },
  state: (options: CanvasOptions): CanvasState => ({
    positions: { circle: { x: 40, y: 40 }, square: { x: 168, y: 88 } },
    zoom: options.zoom ?? 1,
    moving: null,
  }),
  sync: (options) => ({ zoom: options.zoom ?? 1 }),
  live: ["moving"],
  api: (context) => {
    const change = createChange(context)
    const move = createMove<CanvasState>(context, {
      key: "positions",
      draft: "moving",
      set: (value, details) => change("positions", value, details),
      scale: () => context.store.get().zoom,
      bounds: () => ({ minX: 0, minY: 0, maxX: 224, maxY: 128 }),
    })
    return {
      move,
      act(action: string) {
        if (action === "center") {
          move.moveTo("circle", { x: 112, y: 64 })
        } else if (action === "nudge") {
          move.moveBy(["circle"], 10, 0)
        }
      },
    }
  },
  slots: (slot) => ({
    root: slot({ attrs: () => ({ role: "group" }) }),
    surface: slot({
      attrs: (state) => ({
        style: css({
          position: "relative",
          width: "272px",
          height: "176px",
          transform: `scale(${state.zoom})`,
          "transform-origin": "0 0",
        }),
      }),
    }),
    shape: slot<{ value: string }>({
      props: { value: "string" },
      attrs: (state, { key }) => {
        const at = state.positions[key]
        return {
          tabindex: "0",
          style: css({
            position: "absolute",
            left: `${at.x}px`,
            top: `${at.y}px`,
            "touch-action": "none",
          }),
        }
      },
      live: live<CanvasState, any, { value: string }>(
        (state, { api, key }) => ({
          style: api.move.style(state, key),
        })
      ),
      on: {
        keydown: (event, { api, key }) => api.move.keydown(event, key),
        pointerdown: (event, { el, api, key }) =>
          api.move.pointerdown(event, key, el!),
      },
    }),
    action: slot<{ value: string }>({
      props: { value: "string" },
      attrs: () => ({ type: "button" }),
      press: ({ api, key }) => api.act(key),
    }),
  }),
})

const parts = createParts(canvas, {
  root: "div",
  surface: "div",
  shape: "div",
  action: "button",
})

const Canvas = parts.Root

const CanvasSurface = parts.Surface

const CanvasShape = parts.Shape

const CanvasAction = parts.Action

function Example() {
  const log = useLog()
  const [zoom, setZoom] = React.useState(1)
  return (
    <Canvas
      aria-label="Canvas"
      zoom={zoom}
      onPositionsChange={({ circle, square }) =>
        log(
          `Circle at ${circle.x}, ${circle.y} ยท Square at ${square.x}, ${square.y}`
        )
      }
    >
      <div data-slot="controls">
        {[0.5, 1, 1.5].map((option) => (
          <button
            key={option}
            type="button"
            data-variant="ghost"
            data-size="sm"
            aria-pressed={zoom === option}
            onClick={() => setZoom(option)}
          >
            {option * 100}%
          </button>
        ))}
        <CanvasAction value="center" data-size="sm">
          Center
        </CanvasAction>
        <CanvasAction value="nudge" data-size="sm">
          Nudge
        </CanvasAction>
      </div>
      <div data-slot="move-area">
        <CanvasSurface>
          <CanvasShape value="circle" aria-label="Circle" data-shape="round" />
          <CanvasShape value="square" aria-label="Square" />
        </CanvasSurface>
      </div>
    </Canvas>
  )
}

export { Example }
Change the zoom, then drag a shape. Use the buttons to move the circle without dragging.

Both methods report imperative and apply bounds. They don't apply the drag grid or filter disabled keys, so check those application rules before calling them if needed.

Resizing Boxes

Handles on a Box

A box has { x, y, width, height }. Keep boxes in state, start a ResizeDraft | null field at null, and add createResize in api:

const resize = createResize(context, {
  boxes: (state) => state.boxes,
  draft: "draft",
  set: (value, details) => change("boxes", value, details),
  min: () => ({ width: 60, height: 40 }),
  bounds: () => ({ left: 0, top: 0, right: 320, bottom: 200 }),
  step: () => 4,
})

The box slot reads its committed size with boxStyle. List draft in the primitive's live fields, then apply resize.style through a live binding:

box: slot({
  attrs: (state, { api }) => ({ style: api.resize.boxStyle(state, "frame") }),
  live: live((state, { api }) => ({ style: api.resize.style(state, "frame") })),
  on: {
    pointerdown: (event, { el, api }) => api.resize.pointerdown(event, "frame", "move", el!),
  },
}),

Position the box absolutely and give each handle its own ID. Handles use compass names: n, e, s, w, ne, nw, se, and sw. Use move for the box itself.

Render handles as buttons with names such as "Resize bottom right". Their slots call resize.handleAttrs(handle) for the cursor and touch behavior, resize.pointerdown(event, key, handle, el) for dragging, and resize.keydown(event, key, handle) for arrows.

Console
import * as React from "react"
import {
  createChange,
  createResize,
  definePrimitive,
  live,
  type ChangeDetails,
  type ResizeBox,
  type ResizeDraft,
  type ResizeHandle,
  type ResizeReason,
} from "@ui-sdk/core"
import { usePart, usePrimitive, type PartProps } from "@ui-sdk/react/primitive"
import { useLog } from "./log"

type FrameState = {
  boxes: Record<string, ResizeBox>
  draft: ResizeDraft | null
}

type FrameOptions = {
  defaultBox: ResizeBox
  onBoxesChange?: (
    boxes: Record<string, ResizeBox>,
    details: ChangeDetails<ResizeReason>
  ) => void
}

const frame = definePrimitive({
  name: "frame",
  options: { defaultBox: "value", onBoxesChange: "callback" },
  state: (options: FrameOptions): FrameState => ({
    boxes: { frame: options.defaultBox },
    draft: null,
  }),
  live: ["draft"],
  api: (context) => {
    const change = createChange(context)
    return {
      resize: createResize<FrameState>(context, {
        boxes: (state) => state.boxes,
        draft: "draft",
        set: (value, details) => change("boxes", value, details),
        min: () => ({ width: 60, height: 40 }),
        bounds: () => ({ left: 0, top: 0, right: 272, bottom: 176 }),
        step: () => 4,
      }),
    }
  },
  slots: (slot) => ({
    box: slot({
      attrs: (state, { api }) => ({
        style: `${api.resize.boxStyle(state, "frame")};touch-action:none`,
      }),
      live: live<FrameState, any, {}>((state, { api }) => ({
        style: api.resize.style(state, "frame"),
      })),
      on: {
        pointerdown: (event, { el, api }) =>
          api.resize.pointerdown(event, "frame", "move", el!),
      },
    }),
    handle: slot<{ id: string; handle: ResizeHandle }>({
      props: { handle: "string" },
      attrs: (_state, { props, api }) => api.resize.handleAttrs(props.handle),
      on: {
        pointerdown: (event, { el, api, props }) =>
          api.resize.pointerdown(event, "frame", props.handle, el!),
        keydown: (event, { api, props }) =>
          api.resize.keydown(event, "frame", props.handle),
      },
    }),
  }),
})

function Frame({
  defaultBox,
  onBoxesChange,
  ...props
}: FrameOptions & PartProps<"div">) {
  const handle = usePrimitive(frame, { defaultBox, onBoxesChange })
  return usePart(handle, "box", {}, props, "div")
}

function FrameHandle({
  handle,
  ...props
}: { handle: ResizeHandle } & PartProps<"button">) {
  return usePart(
    frame,
    "handle",
    { id: handle, handle },
    { type: "button", ...props },
    "button"
  )
}

const handles: Partial<Record<ResizeHandle, string>> = {
  nw: "top left",
  n: "top",
  ne: "top right",
  e: "right",
  se: "bottom right",
  s: "bottom",
  sw: "bottom left",
  w: "left",
}

function Example() {
  const log = useLog()
  return (
    <div data-slot="resize-stage">
      <Frame
        aria-label="Frame"
        defaultBox={{ x: 68, y: 40, width: 136, height: 96 }}
        onBoxesChange={({ frame }, details) =>
          log(
            `${frame.width} ร— ${frame.height} at ${frame.x}, ${frame.y} (${details.reason})`
          )
        }
      >
        {(Object.keys(handles) as ResizeHandle[]).map((name) => (
          <FrameHandle
            key={name}
            handle={name}
            aria-label={`Resize ${handles[name]}`}
          />
        ))}
      </Frame>
    </div>
  )
}

export { Example }
Drag a handle to resize the frame, or drag the frame to move it. Focus a handle and use the arrows; add Shift for larger steps. Escape cancels a drag.

handleAttrs doesn't supply a label or make an element focusable. The button does that. Arrow keys move an edge by step, the grid a dropped box lands on, or by largeStep with Shift, ten steps by default; an edge handle responds along its own axis. Changes report drag or resize-key.

Keeping a Shape

Set an aspect ratio and size limits in the resize options:

aspect: () => (context.store.get().locked ? 16 / 9 : undefined),
min: () => ({ width: 96, height: 54 }),
max: () => ({ width: 256, height: 144 }),
largeStep: () => 16,
Console
import * as React from "react"
import {
  createChange,
  createResize,
  definePrimitive,
  live,
  type ResizeBox,
  type ResizeDraft,
  type ResizeHandle,
} from "@ui-sdk/core"
import { usePart, usePrimitive, type PartProps } from "@ui-sdk/react/primitive"
import { Checkbox, CheckboxIndicator } from "@ui-sdk/react/checkbox"
import { CheckIcon, PlayIcon } from "lucide-react"
import { useLog } from "./log"

type VideoState = {
  boxes: Record<string, ResizeBox>
  locked: boolean
  draft: ResizeDraft | null
}

type VideoOptions = {
  locked?: boolean
  onBoxesChange?: (boxes: Record<string, ResizeBox>) => void
}

const video = definePrimitive({
  name: "video",
  options: { locked: "boolean", onBoxesChange: "callback" },
  state: (options: VideoOptions): VideoState => ({
    boxes: { video: { x: 56, y: 43, width: 160, height: 90 } },
    locked: options.locked ?? true,
    draft: null,
  }),
  sync: (options) => ({ locked: options.locked ?? true }),
  live: ["draft"],
  api: (context) => {
    const change = createChange(context)
    return {
      resize: createResize<VideoState>(context, {
        boxes: (state) => state.boxes,
        draft: "draft",
        set: (value, details) => change("boxes", value, details),
        aspect: () => (context.store.get().locked ? 16 / 9 : undefined),
        min: () => ({ width: 96, height: 54 }),
        max: () => ({ width: 256, height: 144 }),
        bounds: () => ({ left: 0, top: 0, right: 272, bottom: 176 }),
        largeStep: () => 16,
      }),
    }
  },
  slots: (slot) => ({
    box: slot({
      attrs: (state, { api }) => ({
        style: `${api.resize.boxStyle(state, "video")};touch-action:none`,
      }),
      live: live<VideoState, any, {}>((state, { api }) => ({
        style: api.resize.style(state, "video"),
      })),
      on: {
        pointerdown: (event, { el, api }) =>
          api.resize.pointerdown(event, "video", "move", el!),
      },
    }),
    handle: slot<{ id: string; handle: ResizeHandle }>({
      props: { handle: "string" },
      attrs: (_state, { props, api }) => api.resize.handleAttrs(props.handle),
      on: {
        pointerdown: (event, { el, api, props }) =>
          api.resize.pointerdown(event, "video", props.handle, el!),
        keydown: (event, { api, props }) =>
          api.resize.keydown(event, "video", props.handle),
      },
    }),
  }),
})

function Video({
  locked,
  onBoxesChange,
  ...props
}: VideoOptions & PartProps<"div">) {
  const handle = usePrimitive(video, { locked, onBoxesChange })
  return usePart(handle, "box", {}, props, "div")
}

function VideoHandle({
  handle,
  ...props
}: { handle: ResizeHandle } & PartProps<"button">) {
  return usePart(
    video,
    "handle",
    { id: handle, handle },
    { type: "button", ...props },
    "button"
  )
}

const places: Partial<Record<ResizeHandle, string>> = {
  nw: "top left",
  ne: "top right",
  e: "right",
  se: "bottom right",
  sw: "bottom left",
  w: "left",
}

function Example() {
  const log = useLog()
  const [locked, setLocked] = React.useState(true)
  return (
    <div data-slot="stack">
      <div data-slot="controls">
        <label data-slot="label">
          <Checkbox
            checked={locked}
            onCheckedChange={(next) => setLocked(next === true)}
          >
            <CheckboxIndicator>
              <CheckIcon aria-hidden="true" />
            </CheckboxIndicator>
          </Checkbox>
          Keep 16:9
        </label>
      </div>
      <div data-slot="resize-stage">
        <Video
          aria-label="Video"
          locked={locked}
          onBoxesChange={({ video }) =>
            log(`${Math.round(video.width)} ร— ${Math.round(video.height)}`)
          }
        >
          <PlayIcon aria-hidden="true" />
          {(Object.keys(places) as ResizeHandle[]).map((name) => (
            <VideoHandle
              key={name}
              handle={name}
              aria-label={`Resize ${places[name]}`}
            />
          ))}
        </Video>
      </div>
    </div>
  )
}

export { Example }
Resize the video frame while 16:9 is checked. Uncheck it to resize freely, or hold Shift while dragging a corner to preserve the frame's starting ratio.

aspect is width divided by height. A corner follows the edge that moved further; an edge handle adjusts the other dimension around its center. min and max bound the size, while bounds describes the surrounding area. Here largeStep makes Shift with an arrow move 16 pixels, while plain arrows keep moving 1.

Several Boxes, and from Code

The same behavior can manage several boxes. Add scale for a zoomed desktop and disabled for boxes the user shouldn't move or resize:

scale: () => 0.75,
disabled: (key) => key === "notes",
label: (key) => titles[key],
import * as React from "react"
import {
  createChange,
  createResize,
  css,
  definePrimitive,
  type ResizeBox,
  type ResizeDraft,
} from "@ui-sdk/core"
import { createParts, usePart, type PartProps } from "@ui-sdk/react/primitive"
import { LockIcon } from "lucide-react"

type DesktopState = {
  windows: Record<string, ResizeBox>
  draft: ResizeDraft | null
}

const titles: Record<string, string> = { mail: "Mail", notes: "Notes" }

const desktop = definePrimitive({
  name: "desktop",
  state: (): DesktopState => ({
    windows: {
      mail: { x: 20, y: 20, width: 160, height: 100 },
      notes: { x: 200, y: 100, width: 140, height: 100 },
    },
    draft: null,
  }),
  api: (context) => {
    const change = createChange(context)
    const resize = createResize<DesktopState>(context, {
      boxes: (state) => state.windows,
      draft: "draft",
      set: (value, details) => change("windows", value, details),
      min: () => ({ width: 100, height: 60 }),
      bounds: () => ({ left: 0, top: 0, right: 360, bottom: 220 }),
      scale: () => 0.75,
      disabled: (key) => key === "notes",
      label: (key) => titles[key],
      message: ({ label, handle, box }) =>
        handle === "move"
          ? `${label} moved.`
          : `${label} is ${box.width} by ${box.height}.`,
    })
    return {
      resize,
      act(action: string) {
        if (action === "maximize") {
          resize.resizeTo("mail", { x: 0, y: 0, width: 360, height: 220 })
        } else if (action === "grow") {
          resize.resizeBy("mail", "se", 40, 40)
        }
      },
    }
  },
  slots: (slot) => ({
    root: slot<{}, { mail: ResizeBox | null }>({
      state: (state, { api }) => ({ mail: api.resize.box(state, "mail") }),
    }),
    screen: slot({
      attrs: () => ({
        style: css({
          position: "relative",
          width: "360px",
          height: "220px",
          transform: "scale(0.75)",
          "transform-origin": "0 0",
        }),
      }),
    }),
    window: slot<{ value: string }>({
      props: { value: "string" },
      attrs: (state, { api, key }) => ({
        role: "group",
        "aria-label": titles[key],
        "aria-disabled": key === "notes" ? "true" : undefined,
        "data-resizing": api.resize.dragging(state, key),
        style: `${api.resize.boxStyle(state, key)};${api.resize.style(state, key) ?? ""};touch-action:none`,
      }),
      on: {
        pointerdown: (event, { el, api, key }) =>
          api.resize.pointerdown(event, key, "move", el!),
      },
    }),
    corner: slot<{ id: string; box: string }>({
      props: { box: "string" },
      attrs: (_state, { api }) => api.resize.handleAttrs("se"),
      on: {
        pointerdown: (event, { el, api, props }) =>
          api.resize.pointerdown(event, props.box, "se", el!),
      },
    }),
    action: slot<{ value: string }>({
      props: { value: "string" },
      attrs: () => ({ type: "button" }),
      press: ({ api, key }) => api.act(key),
    }),
  }),
})

const parts = createParts(desktop, {
  root: "div",
  screen: "div",
  window: "div",
  action: "button",
})

const Desktop = parts.Root

const DesktopScreen = parts.Screen

const DesktopWindow = parts.Window

function DesktopCorner({ box, ...props }: { box: string } & PartProps<"div">) {
  return usePart(desktop, "corner", { id: `${box}-se`, box }, props, "div")
}

const DesktopAction = parts.Action

function Example() {
  return (
    <Desktop>
      {({ mail }) => (
        <>
          <div data-slot="controls">
            <DesktopAction data-size="sm" value="maximize">
              Maximize
            </DesktopAction>
            <DesktopAction data-size="sm" value="grow">
              Grow
            </DesktopAction>
          </div>
          <div data-slot="resize-stage">
            <DesktopScreen>
              {Object.entries(titles).map(([key, title]) => (
                <DesktopWindow key={key} value={key}>
                  <div data-slot="resize-titlebar">
                    {title}
                    {key === "mail" ? (
                      <span data-slot="hint">
                        {Math.round(mail!.width)} ร— {Math.round(mail!.height)}
                      </span>
                    ) : (
                      <LockIcon aria-hidden="true" />
                    )}
                  </div>
                  <DesktopCorner box={key} />
                </DesktopWindow>
              ))}
            </DesktopScreen>
          </div>
        </>
      )}
    </Desktop>
  )
}

export { Example }
Move or resize Mail on the scaled desktop. Notes is locked. Maximize sets Mail's box, and Grow enlarges it from the bottom-right corner.

There are two ways to change a box from code:

resize.resizeTo("mail", { x: 0, y: 0, width: 400, height: 240 })
resize.resizeBy("mail", "se", 40, 40)

resizeBy applies the handle's size limits and disabled rule. resizeTo writes the supplied box directly, so validate its dimensions and permissions yourself. Both report imperative.

To render a live size label, leave the draft out of live and read resize.box(state, key). It returns the draft box during the drag and the committed box otherwise. dragging(state, key) returns the active handle. label and message customize announcements, with resizeMessage as the default.

Swiping Rows Aside

Actions Behind a Row

createSwipeActions keeps one row open at a time. Start open as SwipeOpen | null and swiping as SwipeDraft | null, both null:

const swipe = createSwipeActions(context, {
  key: "open",
  draft: "swiping",
})

Each row has moving content above actions on its start or end side. Bind the row with bindRow(el, key) and each action group with bindActions(el, rowKey, side). Return those cleanups from the slots.

Put swiping in live. The content slot combines a transform with live attributes:

attrs: () => ({ style: css({ transform: "translateX(var(--swipe-x, 0px))" }) }),
live: live((state, { api, key }) => api.swipe.contentAttrs(state, key)),

Call swipe.pointerdown(event, key, el) and swipe.keydown(event, key) from the row. Give it position: relative, overflow: hidden, and touch-action: pan-y. The actions slot uses swipe.actionsAttrs(state, rowKey, side) to make hidden actions inert.

import * as React from "react"
import {
  createRovingFocus,
  createSwipeActions,
  css,
  definePrimitive,
  live,
  selects,
  type SwipeDraft,
  type SwipeOpen,
  type SwipeSide,
} from "@ui-sdk/core"
import { createParts, usePart, type PartProps } from "@ui-sdk/react/primitive"

type RowsState = {
  open: SwipeOpen | null
  swiping: SwipeDraft | null
  focused: string | null
}

const rows = definePrimitive({
  name: "rows",
  state: (): RowsState => ({ open: null, swiping: null, focused: null }),
  live: ["swiping"],
  api: (context) => ({
    roving: createRovingFocus<RowsState, { value: string }>(context, {
      slot: "row",
      key: "focused",
    }),
    swipe: createSwipeActions<RowsState>(context, {
      key: "open",
      draft: "swiping",
    }),
  }),
  slots: (slot) => ({
    root: slot({ attrs: () => ({ role: "list" }) }),
    row: slot<{ value: string }>({
      props: { value: "string" },
      selects: selects({ focused: ({ key }) => [key, null] }),
      attrs: (state, part) => ({
        role: "listitem",
        tabindex: part.api.roving.tabIndex(state, part),
        "data-open": state.open?.key === part.key ? state.open.side : undefined,
        style: css({
          position: "relative",
          overflow: "hidden",
          "touch-action": "pan-y",
        }),
      }),
      on: {
        pointerdown: (event, { el, api, key }) =>
          api.swipe.pointerdown(event, key, el!),
        keydown: (event, { el, api, key }) =>
          api.swipe.keydown(event, key) ||
          (event.target === el && api.roving.keydown(event, el!)),
      },
      bind: (el, part) => {
        const cleanups = [
          part.api.roving.bind(el, part),
          part.api.swipe.bindRow(el, part.key),
        ]
        return () => cleanups.forEach((cleanup) => cleanup())
      },
    }),
    content: slot<{ value: string }>({
      props: { value: "string" },
      attrs: () => ({
        style: css({
          position: "relative",
          "z-index": "1",
          transform: "translateX(var(--swipe-x, 0px))",
        }),
      }),
      live: live<RowsState, any, { value: string }>((state, { api, key }) =>
        api.swipe.contentAttrs(state, key)
      ),
    }),
    actions: slot<{ id: string; row: string; side: SwipeSide }>({
      props: { row: "string", side: "string" },
      attrs: (state, { props, api }) => ({
        ...api.swipe.actionsAttrs(state, props.row, props.side),
        style: css({
          position: "absolute",
          top: "0",
          bottom: "0",
          [props.side === "start" ? "inset-inline-start" : "inset-inline-end"]:
            "0",
          display: "flex",
        }),
      }),
      bind: (el, { props, api }) =>
        api.swipe.bindActions(el, props.row, props.side),
    }),
  }),
})

const parts = createParts(rows, { root: "div", row: "div", content: "div" })

const Rows = parts.Root

const RowsItem = parts.Row

const RowsContent = parts.Content

function RowsActions({
  row,
  side,
  ...props
}: { row: string; side: SwipeSide } & PartProps<"div">) {
  return usePart(
    rows,
    "actions",
    { id: `${row}-${side}`, row, side },
    props,
    "div"
  )
}

function Example() {
  const [items, setItems] = React.useState([
    "Team lunch on Friday",
    "Your invoice is ready",
    "New comment on the docs",
  ])
  return (
    <Rows aria-label="Messages">
      {items.map((item) => (
        <RowsItem key={item} value={item}>
          <RowsContent value={item}>{item}</RowsContent>
          <RowsActions row={item} side="end">
            <button
              type="button"
              tabIndex={-1}
              data-variant="destructive"
              onClick={() =>
                setItems((current) => current.filter((other) => other !== item))
              }
            >
              Delete
            </button>
          </RowsActions>
        </RowsItem>
      ))}
    </Rows>
  )
}

export { Example }
Swipe a message left to reveal Delete. From the keyboard, focus a row and press Left to reach its action, then Escape to close it.

A swipe waits for horizontal movement so vertical scrolling still works. Releasing past half the actions' width keeps them open; a flick can open or close them sooner. Action widths come from your layout.

start actions appear when swiping toward the end of the row, left to right in English. The direction reverses in right-to-left layouts. contentAttrs supplies --swipe-x, data-swipe, and data-swiping, so you can disable transitions while the pointer moves.

Full Swipes

Add onFullSwipe to perform an action when the user swipes far enough. The callback performs the work; the helper doesn't click an action button for you:

onFullSwipe: (key, side) => {
  context.options().onAction?.(key, side)
  return true
},
fullSwipe: () => 0.5,
disabled: (key) => key === context.options().pinned,

Return whether the action was accepted. fullSwipe is a fraction of the row's width, 0.6 by default. The content gets data-full while release would trigger it.

Console
import * as React from "react"
import {
  createRovingFocus,
  createSwipeActions,
  css,
  definePrimitive,
  live,
  selects,
  type SwipeDraft,
  type SwipeOpen,
  type SwipeSide,
} from "@ui-sdk/core"
import { createParts, usePart, type PartProps } from "@ui-sdk/react/primitive"
import { PinIcon } from "lucide-react"
import { useLog } from "./log"

type MailboxState = {
  open: SwipeOpen | null
  swiping: SwipeDraft | null
  focused: string | null
}

type MailboxOptions = {
  pinned?: string
  first?: string
  onAction?: (key: string, side: SwipeSide) => void
}

const mailbox = definePrimitive({
  name: "mailbox",
  options: { pinned: "string", first: "string", onAction: "callback" },
  state: (_options: MailboxOptions): MailboxState => ({
    open: null,
    swiping: null,
    focused: null,
  }),
  live: ["swiping"],
  api: (context) => {
    const swipe = createSwipeActions<MailboxState>(context, {
      key: "open",
      draft: "swiping",
      onFullSwipe: (key, side) => {
        context.options().onAction?.(key, side)
        return true
      },
      fullSwipe: () => 0.5,
      disabled: (key) => key === context.options().pinned,
    })
    return {
      roving: createRovingFocus<MailboxState, { value: string }>(context, {
        slot: "row",
        key: "focused",
      }),
      swipe,
      act(action: string) {
        const first = context.options().first
        if (action === "peek" && first) {
          swipe.show(first, "end")
        } else if (action === "close") {
          swipe.close()
        }
      },
    }
  },
  slots: (slot) => ({
    root: slot({}),
    list: slot({ attrs: () => ({ role: "list" }) }),
    row: slot<{ value: string }>({
      props: { value: "string" },
      selects: selects({ focused: ({ key }) => [key, null] }),
      attrs: (state, part) => ({
        role: "listitem",
        tabindex: part.api.roving.tabIndex(state, part),
        "data-open": state.open?.key === part.key ? state.open.side : undefined,
        style: css({
          position: "relative",
          overflow: "hidden",
          "touch-action": "pan-y",
        }),
      }),
      on: {
        pointerdown: (event, { el, api, key }) =>
          api.swipe.pointerdown(event, key, el!),
        keydown: (event, { el, api, key }) =>
          api.swipe.keydown(event, key) ||
          (event.target === el && api.roving.keydown(event, el!)),
      },
      bind: (el, part) => {
        const cleanups = [
          part.api.roving.bind(el, part),
          part.api.swipe.bindRow(el, part.key),
        ]
        return () => cleanups.forEach((cleanup) => cleanup())
      },
    }),
    content: slot<{ value: string }>({
      props: { value: "string" },
      attrs: () => ({
        style: css({
          position: "relative",
          "z-index": "1",
          transform: "translateX(var(--swipe-x, 0px))",
        }),
      }),
      live: live<MailboxState, any, { value: string }>((state, { api, key }) =>
        api.swipe.contentAttrs(state, key)
      ),
    }),
    actions: slot<{ id: string; row: string; side: SwipeSide }>({
      props: { row: "string", side: "string" },
      attrs: (state, { props, api }) => ({
        ...api.swipe.actionsAttrs(state, props.row, props.side),
        style: css({
          position: "absolute",
          top: "0",
          bottom: "0",
          [props.side === "start" ? "inset-inline-start" : "inset-inline-end"]:
            "0",
          display: "flex",
        }),
      }),
      bind: (el, { props, api }) =>
        api.swipe.bindActions(el, props.row, props.side),
    }),
    action: slot<{ value: string }>({
      props: { value: "string" },
      attrs: () => ({ type: "button" }),
      press: ({ api, key }) => api.act(key),
    }),
  }),
})

const parts = createParts(mailbox, {
  root: "div",
  list: "div",
  row: "div",
  content: "div",
  action: "button",
})

const Mailbox = parts.Root

const MailboxList = parts.List

const MailboxRow = parts.Row

const MailboxContent = parts.Content

const MailboxAction = parts.Action

function MailboxActions({
  row,
  side,
  ...props
}: { row: string; side: SwipeSide } & PartProps<"div">) {
  return usePart(
    mailbox,
    "actions",
    { id: `${row}-${side}`, row, side },
    props,
    "div"
  )
}

function Example() {
  const log = useLog()
  const [messages, setMessages] = React.useState([
    "Welcome to Acme",
    "Flight to Lisbon",
    "Weekly digest",
  ])
  const [read, setRead] = React.useState<string[]>([])
  const toggleRead = (message: string) => {
    log(
      `${read.includes(message) ? "Marked unread" : "Marked read"}: ${message}`
    )
    setRead((current) =>
      current.includes(message)
        ? current.filter((other) => other !== message)
        : [...current, message]
    )
  }
  const archive = (message: string) => {
    log(`Archived: ${message}`)
    setMessages((current) => current.filter((other) => other !== message))
  }
  return (
    <Mailbox
      pinned="Welcome to Acme"
      first={messages[1]}
      onAction={(key: string, side: SwipeSide) =>
        side === "start" ? toggleRead(key) : archive(key)
      }
    >
      <div data-slot="controls">
        <MailboxAction data-size="sm" value="peek">
          Peek
        </MailboxAction>
        <MailboxAction data-size="sm" value="close">
          Close
        </MailboxAction>
      </div>
      <MailboxList aria-label="Mailbox">
        {messages.map((message) => (
          <MailboxRow key={message} value={message}>
            <MailboxContent value={message}>
              {message}
              {message === "Welcome to Acme" ? (
                <PinIcon aria-hidden="true" />
              ) : (
                read.includes(message) && <span data-slot="hint">Read</span>
              )}
            </MailboxContent>
            <MailboxActions row={message} side="start">
              <button
                type="button"
                tabIndex={-1}
                data-variant="solid"
                onClick={() => toggleRead(message)}
              >
                {read.includes(message) ? "Unread" : "Read"}
              </button>
            </MailboxActions>
            <MailboxActions row={message} side="end">
              <button
                type="button"
                tabIndex={-1}
                onClick={() => archive(message)}
              >
                Archive
              </button>
            </MailboxActions>
          </MailboxRow>
        ))}
      </MailboxList>
    </Mailbox>
  )
}

export { Example }
Swipe right to mark a message read or left to archive it. Past half the row, release to perform the action. The pinned message stays closed; Peek and Close control the actions from code.

swipe.show(key, "end", { focus: true }) opens a row and focuses its first action. swipe.close({ focus: true }) closes it and returns focus to the row. Omit focus to leave focus where it is. swipe.open() reads the open row.

Drag to Dismiss

A toast or card that's dragged far enough is dismissed on release. Give scrubTo a single snap point at 0: within the threshold its result has that point, so the toast snaps back, and past it, there's no point to return to. Ask on every move, so the toast can show what letting go will do:

import { scrubTo } from "@ui-sdk/core/number"

const keeps = { min: -width, max: width, step: 1, snap: { points: [0], threshold: width * 0.3 } }

onMove: ({ x }) => {
  moved = x - startX
  setOffset(moved)
  setDismissing(scrubTo(moved, keeps).point === undefined)
},
onEnd: () => (scrubTo(moved, keeps).point === undefined ? dismiss(Math.sign(moved)) : setOffset(0)),

Style data-dismissing so the change is clear before release, like iOS: here the layer behind the toast turns red and says "Release to dismiss". Dragging back under the threshold takes it back. Keep a Dismiss button in each toast for the keyboard.

import * as React from "react"
import { bindDrag, dragThreshold } from "@ui-sdk/core"
import { scrubTo } from "@ui-sdk/core/number"
import { XIcon } from "lucide-react"

const initial = [
  { id: 1, title: "Event created", body: "Design review, Friday at 10:00" },
  { id: 2, title: "Invite sent", body: "Inรจs Moreau can now edit" },
  { id: 3, title: "Upload finished", body: "launch-video.mp4 is ready" },
]

type Toast = (typeof initial)[number]

function SwipeToast({
  toast,
  onDismiss,
}: {
  toast: Toast
  onDismiss: () => void
}) {
  const [offset, setOffset] = React.useState(0)
  const [dragging, setDragging] = React.useState(false)
  const [dismissing, setDismissing] = React.useState(false)
  const stop = React.useRef<(() => void) | undefined>(undefined)
  React.useEffect(() => () => stop.current?.(), [])

  const dismiss = (side: number, width: number) => {
    setOffset(side * width)
    setTimeout(onDismiss, 200)
  }

  const pointerdown = (event: React.PointerEvent<HTMLDivElement>) => {
    if (event.button !== 0 || (event.target as Element).closest("button")) {
      return
    }
    const width = event.currentTarget.offsetWidth
    const keeps = {
      min: -width,
      max: width,
      step: 1,
      snap: { points: [0], threshold: width * 0.3 },
    }
    const startX = event.clientX
    let moved = 0
    stop.current?.()
    stop.current = bindDrag(event.currentTarget, event.nativeEvent, {
      threshold: dragThreshold,
      cancelOnEscape: true,
      onStart: () => setDragging(true),
      onMove: ({ x }) => {
        moved = x - startX
        setOffset(moved)
        setDismissing(scrubTo(moved, keeps).point === undefined)
      },
      onEnd: (_end, canceled) => {
        stop.current = undefined
        setDragging(false)
        setDismissing(false)
        if (!canceled && scrubTo(moved, keeps).point === undefined) {
          dismiss(Math.sign(moved), width)
        } else {
          setOffset(0)
        }
      },
    })
  }

  return (
    <li data-slot="dismiss-item">
      <div
        aria-hidden
        data-slot="dismiss-layer"
        data-side={offset < 0 ? "end" : "start"}
        data-dismissing={dismissing || undefined}
        hidden={!dragging}
      >
        {dismissing ? "Release to dismiss" : "Dismiss"}
      </div>
      <div
        data-slot="dismiss-toast"
        data-dragging={dragging || undefined}
        onPointerDown={pointerdown}
        style={{ "--dismiss-x": `${offset}px` } as React.CSSProperties}
      >
        <div data-slot="stack">
          <span>{toast.title}</span>
          <span data-slot="hint">{toast.body}</span>
        </div>
        <button
          type="button"
          data-variant="ghost"
          data-size="sm"
          aria-label={`Dismiss ${toast.title}`}
          onClick={(event) =>
            dismiss(1, event.currentTarget.parentElement!.offsetWidth)
          }
        >
          <XIcon aria-hidden="true" />
        </button>
      </div>
    </li>
  )
}

function Example() {
  const [toasts, setToasts] = React.useState(initial)
  return (
    <div data-slot="stack">
      <div data-slot="controls">
        <button
          type="button"
          onClick={() => setToasts(initial)}
          disabled={toasts.length === initial.length}
        >
          Show All
        </button>
      </div>
      <ul data-slot="dismiss-list" aria-label="Notifications">
        {toasts.map((toast) => (
          <SwipeToast
            key={toast.id}
            toast={toast}
            onDismiss={() =>
              setToasts((current) =>
                current.filter((candidate) => candidate.id !== toast.id)
              )
            }
          />
        ))}
      </ul>
    </div>
  )
}

export { Example }
Drag a toast sideways: past a third of its width, the layer behind turns red, and letting go dismisses it. Drag back, or press Escape, to keep it.

Pressing and Holding

Hold to Confirm

bindHold runs onHold once a press lasts past delay, 500 milliseconds by default. Give it a button and keep its cleanup:

import { bindHold } from "@ui-sdk/core"

const unbind = bindHold(button, {
  delay: 1500,
  onHold: () => deleteProject(),
})

It works with a mouse, a finger, a pen, and Space or Enter while the button has focus. While pressed, the button has data-holding, and data-held once it's held. --hold-delay holds the delay, so CSS can fill the button over exactly that time:

[data-holding] .fill {
  transform: scaleX(1);
  transition: transform var(--hold-delay) linear;
}
Console
import * as React from "react"
import { bindHold } from "@ui-sdk/core"
import { useLog } from "./log"

export function Example() {
  const button = React.useRef<HTMLButtonElement>(null)
  const hint = React.useId()
  const [deleted, setDeleted] = React.useState(false)
  const log = useLog()

  React.useEffect(() => {
    if (deleted) {
      return
    }
    return bindHold(button.current!, {
      delay: 1500,
      onHold: () => setDeleted(true),
      onEnd: ({ held, canceled }) => {
        if (!held) {
          log(canceled ? "Canceled" : "Released before it filled")
        }
      },
    })
  }, [deleted, log])

  if (deleted) {
    return (
      <div data-slot="row">
        <span>Launch site deleted.</span>
        <button
          type="button"
          data-variant="ghost"
          onClick={() => setDeleted(false)}
        >
          Restore
        </button>
      </div>
    )
  }

  return (
    <div data-slot="stack">
      <button
        ref={button}
        type="button"
        data-slot="hold-button"
        data-variant="destructive"
        aria-describedby={hint}
      >
        <span data-slot="hold-fill" aria-hidden />
        Delete Project
      </button>
      <span id={hint} data-slot="hint">
        Press and hold to delete
      </span>
    </div>
  )
}
Press and hold Delete Project until it fills. Let go early and nothing is deleted. From the keyboard, focus it and hold Space.

onEnd runs once the press ends, with held and canceled. A press is canceled when the pointer moves more than tolerance pixels before it's held, 10 by default, when a finger starts to scroll, when Escape is pressed, or when focus leaves the button or the window. A canceled press doesn't click, and Escape doesn't reach a dialog or popup around the button. Say that the button needs holding in its label or description, as the hint does here.

Repeat While Held

bindRepeat steps once when a button is pressed, then keeps stepping while it's held, like a stepper's buttons, a scroll arrow or a remote's volume button. Give it what one step does:

import { bindRepeat } from "@ui-sdk/core"

const unbind = bindRepeat(button, {
  repeat: 80,
  onStep: () => zoomBy(5),
})

After delay, 400 milliseconds by default, onStep runs again every repeat milliseconds, 60 by default, until the press ends. Return false from onStep to stop repeating, like at a bound. count says which step of the press it is, starting at 1, and onEnd gets the total once the press ends.

You don't need a click handler. A mouse or a key steps as it goes down, and the click that follows doesn't step again. A finger or a pen steps when it lifts, or once it's held, so a scroll that starts on the button never steps. A click with no press behind it, like a screen reader's, steps once.

import * as React from "react"
import { bindRepeat } from "@ui-sdk/core"
import { MinusIcon, PlusIcon } from "lucide-react"

const min = 25
const max = 400

function ZoomButton({
  label,
  disabled,
  onStep,
  children,
}: {
  label: string
  disabled: boolean
  onStep: () => boolean
  children: React.ReactNode
}) {
  const button = React.useRef<HTMLButtonElement>(null)
  const step = React.useEffectEvent(onStep)

  React.useEffect(
    () => bindRepeat(button.current!, { repeat: 80, onStep: () => step() }),
    []
  )

  return (
    <button
      ref={button}
      type="button"
      data-variant="outline"
      aria-label={label}
      disabled={disabled}
    >
      {children}
    </button>
  )
}

export function Example() {
  const [zoom, setZoom] = React.useState(100)

  const zoomBy = (amount: number) => {
    const next = Math.min(Math.max(zoom + amount, min), max)
    setZoom(next)
    return next > min && next < max
  }

  return (
    <div data-slot="row">
      <ZoomButton
        label="Zoom Out"
        disabled={zoom === min}
        onStep={() => zoomBy(-5)}
      >
        <MinusIcon aria-hidden />
      </ZoomButton>
      <output aria-live="polite" data-slot="hold-zoom">
        {zoom}%
      </output>
      <ZoomButton
        label="Zoom In"
        disabled={zoom === max}
        onStep={() => zoomBy(5)}
      >
        <PlusIcon aria-hidden />
      </ZoomButton>
    </div>
  )
}
Press Zoom In once to add 5%. Hold it and the zoom keeps climbing until you let go or it reaches 400%. Slide off the button while holding and it waits until you come back.

While held, the repeats wait whenever the pointer is off the button, and go on when it comes back, like a scroll bar's arrows. A short press of Space or Enter steps once; held, they repeat at the same pace as a pointer, however fast the keyboard repeats the key. Pass keys: [] to leave keys to the browser, and each click it sends steps once. Releasing, a cancel from the browser, Escape, or focus leaving the button or the window ends the press.

bindRepeat is built on bindHold, so the button has data-holding while pressed and data-held once it repeats. bindHold takes repeat too, for an action that waits until the press is held before its first run.

onPress runs as a press starts, with input set to mouse, touch, pen or keyboard. Return false to ignore it, like a context menu area that opens on a long press only for touch, since a mouse has right-click. Pass disabled to refuse presses and stop a hold from repeating. While a finger or pen presses, the browser's own long-press menu and iOS's callout stay closed. The cleanup stops a press under way without calling onEnd.

Two-Dimensional Values

A Point on a Pad

createArea maps a surface to two values. Read the point from state and write each change back:

const area = createArea(context, {
  value: (state) => ({ x: state.mix.warmth, y: state.mix.energy }),
  set: (value, details) =>
    change("mix", { warmth: value.x, energy: value.y }, details),
  x: () => ({ min: 0, max: 100, step: 1, label: "Warmth" }),
  y: () => ({ min: 0, max: 100, step: 1, label: "Energy" }),
})

The surface starts pointer tracking; the thumb supplies keyboard controls and slider attributes:

pad: slot({
  on: { pointerdown: (event, { el, api }) => api.area.pointerdown(event, el!) },
}),
thumb: slot({
  attrs: (state, { api }) => ({ ...api.area.thumbAttrs(state), style: api.area.thumbStyle(state) }),
  on: { keydown: (event, { api }) => api.area.keydown(event) },
}),

Make the surface positioned with touch-action: none. The thumb's position is relative to it. Unlike a move draft, this picker calls set throughout the gesture, with the reasons a slider reports: track-press for the press, drag as it moves and step-key for keys.

Console
import * as React from "react"
import {
  createArea,
  createChange,
  definePrimitive,
  type AreaReason,
  type ChangeDetails,
} from "@ui-sdk/core"
import {
  createParts,
  usePart,
  usePrimitive,
  type PartProps,
} from "@ui-sdk/react/primitive"
import { useLog } from "./log"

type Mix = { warmth: number; energy: number }

type MixerState = { mix: Mix }

type MixerOptions = {
  defaultMix: Mix
  onMixChange?: (mix: Mix, details: ChangeDetails<AreaReason>) => void
}

const mixer = definePrimitive({
  name: "mixer",
  options: { defaultMix: "value", onMixChange: "callback" },
  state: (options: MixerOptions): MixerState => ({ mix: options.defaultMix }),
  api: (context) => {
    const change = createChange(context)
    return {
      area: createArea<MixerState>(context, {
        value: (state) => ({ x: state.mix.warmth, y: state.mix.energy }),
        set: (value, details) =>
          change("mix", { warmth: value.x, energy: value.y }, details),
        x: () => ({ min: 0, max: 100, step: 1, label: "Warmth" }),
        y: () => ({ min: 0, max: 100, step: 1, label: "Energy" }),
      }),
    }
  },
  slots: (slot) => ({
    pad: slot({
      attrs: () => ({
        style: "position:relative;touch-action:none;user-select:none",
      }),
      on: {
        pointerdown: (event, { el, api }) => api.area.pointerdown(event, el!),
      },
    }),
    thumb: slot({
      attrs: (state, { api }) => ({
        ...api.area.thumbAttrs(state),
        style: api.area.thumbStyle(state),
      }),
      on: { keydown: (event, { api }) => api.area.keydown(event) },
    }),
  }),
})

const parts = createParts(mixer, { thumb: "div" })

function Mixer({
  defaultMix,
  onMixChange,
  ...props
}: MixerOptions & PartProps<"div">) {
  const handle = usePrimitive(mixer, { defaultMix, onMixChange })
  return usePart(handle, "pad", {}, props, "div")
}

const MixerThumb = parts.Thumb

function Example() {
  const log = useLog()
  return (
    <Mixer
      defaultMix={{ warmth: 30, energy: 70 }}
      onMixChange={(mix, details) =>
        log(`Warmth ${mix.warmth}, energy ${mix.energy} (${details.reason})`)
      }
    >
      <span data-slot="mixer-axis" data-axis="y" aria-hidden="true">
        Energy
      </span>
      <span data-slot="mixer-axis" data-axis="x" aria-hidden="true">
        Warmth
      </span>
      <MixerThumb aria-label="Mix" />
    </Mixer>
  )
}

export { Example }
Click or drag on the pad to change warmth and energy. Focus the thumb and use the arrows, with Shift for larger steps.

Each axis defaults to 0โ€“1 in steps of 0.01. The y axis runs bottom to top, and x follows the text direction. Arrows move one step, Shift moves ten, Page Up and Page Down move y by a tenth of its range, and Home and End go to x's ends.

thumbAttrs exposes one slider with value text describing both axes. thumbStyle sets its percentage position and the --area-x and --area-y fractions.

What's Read Out, and When It Settles

Use valueText for a readable description and onCommit for work that should wait until a gesture ends:

valueText: ({ x, y }) => `${x}% saturation, ${y}% brightness`,
onCommit: (value) => context.options().onColorCommit?.(value),
disabled: () => context.store.get().locked,
Console
import * as React from "react"
import {
  createArea,
  createChange,
  definePrimitive,
  type AreaValue,
} from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"
import { Checkbox, CheckboxIndicator } from "@ui-sdk/react/checkbox"
import { CheckIcon } from "lucide-react"
import { useLog } from "./log"

type ColorState = { color: AreaValue; locked: boolean }

type ColorOptions = {
  locked?: boolean
  onColorChange?: (color: AreaValue) => void
  onColorCommit?: (color: AreaValue) => void
}

const color = definePrimitive({
  name: "color",
  options: {
    locked: "boolean",
    onColorChange: "callback",
    onColorCommit: "callback",
  },
  state: (options: ColorOptions): ColorState => ({
    color: { x: 60, y: 80 },
    locked: !!options.locked,
  }),
  sync: (options) => ({ locked: !!options.locked }),
  api: (context) => {
    const change = createChange(context)
    const area = createArea<ColorState>(context, {
      value: (state) => state.color,
      set: (value, details) => change("color", value, details),
      x: () => ({ min: 0, max: 100, step: 1, label: "Saturation" }),
      y: () => ({ min: 0, max: 100, step: 1, label: "Brightness" }),
      valueText: ({ x, y }) => `${x}% saturation, ${y}% brightness`,
      onCommit: (value) => context.options().onColorCommit?.(value),
      disabled: () => context.store.get().locked,
    })
    return { area }
  },
  slots: (slot) => ({
    root: slot<{}, { swatch: string }>({
      state: (state, { api }) => {
        const { x, y } = api.area.fractions(state)
        const lightness = y * (1 - x / 2)
        const saturation =
          lightness === 0 || lightness === 1
            ? 0
            : (y - lightness) / Math.min(lightness, 1 - lightness)
        return {
          swatch: `hsl(210 ${Math.round(saturation * 100)}% ${Math.round(lightness * 100)}%)`,
        }
      },
    }),
    square: slot({
      attrs: () => ({
        style: "position:relative;touch-action:none;user-select:none",
      }),
      on: {
        pointerdown: (event, { el, api }) => api.area.pointerdown(event, el!),
      },
    }),
    thumb: slot({
      attrs: (state, { api }) => ({
        ...api.area.thumbAttrs(state),
        style: api.area.thumbStyle(state),
      }),
      on: { keydown: (event, { api }) => api.area.keydown(event) },
    }),
    reset: slot({
      attrs: () => ({ type: "button" }),
      press: ({ api }) => api.area.set({ x: 60, y: 80 }),
    }),
  }),
})

const parts = createParts(color, {
  root: "div",
  square: "div",
  thumb: "div",
  reset: "button",
})

const ColorPicker = parts.Root

const ColorSquare = parts.Square

const ColorThumb = parts.Thumb

const ColorReset = parts.Reset

function Example() {
  const log = useLog()
  const [locked, setLocked] = React.useState(false)
  return (
    <ColorPicker
      locked={locked}
      onColorCommit={({ x, y }) =>
        log(`Saved ${x}% saturation, ${y}% brightness`)
      }
    >
      {({ swatch }) => (
        <>
          <div data-slot="controls">
            <ColorReset data-size="sm">Reset</ColorReset>
            <label data-slot="label">
              <Checkbox
                checked={locked}
                onCheckedChange={(next) => setLocked(next === true)}
              >
                <CheckboxIndicator>
                  <CheckIcon aria-hidden="true" />
                </CheckboxIndicator>
              </Checkbox>
              Lock
            </label>
          </div>
          <ColorSquare>
            <ColorThumb aria-label="Color" />
          </ColorSquare>
          <span data-slot="swatch" style={{ background: swatch }} />
        </>
      )}
    </ColorPicker>
  )
}

export { Example }
Drag to adjust saturation and brightness. The swatch follows immediately; the saved value is logged on release or after a keyboard change. Lock disables the picker, and Reset sets its value from code.

onCommit runs at the end of pointer tracking or after a successful keyboard change. disabled prevents user input and removes the thumb from the tab order.

area.set({ x: 60, y: 80 }) snaps and clamps a programmatic value with reason imperative. It still works when disabled and doesn't call onCommit. area.fractions(state) gives each value's position within its axis, useful for a gradient or swatch.

Ranges on Tracks

Bookings on a Timeline

A Span has an id, a track, and start and end values in your units. For room bookings, those units can be minutes from midnight. Keep bookings in an array and a SpansDraft<Booking> | null field at null:

type Booking = Span & { title: string }
type RoomsState = { bookings: Booking[]; draft: SpansDraft<Booking> | null }

let booked = 0
const spans = createSpans<RoomsState, Booking>(context, {
  key: "bookings",
  draft: "draft",
  set: (value, details) => change("bookings", value, details),
  axis: "x",
  min: () => 9 * 60,
  max: () => 17 * 60,
  step: () => 30,
  overlap: false,
  create: (span) => ({ ...span, id: `booking-${++booked}`, title: "New" }),
})

Render spans.spans(state) so the draft appears during a drag. A drop commits the array once, with the affected id and action in its details.

Bind tracks with bindTrack(el, key) and bookings with bindSpan(el, id). Track presses call trackPointerdown(event, key); booking presses call spanPointerdown(event, id). Add data-span-edge="start" or "end" to resize handles inside a booking.

import * as React from "react"
import {
  createChange,
  createSpans,
  definePrimitive,
  type ChangeDetails,
  type Span,
  type SpansDraft,
  type SpansReason,
} from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"

type Booking = Span & { title: string }

type RoomsState = { bookings: Booking[]; draft: SpansDraft<Booking> | null }

type RoomsOptions = {
  defaultBookings: Booking[]
  onBookingsChange?: (
    bookings: Booking[],
    details: ChangeDetails<SpansReason>
  ) => void
}

const hour = (minutes: number) =>
  `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, "0")}`

let booked = 0

const rooms = definePrimitive({
  name: "rooms",
  options: { defaultBookings: "value", onBookingsChange: "callback" },
  state: (options: RoomsOptions): RoomsState => ({
    bookings: options.defaultBookings,
    draft: null,
  }),
  api: (context) => {
    const change = createChange(context)
    return {
      spans: createSpans<RoomsState, Booking>(context, {
        key: "bookings",
        draft: "draft",
        set: (value, details) => change("bookings", value, details),
        axis: "x",
        min: () => 9 * 60,
        max: () => 17 * 60,
        step: () => 30,
        overlap: false,
        create: (span) => ({
          ...span,
          id: `booking-${++booked}`,
          title: "New",
        }),
        label: (booking) =>
          `${booking.title}, ${booking.track}, ${hour(booking.start)} to ${hour(booking.end)}`,
      }),
    }
  },
  slots: (slot) => ({
    root: slot<{}, { bookings: Booking[] }>({
      state: (state, { api }) => ({ bookings: api.spans.spans(state) }),
    }),
    room: slot<{ value: string }>({
      props: { value: "string" },
      attrs: () => ({
        role: "group",
        style: "position:relative;touch-action:none;user-select:none",
      }),
      on: {
        pointerdown: (event, { api, key }) =>
          api.spans.trackPointerdown(event, key),
      },
      bind: (el, { api, key }) => api.spans.bindTrack(el, key),
    }),
    booking: slot<{ value: string }>({
      props: { value: "string" },
      attrs: (state, { api, key }) => {
        const booking = api.spans.spans(state).find((other) => other.id === key)
        return {
          tabindex: "0",
          "aria-label": booking
            ? `${booking.title}, ${booking.track}, ${hour(booking.start)} to ${hour(booking.end)}`
            : undefined,
          ...api.spans.spanAttrs(state, key),
          style: booking ? api.spans.spanStyle(booking) : undefined,
        }
      },
      on: {
        pointerdown: (event, { api, key }) =>
          api.spans.spanPointerdown(event, key),
        keydown: (event, { api, key }) => api.spans.keydown(event, key),
      },
      bind: (el, { api, key }) => api.spans.bindSpan(el, key),
    }),
  }),
})

const parts = createParts(rooms, { root: "div", room: "div", booking: "div" })

const Rooms = parts.Root

const RoomsRoom = parts.Room

const RoomsBooking = parts.Booking

function Example() {
  return (
    <Rooms
      defaultBookings={[
        {
          id: "standup",
          track: "Atlas",
          start: 9 * 60 + 30,
          end: 11 * 60,
          title: "Standup",
        },
        {
          id: "review",
          track: "Birch",
          start: 13 * 60,
          end: 15 * 60,
          title: "Review",
        },
      ]}
    >
      {({ bookings }) => (
        <>
          <div data-slot="spans-lane">
            <span data-slot="spans-label" />
            <div data-slot="spans-axis" aria-hidden="true">
              <span>9:00</span>
              <span>13:00</span>
              <span>17:00</span>
            </div>
          </div>
          {["Atlas", "Birch"].map((room) => (
            <div key={room} data-slot="spans-lane">
              <span data-slot="spans-label">{room}</span>
              <RoomsRoom value={room} aria-label={room}>
                {bookings
                  .filter((booking) => booking.track === room)
                  .map((booking) => (
                    <RoomsBooking key={booking.id} value={booking.id}>
                      {booking.title}
                      <span data-span-edge="end" />
                    </RoomsBooking>
                  ))}
              </RoomsRoom>
            </div>
          ))}
        </>
      )}
    </Rooms>
  )
}

export { Example }
Drag on an empty stretch to book a room. Drag a booking to move it, or its end to resize it. Values snap to half hours, and bookings can't overlap.

Use spanStyle(booking) to position a booking, and spanAttrs(state, id) for data-span-id and drawing, dragging, or resizing states. Supply its focusability and accessible name yourself; label(booking) supplies the name used in announcements. Give tracks and draggable spans touch-action: none.

Call keydown(event, id) on the booking. Arrows along the track move it; Shift with those arrows moves its end. Arrows across tracks change rooms, and Delete or Backspace removes it. Each key commits immediately.

create supplies a new booking's ID and data. Without it, empty tracks don't create bookings. overlap: false rejects proposed ranges that overlap another on the same track.

Days That Run Down

Tracks run vertically by default. Add duration limits, a click duration, and an explicit track order for a calendar:

minSize: () => 60,
maxSize: () => 180,
size: () => 60,
tracks: () => ["Mon", "Tue", "Wed"],
createAutoScroll: { edge: 32 },
Console
import * as React from "react"
import {
  createChange,
  createSpans,
  css,
  definePrimitive,
  stackSpans,
  type Span,
  type SpanAction,
  type SpansDraft,
} from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"
import { useLog } from "./log"

type Plan = Span & { title: string }

type WeekState = {
  plans: Plan[]
  draft: SpansDraft<Plan> | null
  hover: number | null
}

const time = (minutes: number) =>
  `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, "0")}`

let made = 0

const week = definePrimitive({
  name: "week",
  options: { onHover: "callback" },
  state: (_options: {
    onHover?: (day: string, minutes: number) => void
  }): WeekState => ({
    plans: [
      { id: "gym", track: "Mon", start: 9 * 60, end: 10 * 60, title: "Gym" },
      {
        id: "call",
        track: "Mon",
        start: 9 * 60 + 30,
        end: 11 * 60,
        title: "Call",
      },
      {
        id: "lunch",
        track: "Tue",
        start: 12 * 60,
        end: 13 * 60,
        title: "Lunch",
      },
    ],
    draft: null,
    hover: null,
  }),
  api: (context) => {
    const change = createChange(context)
    const spans = createSpans<WeekState, Plan>(context, {
      key: "plans",
      draft: "draft",
      set: (value, details) => change("plans", value, details),
      min: () => 8 * 60,
      max: () => 14 * 60,
      step: () => 30,
      minSize: () => 60,
      maxSize: () => 180,
      size: () => 60,
      tracks: () => ["Mon", "Tue", "Wed"],
      create: (span) => ({ ...span, id: `plan-${++made}`, title: "New" }),
      label: (plan) =>
        `${plan.title}, ${plan.track} ${time(plan.start)} to ${time(plan.end)}`,
      createAutoScroll: { edge: 32 },
    })
    return {
      spans,
      hover: (track: string | null, event?: PointerEvent) => {
        const hover =
          track && event
            ? spans.valueAt(track, event.clientX, event.clientY)
            : null
        if (hover === context.store.get().hover) {
          return
        }
        context.store.set({ hover })
        if (track && hover !== null) {
          context.options().onHover?.(track, hover)
        }
      },
    }
  },
  slots: (slot) => ({
    root: slot({}),
    day: slot<{ value: string }, { plans: Plan[] }>({
      props: { value: "string" },
      state: (state, { api, key }) => ({
        plans: api.spans.spansOn(state, key),
      }),
      attrs: () => ({
        role: "group",
        style: "position:relative;touch-action:none;user-select:none",
      }),
      on: {
        pointerdown: (event, { api, key }) =>
          api.spans.trackPointerdown(event, key),
        pointermove: (event, { api, key }) => api.hover(key, event),
        pointerleave: (_event, { api }) => api.hover(null),
      },
      bind: (el, { api, key }) => api.spans.bindTrack(el, key),
    }),
    plan: slot<
      { value: string },
      { plan: Plan; action: SpanAction | undefined }
    >({
      props: { value: "string" },
      present: (state, { api, key }) =>
        api.spans.spans(state).some((plan) => plan.id === key),
      state: (state, { api, key }) => ({
        plan: api.spans.spans(state).find((plan) => plan.id === key)!,
        action: api.spans.action(state, key),
      }),
      attrs: (state, { api, key }) => {
        const plans = api.spans.spans(state)
        const plan = plans.find((other) => other.id === key)!
        const lanes = stackSpans(
          plans.filter((other) => other.track === plan.track)
        )
        return {
          tabindex: "0",
          "aria-label": `${plan.title}, ${plan.track} ${time(plan.start)} to ${time(plan.end)}`,
          ...api.spans.spanAttrs(state, key),
          style: api.spans.spanStyle(plan, lanes.get(key)),
        }
      },
      on: {
        pointerdown: (event, { api, key }) =>
          api.spans.spanPointerdown(event, key),
        keydown: (event, { api, key }) => api.spans.keydown(event, key),
      },
      bind: (el, { api, key }) => api.spans.bindSpan(el, key),
    }),
    edge: slot<{ edge: "start" | "end" }>({
      props: { edge: "string" },
      attrs: (_state, { props }) => ({
        "data-span-edge": props.edge,
        style: css({
          position: "absolute",
          left: "0",
          right: "0",
          height: "6px",
          cursor: "ns-resize",
        }),
      }),
    }),
  }),
})

const parts = createParts(week, {
  root: "div",
  day: "div",
  plan: "div",
  edge: "div",
})

const Week = parts.Root

const WeekDay = parts.Day

const WeekPlan = parts.Plan

const WeekEdge = parts.Edge

function Example() {
  const log = useLog()
  return (
    <Week onHover={(day, minutes) => log(`Pointer at ${day} ${time(minutes)}`)}>
      <div data-slot="spans-week-head">
        {["Mon", "Tue", "Wed"].map((day) => (
          <span key={day}>{day}</span>
        ))}
      </div>
      <div data-slot="spans-week-frame">
        <div data-slot="spans-week-hours">
          {[8, 9, 10, 11, 12, 13].map((hour) => (
            <div key={hour}>{hour}:00</div>
          ))}
        </div>
        {["Mon", "Tue", "Wed"].map((day) => (
          <WeekDay key={day} value={day} aria-label={day}>
            {({ plans }) =>
              plans.map((plan) => (
                <WeekPlan key={plan.id} value={plan.id}>
                  {({ plan, action }) => (
                    <>
                      <WeekEdge edge="start" />
                      {plan.title}
                      {action && ` ${time(plan.start)}โ€“${time(plan.end)}`}
                      <WeekEdge edge="end" />
                    </>
                  )}
                </WeekPlan>
              ))
            }
          </WeekDay>
        ))}
      </div>
    </Week>
  )
}

export { Example }
Drag down a day to draw an event, or click for an hour. Move or resize events between one and three hours. Hold a drag near the top or bottom to scroll.

minSize and maxSize bound duration; size determines what a click creates. tracks controls keyboard travel across days. Auto-scroll accepts the same options as other drags.

When overlap is allowed, give each event a lane so they can share the day's width:

const lanes = stackSpans(plans.filter((other) => other.track === plan.track))
const style = spans.spanStyle(plan, lanes.get(plan.id))

spansOn(state, track) reads one track's ranges. valueAt(track, x, y) converts a pointer position to a value within that track's range, snapped to step. action(state, id) reports the active pointer action.

Changing Ranges from Code

Use update and remove for form fields or action buttons:

spans.update("standup", { start: booking.start + 30, end: booking.end + 30 })
spans.update("standup", { end: booking.end + 30 })
spans.remove("standup")

update applies range and overlap rules. These methods report imperative; they don't check disabled, which applies to pointer and keyboard input. isSpanFree(bookings, proposed) checks whether a proposed booking overlaps another on its track.

Console
import * as React from "react"
import {
  createChange,
  createSpans,
  definePrimitive,
  isSpanFree,
  spansMessage,
  type Span,
  type SpansDraft,
} from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"
import { useLog } from "./log"

type Booking = Span & { title: string }

type RoomState = { bookings: Booking[]; draft: SpansDraft<Booking> | null }

type RoomOptions = {
  onBookingsChange?: (bookings: Booking[]) => void
  onNote?: (note: string) => void
}

const hour = (minutes: number) =>
  `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, "0")}`

let lunches = 0

const room = definePrimitive({
  name: "room",
  options: { onBookingsChange: "callback", onNote: "callback" },
  state: (_options: RoomOptions): RoomState => ({
    bookings: [
      {
        id: "standup",
        track: "Atlas",
        start: 9 * 60 + 30,
        end: 11 * 60,
        title: "Standup",
      },
      {
        id: "board",
        track: "Atlas",
        start: 14 * 60,
        end: 16 * 60,
        title: "Board meeting",
      },
    ],
    draft: null,
  }),
  api: (context) => {
    const { store } = context
    const change = createChange(context)
    const spans = createSpans<RoomState, Booking>(context, {
      key: "bookings",
      draft: "draft",
      set: (value, details) => change("bookings", value, details),
      axis: "x",
      min: () => 9 * 60,
      max: () => 17 * 60,
      step: () => 30,
      overlap: false,
      disabled: (booking) => booking.id === "board",
      label: (booking) =>
        `${booking.title}, ${hour(booking.start)} to ${hour(booking.end)}`,
      message: (message) =>
        message.type === "delete"
          ? `${message.span.title} is cancelled.`
          : spansMessage(message),
      createAutoScroll: false,
    })
    const standup = () =>
      store.get().bookings.find((booking) => booking.id === "standup")
    return {
      spans,
      act(action: string) {
        const booking = standup()
        if (action === "later" && booking) {
          spans.update("standup", {
            start: booking.start + 30,
            end: booking.end + 30,
          })
        } else if (action === "longer" && booking) {
          spans.update("standup", { end: booking.end + 30 })
        } else if (action === "delete") {
          spans.remove("standup")
        } else if (action === "noon") {
          const noon: Booking = {
            id: `lunch-${++lunches}`,
            track: "Atlas",
            start: 12 * 60,
            end: 13 * 60,
            title: "Lunch",
          }
          const free = isSpanFree(store.get().bookings, noon)
          context.options().onNote?.(free ? "Booked noon" : "Noon is taken")
          if (free) {
            change("bookings", [...store.get().bookings, noon], {
              reason: "imperative",
            })
          }
        }
      },
    }
  },
  slots: (slot) => ({
    root: slot<{}, { bookings: Booking[] }>({
      state: (state, { api }) => ({ bookings: api.spans.spans(state) }),
    }),
    track: slot<{ value: string }>({
      props: { value: "string" },
      attrs: () => ({
        role: "group",
        style: "position:relative;touch-action:none;user-select:none",
      }),
      bind: (el, { api, key }) => {
        const unbind = api.spans.bindTrack(el, key)
        const view = el.ownerDocument.defaultView!
        view.addEventListener("blur", api.spans.cancel)
        return () => {
          unbind()
          view.removeEventListener("blur", api.spans.cancel)
        }
      },
    }),
    booking: slot<{ value: string }>({
      props: { value: "string" },
      attrs: (state, { api, key }) => {
        const booking = api.spans.spans(state).find((other) => other.id === key)
        return {
          tabindex: "0",
          "aria-label": booking
            ? `${booking.title}, ${hour(booking.start)} to ${hour(booking.end)}`
            : undefined,
          "aria-disabled": key === "board" ? "true" : undefined,
          ...api.spans.spanAttrs(state, key),
          style: booking ? api.spans.spanStyle(booking) : undefined,
        }
      },
      on: {
        pointerdown: (event, { api, key }) =>
          api.spans.spanPointerdown(event, key),
        keydown: (event, { api, key }) => api.spans.keydown(event, key),
      },
      bind: (el, { api, key }) => api.spans.bindSpan(el, key),
    }),
    action: slot<{ value: string }>({
      props: { value: "string" },
      attrs: () => ({ type: "button" }),
      press: ({ api, key }) => api.act(key),
    }),
  }),
})

const parts = createParts(room, {
  root: "div",
  track: "div",
  booking: "div",
  action: "button",
})

const Room = parts.Root

const RoomTrack = parts.Track

const RoomBooking = parts.Booking

const RoomAction = parts.Action

function Example() {
  const log = useLog()
  return (
    <Room
      onBookingsChange={(bookings) =>
        log(
          bookings
            .map(
              (booking) =>
                `${booking.title} ${hour(booking.start)}โ€“${hour(booking.end)}`
            )
            .join(" ยท ")
        )
      }
      onNote={log}
    >
      {({ bookings }) => (
        <>
          <div data-slot="controls">
            {[
              ["later", "Later"],
              ["longer", "Longer"],
              ["delete", "Delete"],
              ["noon", "Book Noon"],
            ].map(([value, text]) => (
              <RoomAction data-size="sm" key={value} value={value}>
                {text}
              </RoomAction>
            ))}
          </div>
          <div data-slot="spans-axis" aria-hidden="true">
            <span>9:00</span>
            <span>13:00</span>
            <span>17:00</span>
          </div>
          <RoomTrack value="Atlas" aria-label="Atlas">
            {bookings.map((booking) => (
              <RoomBooking key={booking.id} value={booking.id}>
                {booking.title}
              </RoomBooking>
            ))}
          </RoomTrack>
        </>
      )}
    </Room>
  )
}

export { Example }
Move, lengthen, or delete Standup with the buttons. Book noon checks whether the room is free. The board meeting is locked, and leaving the window during a drag cancels it.

spans.cancel() ends a drag without committing it. If you cancel when the window loses focus, remove that listener when the primitive is destroyed:

window.addEventListener("blur", spans.cancel)
context.onDestroy(() => window.removeEventListener("blur", spans.cancel))

message customizes announcements with spansMessage as the default. Set createAutoScroll: false when your tracks don't need edge scrolling.

Previews

Press and Slide

A preview lets someone try a value before choosing it. Keep a string | null preview beside the chosen value, and render preview ?? value:

const preview = createPreview(context, {
  key: "preview",
  slot: "font",
  value: () => context.store.get().value,
  choose: (key, details) => change("value", key, details),
})

The preview writes directly to its state field. Add set if it needs to go through a change callback too.

Keep the existing roving focus binding for the font slot, and add these handlers:

on: {
  pointerdown: (event, { el, api, key }) => api.preview.pointerdown(event, key, el!.parentElement!),
  keydown: (event, { el, api, key }) => {
    if (api.preview.keydown(event, key)) {
      return
    }
    const moved = api.roving.keydown(event, el!)
    if (moved) {
      api.preview.show(moved.key, { reason: "list-navigation", event })
    }
  },
},
Console
import * as React from "react"
import {
  createChange,
  createPreview,
  createRovingFocus,
  definePrimitive,
  selects,
  type ChangeDetails,
  type PreviewReason,
} from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"
import { useLog } from "./log"

type FontsState = {
  value: string
  preview: string | null
  focused: string | null
}

type FontsOptions = {
  defaultValue: string
  onValueChange?: (
    value: string,
    details: ChangeDetails<"item-press" | "list-navigation">
  ) => void
  onPreviewChange?: (
    preview: string | null,
    details: ChangeDetails<PreviewReason>
  ) => void
}

const fonts = definePrimitive({
  name: "fonts",
  options: {
    defaultValue: "string",
    onValueChange: "callback",
    onPreviewChange: "callback",
  },
  state: (options: FontsOptions): FontsState => ({
    value: options.defaultValue,
    preview: null,
    focused: null,
  }),
  api: (context) => {
    const { store } = context
    const change = createChange(context)
    const roving = createRovingFocus<FontsState, { value: string }>(context, {
      slot: "font",
      key: "focused",
      orientation: () => "horizontal",
    })
    const preview = createPreview<FontsState>(context, {
      key: "preview",
      slot: "font",
      value: () => store.get().value,
      set: (value, details) => change("preview", value, details),
      choose: (key, details) => change("value", key, details),
    })
    return { roving, preview }
  },
  slots: (slot) => ({
    root: slot<{}, { shown: string }>({
      state: (state) => ({ shown: state.preview ?? state.value }),
      attrs: () => ({ role: "radiogroup" }),
      on: {
        focusout: (event, { el, api }) => {
          if (
            api.preview.dragging() ||
            el?.contains(event.relatedTarget as Node)
          ) {
            return
          }
          api.preview.clear({ reason: "focus-out", event })
        },
      },
    }),
    font: slot<{ value: string }>({
      props: { value: "string" },
      selects: selects({ focused: ({ key }) => [key, null] }),
      attrs: (state, part) => ({
        type: "button",
        role: "radio",
        "aria-checked": String(state.value === part.key),
        tabindex: part.api.roving.tabIndex(state, part),
        "data-checked": state.value === part.key ? "" : undefined,
        "data-previewing": state.preview === part.key ? "" : undefined,
        style: "touch-action:none;user-select:none",
      }),
      on: {
        pointerdown: (event, { el, api, key }) =>
          api.preview.pointerdown(event, key, el!.parentElement!),
        keydown: (event, { el, api, key }) => {
          if (api.preview.keydown(event, key)) {
            return
          }
          const moved = api.roving.keydown(event, el!)
          if (moved) {
            api.preview.show(moved.key, { reason: "list-navigation", event })
          }
        },
      },
      bind: (el, part) => part.api.roving.bind(el, part),
    }),
  }),
})

const parts = createParts(fonts, { root: "div", font: "button" })

const Fonts = parts.Root

const FontsOption = parts.Font

const families: Record<string, string> = {
  Sans: "ui-sans-serif, system-ui",
  Serif: "ui-serif, Georgia, serif",
  Mono: "var(--mono)",
}

function Example() {
  const log = useLog()
  const [shown, setShown] = React.useState("Sans")
  const [preview, setPreview] = React.useState<string | null>(null)
  return (
    <div data-slot="stack">
      <p
        data-slot="preview-sample"
        style={
          {
            "--preview-font": families[preview ?? shown],
          } as React.CSSProperties
        }
      >
        The quick brown fox
      </p>
      <Fonts
        aria-label="Font"
        defaultValue="Sans"
        onValueChange={(value) => {
          setShown(value)
          log(`Chose ${value}`)
        }}
        onPreviewChange={setPreview}
      >
        {Object.keys(families).map((font) => (
          <FontsOption key={font} value={font}>
            {font}
          </FontsOption>
        ))}
      </Fonts>
    </div>
  )
}

export { Example }
Press a font and slide over the others to preview them. Release over one to choose it, or outside to cancel. With the keyboard, arrows preview and Enter or Space chooses.

Pass the common parent surface to pointerdown to track the gesture across the options. show(key) previews one, choose(key) commits it, and clear() returns to the chosen value. Enter and Space choose; Escape clears.

Clear the preview when focus leaves the group, unless a pointer is still scrubbing:

focusout: (event, { el, api }) => {
  if (api.preview.dragging() || el?.contains(event.relatedTarget as Node)) {
    return
  }
  api.preview.clear({ reason: "focus-out", event })
},

Pressing and sliding can also try out an order. Measure each tab when the press starts, and make the left edge of every slot the dragged tab could take a snap point. scrubTo pulls the tab into a slot as it comes close, the nearest slot shows the order it would make, and letting go keeps it:

const slots = lefts([...others, key], widths)

onMove: ({ x, event }) => {
  const { value: left } = scrubTo(start + x, { min: 0, max: slots.at(-1), step: 1, snap: { points: slots, threshold: 12 } }, {
    snap: !event.metaKey,
  })
  preview(insert(others, key, nearest(slots, left)), left)
},

For the keyboard, stepFromKey and stepBy move the focused tab one place, or to either end with Home and End, the same way they step a slider.

import * as React from "react"
import { bindDrag, dragThreshold } from "@ui-sdk/core"
import { scrubTo, stepBy, stepFromKey } from "@ui-sdk/core/number"

const tabs = {
  inbox: "Inbox",
  drafts: "Drafts",
  sent: "Sent",
  spam: "Spam",
}

const counts: Record<string, string> = {
  inbox: "12 conversations, 3 unread",
  drafts: "2 drafts",
  sent: "48 conversations",
  spam: "No spam",
}

type Key = keyof typeof tabs

const gap = 4

type Drag = {
  key: Key
  left: number
  target: number
  others: Key[]
  widths: Record<string, number>
}

const insert = (others: Key[], key: Key, at: number) => [
  ...others.slice(0, at),
  key,
  ...others.slice(at),
]

const nearest = (points: number[], value: number) =>
  points.reduce(
    (best, point, index) =>
      Math.abs(point - value) < Math.abs(points[best] - value) ? index : best,
    0
  )

const lefts = (order: Key[], widths: Record<string, number>) =>
  order.reduce<number[]>(
    (all, key, index) => [
      ...all,
      index ? all[index - 1] + widths[order[index - 1]] + gap : 0,
    ],
    []
  )

function Example() {
  const [order, setOrder] = React.useState(Object.keys(tabs) as Key[])
  const [selected, setSelected] = React.useState<Key>("inbox")
  const [drag, setDrag] = React.useState<Drag | null>(null)
  const elements = React.useRef(new Map<Key, HTMLButtonElement>())
  const stop = React.useRef<(() => void) | undefined>(undefined)
  React.useEffect(() => () => stop.current?.(), [])

  const pointerdown = (
    event: React.PointerEvent<HTMLButtonElement>,
    key: Key
  ) => {
    if (event.button !== 0) {
      return
    }
    const widths = Object.fromEntries(
      order.map((candidate) => [
        candidate,
        elements.current.get(candidate)!.getBoundingClientRect().width,
      ])
    )
    const others = order.filter((candidate) => candidate !== key)
    const slots = lefts([...others, key], widths)
    const start = slots[order.indexOf(key)] - event.clientX
    let latest: Drag | null = null
    stop.current?.()
    stop.current = bindDrag(event.currentTarget, event.nativeEvent, {
      threshold: dragThreshold,
      cancelOnEscape: true,
      onMove: ({ x, event }) => {
        const { value: left } = scrubTo(
          start + x,
          {
            min: 0,
            max: slots[slots.length - 1],
            step: 1,
            snap: { points: slots, threshold: 12 },
          },
          { snap: !(event.metaKey || event.ctrlKey) }
        )
        latest = { key, left, target: nearest(slots, left), others, widths }
        setDrag(latest)
      },
      onEnd: (_end, canceled) => {
        stop.current = undefined
        if (latest && !canceled) {
          setOrder(insert(latest.others, latest.key, latest.target))
        }
        setDrag(null)
      },
    })
  }

  const keydown = (event: React.KeyboardEvent<HTMLButtonElement>, key: Key) => {
    const move = stepFromKey(event, { arrows: "horizontal" })
    if (!move) {
      return
    }
    event.preventDefault()
    const index = stepBy(order.indexOf(key), move, {
      min: 0,
      max: order.length - 1,
      step: 1,
    })
    if (event.altKey) {
      setOrder(
        insert(
          order.filter((candidate) => candidate !== key),
          key,
          index
        )
      )
      return
    }
    setSelected(order[index])
    elements.current.get(order[index])?.focus()
  }

  const shown = drag ? insert(drag.others, drag.key, drag.target) : order
  const placed = drag ? lefts(shown, drag.widths) : []
  return (
    <div data-slot="stack">
      <div
        role="tablist"
        aria-label="Mailboxes"
        data-slot="drag-tabs-list"
        data-dragging={drag ? "" : undefined}
        style={
          drag
            ? ({
                "--drag-tabs-width": `${placed[placed.length - 1] + drag.widths[shown[shown.length - 1]] + 4}px`,
              } as React.CSSProperties)
            : undefined
        }
      >
        {shown.map((key, index) => (
          <button
            key={key}
            ref={(el) => {
              if (el) {
                elements.current.set(key, el)
              } else {
                elements.current.delete(key)
              }
            }}
            type="button"
            role="tab"
            id={`mailbox-${key}`}
            data-slot="drag-tabs-tab"
            aria-selected={key === selected}
            aria-controls="mailbox-panel"
            tabIndex={key === selected ? 0 : -1}
            data-moving={drag?.key === key || undefined}
            onPointerDown={(event) => pointerdown(event, key)}
            onKeyDown={(event) => keydown(event, key)}
            onClick={() => setSelected(key)}
            style={
              drag
                ? ({
                    "--drag-tabs-left": `${(drag.key === key ? drag.left : placed[index]) + 2}px`,
                  } as React.CSSProperties)
                : undefined
            }
          >
            {tabs[key]}
          </button>
        ))}
      </div>
      <div
        role="tabpanel"
        id="mailbox-panel"
        data-slot="drag-tabs-panel"
        aria-labelledby={`mailbox-${selected}`}
      >
        <h3>{tabs[selected]}</h3>
        <span data-slot="hint">{counts[selected]}</span>
      </div>
    </div>
  )
}

export { Example }
Drag a tab along the bar: it snaps into each place as it nears it, and the others make room. Hold Command or Ctrl to place it freely, or press Escape to cancel. With the keyboard, Option or Alt with an arrow moves the focused tab.

Hovering, and What's Read Out

For a hover preview, use show and clear on enter and leave, then choose on click:

pointerenter: (event, { api, key }) => api.preview.show(key, { reason: "item-hover", event }),
pointerleave: (event, { api }) => api.preview.clear({ reason: "item-hover", event }),
click: (event, { api, key }) => api.preview.choose(key, { event }),
import * as React from "react"
import {
  createChange,
  createPreview,
  createRovingFocus,
  definePrimitive,
  selects,
} from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"

type ThemesState = {
  value: string
  preview: string | null
  focused: string | null
}

const names: Record<string, string> = {
  light: "Light",
  dark: "Dark",
  sepia: "Sepia",
  contrast: "Contrast",
}

const themes = definePrimitive({
  name: "themes",
  state: (): ThemesState => ({ value: "light", preview: null, focused: null }),
  api: (context) => {
    const { store } = context
    const change = createChange(context)
    return {
      roving: createRovingFocus<ThemesState, { value: string }>(context, {
        slot: "theme",
        key: "focused",
      }),
      preview: createPreview<ThemesState>(context, {
        key: "preview",
        slot: "theme",
        value: () => store.get().value,
        choose: (key, details) => change("value", key, details),
        disabled: (key) => key === "contrast",
        label: (key) => `the ${names[key]} theme`,
        message: (label) =>
          `Trying ${label}. Enter keeps it, Escape puts it back.`,
      }),
    }
  },
  slots: (slot) => ({
    root: slot<{}, { shown: string }>({
      state: (state) => ({ shown: state.preview ?? state.value }),
    }),
    list: slot({
      attrs: () => ({ role: "radiogroup" }),
      on: {
        focusout: (event, { el, api }) => {
          if (!el?.contains(event.relatedTarget as Node)) {
            api.preview.clear({ reason: "focus-out", event })
          }
        },
      },
    }),
    theme: slot<{ value: string }>({
      props: { value: "string" },
      selects: selects({ focused: ({ key }) => [key, null] }),
      attrs: (state, part) => ({
        type: "button",
        role: "radio",
        "aria-checked": String(state.value === part.key),
        "aria-disabled": part.key === "contrast" ? "true" : undefined,
        tabindex: part.api.roving.tabIndex(state, part),
        "data-previewing": state.preview === part.key ? "" : undefined,
      }),
      on: {
        pointerenter: (event, { api, key }) =>
          api.preview.show(key, { reason: "item-hover", event }),
        pointerleave: (event, { api }) =>
          api.preview.clear({ reason: "item-hover", event }),
        click: (event, { api, key }) => api.preview.choose(key, { event }),
        keydown: (event, { el, api, key }) => {
          if (api.preview.keydown(event, key)) {
            return
          }
          const moved = api.roving.keydown(event, el!)
          if (moved) {
            api.preview.show(moved.key, { reason: "list-navigation", event })
          }
        },
      },
      bind: (el, part) => part.api.roving.bind(el, part),
    }),
  }),
})

const parts = createParts(themes, { root: "div", list: "div", theme: "button" })

const Themes = parts.Root

const ThemesList = parts.List

const ThemesOption = parts.Theme

const looks: Record<string, { paper: string; text: string }> = {
  light: { paper: "#ffffff", text: "#171717" },
  dark: { paper: "#171717", text: "#f5f5f5" },
  sepia: { paper: "#f4ecd8", text: "#5b4636" },
  contrast: { paper: "#000000", text: "#fde047" },
}

function Example() {
  return (
    <Themes>
      {({ shown }) => (
        <>
          <p
            data-slot="preview-sample"
            style={
              {
                "--preview-paper": looks[shown].paper,
                "--preview-text": looks[shown].text,
              } as React.CSSProperties
            }
          >
            The quick brown fox jumps over the lazy dog.
          </p>
          <ThemesList aria-label="Theme">
            {Object.keys(names).map((theme) => (
              <ThemesOption key={theme} value={theme}>
                {names[theme]}
                {theme === "contrast" && <span data-slot="hint">Soon</span>}
              </ThemesOption>
            ))}
          </ThemesList>
        </>
      )}
    </Themes>
  )
}

export { Example }
Hover over a theme to try it and click to keep it. Arrows preview too; Enter chooses and Escape clears. Contrast is disabled.

disabled(key) prevents previewing or choosing an option. label(key) and message(label) customize announcements for previews with reason list-navigation; pointer previews don't announce every pass over an option.

API Reference

The option tables link back to the examples that introduce them.

keykeyof State & string
The state key that holds every item's position, a Record<string, MovePoint>.
Example: Place Anything
draftkeyof State & string
The state key that holds the MoveDraft while a pointer drags. Start it at null, and list it in live.
Example: Place Anything
set(value: Record<string, MovePoint>, details: { reason: MoveReason; event?: Event; }) => boolean
Asks for new positions and returns whether they went through. Pass the primitive's change: set: (value, details) => change("positions", value, details).
Example: Place Anything
group?((key: string) => string[])
The keys that move together with the one pressed, like the selection when it holds it. Defaults to that one key.
Example: Several at Once
step?(() => number)
The grid a dropped position lands on, and how far Alt with an arrow nudges. Defaults to 1.
Example: Several at Once
largeStep?(() => number)
How far Alt with Shift and an arrow nudges. Defaults to ten steps.
Example: Place Anything
scale?(() => number)
How many screen pixels one unit of position takes, like a canvas's zoom, so a drag follows the pointer when the items are scaled. Defaults to 1.
Example: Zoom and Moving from Code
bounds?((key: string) => { minX: number; minY: number; maxX: number; maxY: number; } | undefined)
Keeps positions inside a box, like the board's size minus a card's.
Example: Place Anything
disabled?((key: string) => boolean)
Whether an item can't be moved.
Example: Several at Once
label?((key: string) => string | undefined)
The name screen readers hear for an item.
Example: Several at Once
message?((message: MoveMessage) => string)
The text screen readers hear after a move. Defaults to English.
Example: Several at Once
boxes(state: State) => Record<string, ResizeBox>
Every box in state, by key, like (state) => state.boxes or (state) => ({ crop: state.crop }).
Example: Handles on a Box
draftkeyof State & string
The state key that holds the ResizeDraft while a pointer drags. Start it at null, and list it in live.
Example: Handles on a Box
set(value: Record<string, ResizeBox>, details: { reason: ResizeReason; event?: Event; }) => boolean
Asks for new boxes and returns whether they went through. Pass the primitive's change: set: (value, details) => change("boxes", value, details), or change("crop", value.crop, details) for a primitive with one box.
Example: Handles on a Box
min?((key: string) => { width: number; height: number; })
The smallest a box gets. Defaults to 1 by 1.
Example: Handles on a Box
max?((key: string) => { width: number; height: number; } | undefined)
The largest a box gets.
Example: Keeping a Shape
bounds?((key: string) => { left: number; top: number; right: number; bottom: number; } | undefined)
Keeps a box inside an area, like the image a crop sits on.
Example: Handles on a Box
aspect?((key: string) => number | undefined)
Keeps width over height at this ratio, like 16 / 9. Without one, Shift on a corner keeps the ratio the box had when the drag started.
Example: Keeping a Shape
step?(() => number)
The grid a dropped box lands on, and how far an arrow moves an edge. Defaults to 1.
Example: Handles on a Box
largeStep?(() => number)
How far Shift with an arrow moves an edge. Defaults to ten steps.
Example: Keeping a Shape
scale?(() => number)
How many screen pixels one unit takes, like a canvas's zoom. Defaults to 1.
Example: Several Boxes, and from Code
disabled?((key: string) => boolean)
Whether a box can't be changed.
Example: Several Boxes, and from Code
label?((key: string) => string | undefined)
The name screen readers hear for a box.
Example: Several Boxes, and from Code
message?((message: ResizeMessage) => string)
The text screen readers hear after a change. Defaults to English.
Example: Several Boxes, and from Code
keykeyof State & string
The state key that holds the SwipeOpen row, or null.
Example: Actions Behind a Row
draftkeyof State & string
The state key that holds the SwipeDraft while a pointer swipes. Start it at null, and list it in live.
Example: Actions Behind a Row
onFullSwipe?((key: string, side: SwipeSide, details: { event: Event; }) => boolean)
Runs a side's first action when a swipe goes past fullSwipe of the row's width, and returns whether it did. Without it, a long swipe only opens the row.
Example: Full Swipes
fullSwipe?(() => number)
How far across the row a swipe runs the first action, as a fraction of its width.
Example: Full Swipes
disabled?((key: string) => boolean)
Whether a row can't be swiped.
Example: Full Swipes
delay?number
How long a press lasts before it's held, in milliseconds.
Example: Hold to Confirm
repeat?number
Runs onHold again this often while held, in milliseconds, like a stepper's button. Without it, onHold runs once.
Example: Repeat While Held
tolerance?number
How far a pointer can move before it's held, in pixels, before the press is canceled, like when a finger starts to scroll.
Example: Hold to Confirm
keys?string[]
The keys that press it while it has focus. A short press clicks it, as a pointer's does. An empty list leaves keys alone.
Example: Repeat While Held
disabled?(() => boolean)
Whether a press can't start, and a held one stops repeating.
Example: Repeat While Held
onPress?((details: HoldDetails) => boolean | void)
Runs as a press starts. Return false to ignore it, like a mouse press where only touch holds.
Example: Repeat While Held
onHold?((details: HoldDetails & { count: number; }) => boolean | void)
Runs once the press is held, then every repeat milliseconds. Return false to stop repeating, like at a bound; the press goes on until it ends.
Example: Repeat While Held
onEnd?((details: HoldEndDetails) => void)
Runs once when the press ends, released or canceled.
Example: Hold to Confirm
delay?number
How long a press lasts before it starts repeating, in milliseconds.
Example: Repeat While Held
repeat?number
How often it steps again while held, in milliseconds.
Example: Repeat While Held
keys?string[]
The keys that press it while it has focus. An empty list leaves keys to the browser, which clicks a button once per key press.
Example: Repeat While Held
disabled?(() => boolean)
Whether a press can't start, and a held one stops repeating.
Example: Repeat While Held
onPress?((details: HoldDetails) => boolean | void)
Runs as a press starts. Return false to ignore it.
Example: Repeat While Held
onStep?((details: RepeatDetails) => boolean | void)
Runs once for each press, then every repeat milliseconds while it's held. Return false to stop repeating, like at a bound.
Example: Repeat While Held
onEnd?((details: RepeatEndDetails) => void)
Runs once when a press ends, released or canceled, and after a click's step.
Example: Repeat While Held
value(state: State) => AreaValue
Reads the point from state, like (state) => state.value, or one made from other state, like a color's saturation and brightness.
Example: A Point on a Pad
set(value: AreaValue, details: { reason: AreaReason; event?: Event; }) => boolean
Asks for a new value and returns whether it went through. Pass the primitive's change: set: (value, details) => change("value", value, details).
Example: A Point on a Pad
x?(() => AreaAxis)
The x axis, left to right.
Example: A Point on a Pad
y?(() => AreaAxis)
The y axis, bottom to top, like a chart: a higher value sits higher.
Example: A Point on a Pad
valueText?((value: AreaValue) => string)
The text screen readers hear for the value. Defaults to each axis's label and percentage.
Example: What's Read Out, and When It Settles
onCommit?((value: AreaValue, details: { reason: AreaReason; event?: Event; }) => void)
Runs when a press or a drag ends, or a key changed the value, with the value it ended on, like Slider's onValueCommit.
Example: What's Read Out, and When It Settles
disabled?(() => boolean)
Whether it can't change.
Example: What's Read Out, and When It Settles
keykeyof State & string
The state key that holds the spans, an array of Span.
Example: Bookings on a Timeline
draftkeyof State & string
The state key that holds the SpansDraft while a pointer drags. Start it at null.
Example: Bookings on a Timeline
set(value: S[], details: { reason: SpansReason; event?: Event; id: string; action: SpanAction; }) => boolean
Asks for the new spans and returns whether they went through. Pass the primitive's change: set: (value, details) => change("spans", value, details).
Example: Bookings on a Timeline
min?(() => number)
Where tracks start, in their units.
Example: Bookings on a Timeline
max?(() => number)
Where tracks end.
Example: Bookings on a Timeline
step?(() => number)
What every start and end snaps to, and what arrows move by.
Example: Bookings on a Timeline
minSize?(() => number)
The shortest a span can be.
Example: Days That Run Down
maxSize?(() => number)
The longest a span can be.
Example: Days That Run Down
axis?"x" | "y"
The direction tracks run: y down, like a calendar's days, or x across, like a Gantt chart's rows.
Example: Bookings on a Timeline
tracks?(() => string[])
The tracks in the order the arrows across them go through. Defaults to the bound tracks in document order.
Example: Days That Run Down
overlap?boolean
Whether spans on one track can overlap. When they can't, a drag stops where it would.
Example: Bookings on a Timeline
create?((span: Span) => S | null)
Makes the span a press on a track starts to draw, with a new id and anything else it needs, like a title, or returns null to not draw. Runs on every press on an empty stretch of track. Without it, nothing is drawn.
Example: Bookings on a Timeline
size?(() => number | undefined)
How long a span a click on an empty track makes. Without it, a click makes none.
Example: Days That Run Down
disabled?((span: S) => boolean)
Whether a span can't change.
Example: Changing Ranges from Code
label?((span: S) => string)
What screen readers call a span, like "Standup, Tuesday 9:00 to 9:30". Defaults to its track and range.
Example: Bookings on a Timeline
message?((message: SpansMessage<S>) => string)
The text screen readers hear for each change. Defaults to English.
Example: Changing Ranges from Code
createAutoScroll?false | Omit<AutoScrollOptions, "ignore" | "onScroll">
How a drag near a scroller's edge scrolls it, or false not to.
Example: Days That Run Down
keykeyof State & string
The state key that holds the previewed option's key, or null.
Example: Press and Slide
slotstring
The slot whose parts are the options.
Example: Press and Slide
set?((value: string | null, details: { reason: PreviewReason; event?: Event; }) => boolean)
Asks for a new preview. Defaults to setting the state key; pass the primitive's change to fire onPreviewChange.
Example: Press and Slide
choose(key: string, details: { reason: "item-press"; event?: Event; }) => boolean
Chooses an option. Pass the primitive's change for its value.
Example: Press and Slide
value?(() => string | null)
The chosen option now, which previews as nothing.
Example: Press and Slide
disabled?((key: string) => boolean)
Whether an option can't be previewed or chosen.
Example: Hovering, and What's Read Out
label?((key: string) => string)
An option's name for screen readers. Defaults to its key.
Example: Hovering, and What's Read Out
message?((label: string) => string)
What screen readers hear when the keyboard previews one. Defaults to English.
Example: Hovering, and What's Read Out