Dragging and Resizing
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.
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 }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.
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 }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)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 }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.
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 }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,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 }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 }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 }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.
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.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 }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;
}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>
)
}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>
)
}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.
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 }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,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 }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 }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 },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 }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.
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 }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 })
}
},
},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 }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 }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 }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
MoveDraftwhile a pointer drags. Start it atnull, and list it inlive. - 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
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.boxesor(state) => ({ crop: state.crop }). - Example: Handles on a Box
draftkeyof State & string- The state key that holds the
ResizeDraftwhile a pointer drags. Start it atnull, and list it inlive. - 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), orchange("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
SwipeOpenrow, ornull. - Example: Actions Behind a Row
draftkeyof State & string- The state key that holds the
SwipeDraftwhile a pointer swipes. Start it atnull, and list it inlive. - 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
fullSwipeof 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
repeat?number- Runs
onHoldagain this often while held, in milliseconds, like a stepper's button. Without it,onHoldruns 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
falseto 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
repeatmilliseconds. Returnfalseto 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
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
falseto ignore it. - Example: Repeat While Held
onStep?((details: RepeatDetails) => boolean | void)- Runs once for each press, then every
repeatmilliseconds while it's held. Returnfalseto 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
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
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
SpansDraftwhile a pointer drags. Start it atnull. - 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
step?(() => number)- What every start and end snaps to, and what arrows move by.
- Example: Bookings on a Timeline
axis?"x" | "y"- The direction tracks run:
ydown, like a calendar's days, orxacross, 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
idand anything else it needs, like a title, or returnsnullto 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
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
falsenot 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
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