Run your CSS transitions as panels come and go, keep them present until they finish, and turn pointer movement into gestures.

Motion here follows state. When state changes, your CSS transitions run, and an element stays in the page until its animation finishes. Someone who prefers reduced motion gets the end at once. bindPanel manages panel transitions, and animateIn and animateOut apply the states for CSS entry and exit animations.

Import these helpers from @ui-sdk/core. The functions in @ui-sdk/core/gesture calculate velocity, spring motion, resistance, and direction. You choose how to use those calculations in your input handlers and animation loop.

To slide items into new places, use CSS, View Transitions or a motion library.

Panels and Presence

Open and Close

bindPanel coordinates a panel's height, opening and closing styles, and focus. Your state tells it whether the panel is open or still exiting:

present: (state, { key }) => state.expanded.includes(key) || state.exiting.includes(key),

Keep a closing panel present until its transition finishes. Inside its bind, connect those values to bindPanel and return the cleanup:

bind: (el, { api, key, store }) =>
  bindPanel(el, {
    size: (el, height) => el.style.setProperty("--panel-height", height),
    isOpen: () => store.get().expanded.includes(key),
    isExiting: () => store.get().exiting.includes(key),
    isFindable: () => false,
    opening: () => "animate",
    reveal: () => api.toggle(key),
    trigger: () => el.previousElementSibling as HTMLElement | undefined,
    subscribe: store.subscribe,
    settled: () => {},
    exited: () => store.set({ exiting: store.get().exiting.filter((item) => item !== key) }),
  }),

Import bindPanel from @ui-sdk/core. The trigger in this example sits immediately before its panel. exited removes the key from exiting, allowing the panel to unmount.

import * as React from "react"
import { bindPanel, definePrimitive } from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"

type FaqState = {
  expanded: string[]
  exiting: string[]
}

const without = (list: string[], value: string) =>
  list.filter((other) => other !== value)

const faq = definePrimitive({
  name: "faq",
  state: (): FaqState => ({ expanded: [], exiting: [] }),
  api: (context) => {
    const { store } = context
    return {
      toggle(value: string) {
        const { expanded, exiting } = store.get()
        if (expanded.includes(value)) {
          store.set({
            expanded: without(expanded, value),
            exiting: [...exiting, value],
          })
        } else {
          store.set({
            expanded: [...expanded, value],
            exiting: without(exiting, value),
          })
        }
      },
    }
  },
  slots: (slot) => ({
    root: slot({}),
    trigger: slot<{ value: string }>({
      props: { value: "string" },
      attrs: (state, { id, key }) => ({
        type: "button",
        "aria-expanded": String(state.expanded.includes(key)),
        "aria-controls": `${id}-${key}`,
      }),
      press: ({ api, key }) => api.toggle(key),
    }),
    panel: slot<{ value: string }>({
      props: { value: "string" },
      present: (state, { key }) =>
        state.expanded.includes(key) || state.exiting.includes(key),
      attrs: (_state, { id, key }) => ({ id: `${id}-${key}`, role: "region" }),
      bind: (el, { api, key, store }) =>
        bindPanel(el, {
          size: (el, height) => el.style.setProperty("--panel-height", height),
          isOpen: () => store.get().expanded.includes(key),
          isExiting: () => store.get().exiting.includes(key),
          isFindable: () => false,
          opening: () => "animate",
          reveal: () => api.toggle(key),
          trigger: () => el.previousElementSibling as HTMLElement | undefined,
          subscribe: store.subscribe,
          settled: () => {},
          exited: () =>
            store.set({ exiting: without(store.get().exiting, key) }),
        }),
    }),
  }),
})

const parts = createParts(faq, { root: "div", trigger: "button", panel: "div" })

const Faq = parts.Root

const FaqTrigger = parts.Trigger

const FaqPanel = parts.Panel

const questions = [
  {
    value: "refunds",
    question: "Can I get a refund?",
    answer: "Yes, within 30 days of buying, no questions asked.",
  },
  {
    value: "seats",
    question: "Can I add seats later?",
    answer: "Any time. You pay for the rest of the month only.",
  },
  {
    value: "export",
    question: "Can I export my data?",
    answer: "Everything exports to CSV or JSON from Settings.",
  },
]

function Example() {
  return (
    <div data-slot="panel-frame">
      <Faq>
        {questions.map((item) => (
          <div key={item.value}>
            <FaqTrigger value={item.value}>{item.question}</FaqTrigger>
            <FaqPanel value={item.value}>
              <p>{item.answer}</p>
            </FaqPanel>
          </div>
        ))}
      </Faq>
    </div>
  )
}

export { Example, faq, Faq, FaqPanel, FaqTrigger, type FaqState }
Open a question, then close it. The answer stays mounted while its height transitions to zero.

Use the attributes it sets to write the transition:

[data-slot="faq-panel"] {
  height: var(--panel-height);
  overflow: hidden;
  transition: height 200ms ease-out;
}
[data-slot="faq-panel"][data-starting-style],
[data-slot="faq-panel"][data-ending-style] {
  height: 0;
}
@media (prefers-reduced-motion: reduce) {
  [data-slot="faq-panel"] {
    transition: none;
  }
}

size receives a measured height during the transition and auto once open. If focus is inside a closing panel, trigger() provides the element to focus instead.

Find in Page, and Opening at Once

A closed panel can stay searchable with hidden="until-found". Keep it mounted and use hiddenAttrs for its visibility:

attrs: (state, { key }) => hiddenAttrs(state.expanded.includes(key), true),

Import hiddenAttrs from @ui-sdk/core. In the panel binding, enable find in page and open the matching section in reveal:

isFindable: () => true,
reveal: () => api.show([key]),
opening: () => store.get().opening,
Console
import * as React from "react"
import { bindPanel, definePrimitive, hiddenAttrs } from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"
import { useLog } from "./log"

type NotesState = {
  expanded: string[]
  exiting: string[]
  opening: "mount" | "instant" | "animate"
}

type NotesOptions = { onSettled?: (value: string, open: boolean) => void }

const without = (list: string[], value: string) =>
  list.filter((other) => other !== value)

const notes = definePrimitive({
  name: "notes",
  options: { onSettled: "callback" },
  state: (_options: NotesOptions): NotesState => ({
    expanded: ["3.0"],
    exiting: [],
    opening: "mount",
  }),
  api: (context) => {
    const { store } = context
    return {
      settle: (value: string, open: boolean) =>
        context.options().onSettled?.(value, open),
      toggle(value: string) {
        const { expanded, exiting } = store.get()
        if (expanded.includes(value)) {
          store.set({
            opening: "animate",
            expanded: without(expanded, value),
            exiting: [...exiting, value],
          })
        } else {
          store.set({
            opening: "animate",
            expanded: [...expanded, value],
            exiting: without(exiting, value),
          })
        }
      },
      show(values: string[]) {
        const { expanded } = store.get()
        store.set({
          opening: "instant",
          expanded: [
            ...expanded,
            ...values.filter((v) => !expanded.includes(v)),
          ],
        })
      },
    }
  },
  slots: (slot) => ({
    root: slot({}),
    trigger: slot<{ value: string }>({
      props: { value: "string" },
      attrs: (state, { id, key }) => ({
        type: "button",
        "aria-expanded": String(state.expanded.includes(key)),
        "aria-controls": `${id}-${key}`,
      }),
      press: ({ api, key }) => api.toggle(key),
    }),
    panel: slot<{ value: string }>({
      props: { value: "string" },
      attrs: (state, { id, key }) => ({
        id: `${id}-${key}`,
        role: "region",
        ...hiddenAttrs(
          state.expanded.includes(key) || state.exiting.includes(key),
          true
        ),
      }),
      bind: (el, { api, key, store }) =>
        bindPanel(el, {
          size: (el, height) => el.style.setProperty("--panel-height", height),
          isOpen: () => store.get().expanded.includes(key),
          isExiting: () => store.get().exiting.includes(key),
          isFindable: () => true,
          opening: () => store.get().opening,
          reveal: () => api.show([key]),
          trigger: () => el.previousElementSibling as HTMLElement | undefined,
          subscribe: store.subscribe,
          settled: (open) => api.settle(key, open),
          exited: () =>
            store.set({ exiting: without(store.get().exiting, key) }),
        }),
    }),
    expand: slot<{ value: string }>({
      props: { value: "string" },
      attrs: () => ({ type: "button" }),
      press: ({ api, key }) => api.show(key.split(" ")),
    }),
  }),
})

const parts = createParts(notes, {
  root: "div",
  trigger: "button",
  panel: "div",
  expand: "button",
})

const Notes = parts.Root

const NotesTrigger = parts.Trigger

const NotesPanel = parts.Panel

const NotesExpand = parts.Expand

const releases = [
  {
    value: "3.0",
    notes:
      "Dark mode follows your system, and the sidebar remembers its width.",
  },
  {
    value: "2.4",
    notes: "Drafts save while you're offline and sync once you're back.",
  },
  { value: "2.3", notes: "Export a board as a PDF, with one page per column." },
]

function Example() {
  const log = useLog()
  return (
    <Notes
      onSettled={(value, open) =>
        log(`Version ${value} ${open ? "opened" : "closed"}`)
      }
    >
      <div data-slot="controls">
        <NotesExpand
          value={releases.map((release) => release.value).join(" ")}
          data-size="sm"
        >
          Open All
        </NotesExpand>
      </div>
      <div data-slot="panel-list">
        {releases.map((release) => (
          <div key={release.value}>
            <NotesTrigger value={release.value}>
              Version {release.value}
            </NotesTrigger>
            <NotesPanel value={release.value}>
              <p>{release.notes}</p>
            </NotesPanel>
          </div>
        ))}
      </div>
    </Notes>
  )
}

export {
  Example,
  notes,
  Notes,
  NotesExpand,
  type NotesOptions,
  NotesPanel,
  NotesTrigger,
  type NotesState,
}
Use your browser's find command to search for offline. In a supporting browser, its closed section opens. Open All reveals the remaining sections immediately.

opening() returns "mount" for an initially open panel, "instant" to skip its opening animation, or "animate". settled(open) reports completed opens and closes, including instant changes, but does not run for the initial "mount".

Find in Page on Its Own

For hidden content without transitions, use bindFindable with hiddenAttrs. It restores hidden="until-found" when needed and calls your reveal callback on a match:

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

const findable = bindFindable(el, {
  shown: () => store.get().shown,
  findable: () => true,
  reveal: () => api.show(),
})
const unsubscribe = store.subscribe(findable.check)

Inside a slot's bind, return a cleanup that calls both unsubscribe() and findable().

import * as React from "react"
import {
  bindFindable,
  definePrimitive,
  hiddenAttrs,
  supportsHiddenUntilFound,
} from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"

type RecipeState = { shown: boolean }

const recipe = definePrimitive({
  name: "recipe",
  state: (): RecipeState => ({ shown: false }),
  api: (context) => ({
    toggle: () => context.store.set({ shown: !context.store.get().shown }),
    show: () => context.store.set({ shown: true }),
  }),
  slots: (slot) => ({
    root: slot<{}, { shown: boolean; findable: boolean }>({
      state: (state) => ({
        shown: state.shown,
        findable: supportsHiddenUntilFound(),
      }),
    }),
    toggle: slot({
      attrs: (state, { id }) => ({
        type: "button",
        "aria-expanded": String(state.shown),
        "aria-controls": `${id}-method`,
      }),
      press: ({ api }) => api.toggle(),
    }),
    method: slot({
      attrs: (state, { id }) => ({
        id: `${id}-method`,
        ...hiddenAttrs(state.shown, true),
      }),
      bind: (el, { api, store }) => {
        const findable = bindFindable(el, {
          shown: () => store.get().shown,
          findable: () => true,
          reveal: () => api.show(),
        })
        const unsubscribe = store.subscribe(findable.check)
        return () => {
          unsubscribe()
          findable()
        }
      },
    }),
  }),
})

const parts = createParts(recipe, {
  root: "div",
  toggle: "button",
  method: "div",
})

const Recipe = parts.Root

const RecipeToggle = parts.Toggle

const RecipeMethod = parts.Method

function Example() {
  return (
    <Recipe>
      {({ shown, findable }) => (
        <>
          <p data-slot="label">Tomato soup</p>
          <RecipeToggle data-size="sm">
            {shown ? "Hide Method" : "Show Method"}
          </RecipeToggle>
          <div data-slot="findable-method">
            <RecipeMethod>
              Soften the onion in butter, add the tomatoes and stock, and simmer
              for twenty minutes. Blend until smooth.
            </RecipeMethod>
          </div>
          <p data-findable data-slot="hint">
            {findable
              ? "Find in page can show the method."
              : "This browser can't find hidden text."}
          </p>
        </>
      )}
    </Recipe>
  )
}

export { Example, recipe, Recipe, RecipeMethod, type RecipeState, RecipeToggle }
Search the page for simmer to reveal the hidden method in a supporting browser. Show Method and Hide Method work without browser search.

supportsHiddenUntilFound() tells you whether the browser supports this feature. Provide a visible control for revealing the content either way.

Entering and Leaving

For an element that simply appears and disappears, use animateIn and animateOut:

import { animateIn, animateOut } from "@ui-sdk/core"

const stopEntering = animateIn(el)

When it should leave, stop the pending entrance and begin the exit. Remove the element in the completion callback:

stopEntering()
const stopExiting = animateOut(el, () => removeToast(key))
import * as React from "react"
import { XIcon } from "lucide-react"
import { animateIn, animateOut, definePrimitive } from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"

type ToastsState = {
  toasts: string[]
  leaving: string[]
}

const toasts = definePrimitive({
  name: "toasts",
  state: (): ToastsState => ({ toasts: [], leaving: [] }),
  api: (context) => {
    const { store } = context
    let count = 0
    return {
      add: () =>
        store.set({ toasts: [...store.get().toasts, `toast-${++count}`] }),
      dismiss(key: string) {
        if (!store.get().leaving.includes(key)) {
          store.set({ leaving: [...store.get().leaving, key] })
        }
      },
      remove(key: string) {
        const { toasts, leaving } = store.get()
        store.set({
          toasts: toasts.filter((k) => k !== key),
          leaving: leaving.filter((k) => k !== key),
        })
      },
    }
  },
  slots: (slot) => ({
    root: slot<{}, { toasts: string[] }>({
      state: (state) => ({ toasts: state.toasts }),
    }),
    add: slot({
      attrs: () => ({ type: "button" }),
      press: ({ api }) => api.add(),
    }),
    toast: slot<{ value: string }>({
      props: { value: "string" },
      attrs: () => ({ role: "status" }),
      bind: (el, { api, key, store }) => {
        let stop = animateIn(el)
        const unsubscribe = store.subscribe(() => {
          if (
            !store.get().leaving.includes(key) ||
            el.hasAttribute("data-ending-style")
          ) {
            return
          }
          stop()
          stop = animateOut(el, () => api.remove(key))
        })
        return () => {
          unsubscribe()
          stop()
        }
      },
    }),
    dismiss: slot<{ value: string }>({
      props: { value: "string" },
      attrs: () => ({ type: "button", "aria-label": "Dismiss" }),
      press: ({ api, key }) => api.dismiss(key),
    }),
  }),
})

const parts = createParts(toasts, {
  root: "div",
  add: "button",
  toast: "div",
  dismiss: "button",
})

const Toasts = parts.Root

const ToastsAdd = parts.Add

const Toast = parts.Toast

const ToastDismiss = parts.Dismiss

function Example() {
  return (
    <Toasts>
      {({ toasts }) => (
        <>
          <ToastsAdd data-size="sm">Save</ToastsAdd>
          <div data-slot="scroll">
            {toasts.map((key) => (
              <Toast key={key} value={key}>
                Saved
                <ToastDismiss value={key}>
                  <XIcon aria-hidden="true" />
                </ToastDismiss>
              </Toast>
            ))}
          </div>
        </>
      )}
    </Toasts>
  )
}

export {
  Example,
  Toast,
  ToastDismiss,
  toasts,
  Toasts,
  ToastsAdd,
  type ToastsState,
}
Press Save to add a message. Press its Dismiss button; it leaves the page after fading out.

Style data-starting-style and data-ending-style with CSS. The exit waits for running animations, with a timeout so a stuck animation can't keep the element mounted forever. Both helpers return cleanup functions; call the current one when the element unmounts. Add a reduced-motion rule to your CSS, as in the panel example.

Options

size(el: HTMLElement, height: string, width: string) => void
Writes the panel's size variables, like --accordion-panel-height: the measured size while animating, auto once open and 0px while closed.
Example: Open and Close
isOpen() => boolean
Whether the panel is open now.
Example: Open and Close
isExiting() => boolean
Whether a closed panel is still shown while it animates out. Keep the panel present while it is.
Example: Open and Close
isFindable() => boolean
Whether a closed panel stays in the page, findable with hidden="until-found".
Example: Find in Page, and Opening at Once
opening() => "mount" | "instant" | "animate"
How the panel opens: as it mounts with the page, at once, or animated.
Example: Find in Page, and Opening at Once
reveal(event: Event) => void
Find in page matched text in the closed panel. Open it here.
Example: Find in Page, and Opening at Once
trigger() => HTMLElement
Where focus goes when the panel closes with focus inside it, like its trigger.
Example: Open and Close
subscribe(listener: () => void) => () => void
Runs listener when the panel's state may have changed, like store.subscribe. Returns the unsubscribe.
Example: Open and Close
settled(open: boolean) => void
The open or close animation finished, or there was none. Never on mount.
Example: Find in Page, and Opening at Once
exited() => void
The exit animation finished, so the panel can leave the page: stop counting it as exiting here.
Example: Open and Close

Gestures

The functions in @ui-sdk/core/gesture help interpret pointer movement. Your component still handles input, state and animation frames.

Throws and Springs

Record positions while the pointer moves, then read its velocity on release:

import { createVelocityTracker, projectDistance } from "@ui-sdk/core/gesture"

const tracker = createVelocityTracker()
tracker.add(event.timeStamp, x)

velocity(time) returns pixels per millisecond. projectDistance estimates how far that speed would carry an object as it slows down:

const speed = tracker.velocity(release.timeStamp)
const target = x + projectDistance(speed, 0.994)
import * as React from "react"
import {
  bindDrag,
  createChange,
  css,
  definePrimitive,
  dragThreshold,
  isRTL,
  live,
  type ChangeDetails,
} from "@ui-sdk/core"
import {
  createVelocityTracker,
  projectDistance,
  solveSpring,
} from "@ui-sdk/core/gesture"
import { createParts } from "@ui-sdk/react/primitive"

const speeds = ["Off", "Low", "Medium", "High"]
const last = speeds.length - 1

type FanState = {
  speed: number
  offset: number | null
}

type FanOptions = {
  defaultSpeed?: number
  onSpeedChange?: (
    speed: number,
    details: ChangeDetails<"drag" | "step-key">
  ) => void
}

const clamp = (value: number, min: number, max: number) =>
  Math.min(max, Math.max(min, value))

const fan = definePrimitive({
  name: "fan",
  options: { defaultSpeed: "number", onSpeedChange: "callback" },
  state: (options: FanOptions): FanState => ({
    speed: options.defaultSpeed ?? 0,
    offset: null,
  }),
  live: ["offset"],
  api: (context) => {
    const { store } = context
    const change = createChange(context)
    let stop: (() => void) | undefined
    let frame = 0
    const settle = () => {
      cancelAnimationFrame(frame)
      store.set({ offset: null })
    }
    context.onDestroy(() => {
      stop?.()
      cancelAnimationFrame(frame)
    })
    return {
      pointerdown(event: PointerEvent, thumb: HTMLElement) {
        const track = thumb.parentElement
        if (event.button !== 0 || !track) {
          return
        }
        settle()
        const span =
          (track.clientWidth - thumb.offsetWidth) * (isRTL(track) ? -1 : 1)
        const from = store.get().speed / last
        const velocity = createVelocityTracker()
        stop = bindDrag(thumb, event, {
          threshold: dragThreshold,
          cancelOnEscape: true,
          onMove: ({ x, event: move }) => {
            velocity.add(move.timeStamp, x)
            store.set({
              offset: clamp(from + (x - event.clientX) / span, 0, 1),
            })
          },
          onEnd: (end, canceled) => {
            stop = undefined
            const at = store.get().offset
            if (canceled || at === null) {
              return settle()
            }
            const v = velocity.velocity(end.timeStamp) / span
            const target = Math.round(
              clamp(at + projectDistance(v, 0.994), 0, 1) * last
            )
            change("speed", target, { reason: "drag", event: end })
            const spring = solveSpring(0.4, 0.8, at - target / last, v)
            const start = performance.now()
            const tick = () => {
              const t = performance.now() - start
              const done = t > 1000 || Math.abs(spring.position(t) * span) < 0.5
              if (done) {
                return settle()
              }
              store.set({ offset: target / last + spring.position(t) })
              frame = requestAnimationFrame(tick)
            }
            frame = requestAnimationFrame(tick)
          },
        })
      },
      keydown(event: KeyboardEvent, el: HTMLElement) {
        const step = { ArrowLeft: -1, ArrowRight: 1, Home: -last, End: last }[
          event.key
        ]
        if (!step) {
          return
        }
        event.preventDefault()
        settle()
        const flip = event.key.startsWith("Arrow") && isRTL(el) ? -1 : 1
        change("speed", clamp(store.get().speed + step * flip, 0, last), {
          reason: "step-key",
          event,
        })
      },
    }
  },
  slots: (slot) => ({
    root: slot<{}, { speed: number }>({
      state: (state) => ({ speed: state.speed }),
      attrs: () => ({ style: css({ position: "relative" }) }),
    }),
    thumb: slot({
      attrs: (state) => ({
        role: "slider",
        tabindex: "0",
        "aria-valuemin": "0",
        "aria-valuemax": String(last),
        "aria-valuenow": String(state.speed),
        "aria-valuetext": speeds[state.speed],
      }),
      live: live<FanState, any, {}>((state) => ({
        "data-dragging": state.offset === null ? undefined : "",
        style: css({
          position: "absolute",
          "inset-inline-start": `calc(${state.offset ?? state.speed / last} * (100% - var(--thumb-size)))`,
          "touch-action": "none",
        }),
      })),
      on: {
        pointerdown: (event, { el, api }) => api.pointerdown(event, el!),
        keydown: (event, { el, api }) => api.keydown(event, el!),
      },
    }),
  }),
})

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

const Fan = parts.Root

const FanThumb = parts.Thumb

function Example() {
  return (
    <div data-slot="stack">
      <Fan>
        {({ speed }) => (
          <FanThumb aria-label="Fan speed">{speeds[speed]}</FanThumb>
        )}
      </Fan>
      <div data-slot="gesture-scale">
        {speeds.map((speed) => (
          <span key={speed}>{speed}</span>
        ))}
      </div>
    </div>
  )
}

export { Example, fan, Fan, FanThumb, type FanOptions, type FanState }
Drag the fan control slowly, then try a quick flick. A faster release travels farther. Left and Right also change the speed.

To settle onto a target, create a spring and read its position each frame:

import { solveSpring } from "@ui-sdk/core/gesture"

const spring = solveSpring(0.4, 0.8, x - target, speed)
const position = target + spring.position(elapsedMilliseconds)

The first argument is the response in seconds. The second is the damping ratio: 1 settles without oscillating; a lower value allows a bounce. The remaining arguments are the initial displacement and velocity. position(t) and velocity(t) take elapsed milliseconds. In your animation loop, check both position and velocity before deciding the spring has settled.

Past an Edge

rubberBand makes a pull resist as it moves farther past an edge:

import { rubberBand } from "@ui-sdk/core/gesture"

const offset = rubberBand(distancePastEdge, list.clientHeight)
Console
import * as React from "react"
import { bindDrag, css, definePrimitive, live } from "@ui-sdk/core"
import {
  createVelocityTracker,
  rubberBand,
  solveSpring,
} from "@ui-sdk/core/gesture"
import { createParts } from "@ui-sdk/react/primitive"
import { useLog } from "./log"

type InboxState = {
  pull: number
  refreshing: boolean
  refreshed: number
}

type InboxOptions = {
  onRelease?: (speed: number) => void
  onRefresh?: (count: number) => void
}

const hold = 48

const inbox = definePrimitive({
  name: "inbox",
  options: { onRelease: "callback", onRefresh: "callback" },
  state: (_options: InboxOptions): InboxState => ({
    pull: 0,
    refreshing: false,
    refreshed: 0,
  }),
  live: ["pull"],
  api: (context) => {
    const { store } = context
    const velocity = createVelocityTracker({ maxSpeed: 4 })
    let stop: (() => void) | undefined
    let frame = 0
    let timer: ReturnType<typeof setTimeout> | undefined

    function springTo(to: number, speed: number) {
      cancelAnimationFrame(frame)
      const spring = solveSpring(0.35, 1, store.get().pull - to, speed)
      const start = performance.now()
      const tick = () => {
        const t = performance.now() - start
        const done = t > 800 || Math.abs(spring.position(t)) < 0.5
        store.set({ pull: done ? to : to + spring.position(t) })
        if (!done) {
          frame = requestAnimationFrame(tick)
        }
      }
      frame = requestAnimationFrame(tick)
    }

    function refresh(speed = 0) {
      store.set({ refreshing: true })
      springTo(hold, speed)
      timer = setTimeout(() => {
        const refreshed = store.get().refreshed + 1
        store.set({ refreshing: false, refreshed })
        context.options().onRefresh?.(refreshed)
        springTo(0, 0)
      }, 800)
    }

    context.onDestroy(() => {
      stop?.()
      cancelAnimationFrame(frame)
      clearTimeout(timer)
    })
    return {
      refresh: () => !store.get().refreshing && refresh(),
      pointerdown(event: PointerEvent, el: HTMLElement) {
        if (event.button !== 0 || store.get().refreshing) {
          return
        }
        cancelAnimationFrame(frame)
        velocity.reset()
        const from = store.get().pull
        stop = bindDrag(el, event, {
          threshold: 4,
          onMove: ({ y, event: move }) => {
            const pulled = y - event.clientY + from
            const pull = rubberBand(pulled, el.clientHeight)
            velocity.add(move.timeStamp, pull)
            store.set({ pull })
          },
          onEnd: (end, canceled) => {
            stop = undefined
            const speed = canceled ? 0 : velocity.velocity(end.timeStamp)
            context.options().onRelease?.(speed)
            if (!canceled && (store.get().pull > hold || speed > 0.6)) {
              refresh(speed)
            } else {
              springTo(0, speed)
            }
          },
        })
      },
    }
  },
  slots: (slot) => ({
    root: slot<{}, { refreshing: boolean }>({
      state: (state) => ({ refreshing: state.refreshing }),
    }),
    list: slot({
      attrs: (state) => ({
        role: "list",
        "aria-busy": state.refreshing ? "true" : undefined,
      }),
      live: live<InboxState, any, {}>((state) => ({
        "data-pulling": state.pull > 0 ? "" : undefined,
        style: css({
          transform: `translateY(${state.pull}px)`,
          "touch-action": "none",
        }),
      })),
      on: { pointerdown: (event, { el, api }) => api.pointerdown(event, el!) },
    }),
    refresh: slot({
      attrs: () => ({ type: "button" }),
      press: ({ api }) => api.refresh(),
    }),
  }),
})

const parts = createParts(inbox, {
  root: "div",
  list: "div",
  refresh: "button",
})

const Inbox = parts.Root

const InboxList = parts.List

const InboxRefresh = parts.Refresh

const messages = [
  "Lunch on Friday?",
  "Your invoice is ready",
  "Design review notes",
  "Welcome aboard",
]

function Example() {
  const log = useLog()
  return (
    <Inbox
      onRelease={(speed) => log(`Let go at ${speed.toFixed(2)} px/ms`)}
      onRefresh={(count) =>
        log(`Refreshed ${count} ${count === 1 ? "time" : "times"}`)
      }
    >
      {({ refreshing }) => (
        <>
          <div data-slot="controls">
            <InboxRefresh data-size="sm">Refresh</InboxRefresh>
          </div>
          <div data-slot="gesture-frame">
            <p>{refreshing ? "Refreshingโ€ฆ" : "Pull to refresh"}</p>
            <InboxList aria-label="Inbox">
              {messages.map((message) => (
                <div key={message} role="listitem">
                  {message}
                </div>
              ))}
            </InboxList>
          </div>
        </>
      )}
    </Inbox>
  )
}

export {
  Example,
  inbox,
  Inbox,
  InboxList,
  type InboxOptions,
  InboxRefresh,
  type InboxState,
}
Pull the inbox down far enough to refresh, then release. Try a short pull held still before release; it returns without refreshing. Refresh also works by button.

The result approaches the supplied dimension as the pull grows. Call tracker.reset() at the start of each gesture. staleAfter makes an old sample read as zero, so holding still before release doesn't act like a fresh flick.

Which Way a Stroke Points

sectorOf turns a drag direction into a numbered wedge. With four wedges, zero is up, then right, down and left:

import { sectorOf } from "@ui-sdk/core/gesture"

const index = sectorOf(dx, dy, 4) // -1 near the center
import * as React from "react"
import {
  FlameIcon,
  FrownIcon,
  HeartIcon,
  LaughIcon,
  PartyPopperIcon,
  ThumbsUpIcon,
} from "lucide-react"
import { bindDrag, css, definePrimitive } from "@ui-sdk/core"
import { sectorOf } from "@ui-sdk/core/gesture"
import { createParts } from "@ui-sdk/react/primitive"

const icons = {
  Like: ThumbsUpIcon,
  Celebrate: PartyPopperIcon,
  Laugh: LaughIcon,
  Fire: FlameIcon,
  Sad: FrownIcon,
  Love: HeartIcon,
}

const reactions = Object.keys(icons) as (keyof typeof icons)[]

const start = 1 / 12

function sectorPoint(index: number, count: number, radius: number, start = 0) {
  const angle = (index / Math.max(count, 1) + start) * 2 * Math.PI
  return {
    x: Math.round(Math.sin(angle) * radius),
    y: Math.round(-Math.cos(angle) * radius),
  }
}

type ReactState = {
  pointing: number
  picked: string | null
}

const react = definePrimitive({
  name: "react",
  state: (): ReactState => ({ pointing: -1, picked: null }),
  api: (context) => {
    const { store } = context
    let stop: (() => void) | undefined
    context.onDestroy(() => stop?.())
    return {
      pick: (index: number) =>
        store.set({ picked: reactions[index], pointing: -1 }),
      pointerdown(event: PointerEvent, el: HTMLElement) {
        if (event.button !== 0) {
          return
        }
        event.preventDefault()
        stop = bindDrag(el, event, {
          cancelOnEscape: true,
          onMove: ({ x, y }) =>
            store.set({
              pointing: sectorOf(
                x - event.clientX,
                y - event.clientY,
                reactions.length,
                {
                  deadZone: 24,
                  start,
                }
              ),
            }),
          onEnd: (_end, canceled) => {
            stop = undefined
            const { pointing } = store.get()
            if (!canceled && pointing !== -1) {
              store.set({ picked: reactions[pointing] })
            }
            store.set({ pointing: -1 })
          },
        })
      },
    }
  },
  slots: (slot) => ({
    root: slot<{}, { picked: string | null }>({
      state: (state) => ({ picked: state.picked }),
      attrs: () => ({ style: css({ position: "relative" }) }),
    }),
    handle: slot({
      attrs: () => ({
        "aria-hidden": "true",
        style: css({ "touch-action": "none" }),
      }),
      on: { pointerdown: (event, { el, api }) => api.pointerdown(event, el!) },
    }),
    reaction: slot<{ value: string }>({
      props: { value: "string" },
      attrs: (state, { props }) => {
        const index = reactions.indexOf(props.value as keyof typeof icons)
        const { x, y } = sectorPoint(index, reactions.length, 72, start)
        return {
          type: "button",
          "aria-label": props.value,
          "aria-pressed": String(state.picked === props.value),
          "data-pointing": state.pointing === index ? "" : undefined,
          style: css({
            position: "absolute",
            left: "50%",
            top: "50%",
            translate: `${x}px ${y}px`,
          }),
        }
      },
      press: ({ api, props }) =>
        api.pick(reactions.indexOf(props.value as keyof typeof icons)),
    }),
  }),
})

const parts = createParts(react, {
  root: "div",
  handle: "div",
  reaction: "button",
})

const ReactionRing = parts.Root

const ReactionHandle = parts.Handle

const Reaction = parts.Reaction

function Example() {
  return (
    <ReactionRing>
      {({ picked }) => {
        const Picked = picked ? icons[picked as keyof typeof icons] : null
        return (
          <>
            <ReactionHandle>
              {Picked ? <Picked aria-hidden="true" /> : null}
            </ReactionHandle>
            {reactions.map((reaction) => {
              const Icon = icons[reaction]
              return (
                <Reaction key={reaction} value={reaction}>
                  <Icon aria-hidden="true" />
                </Reaction>
              )
            })}
          </>
        )
      }}
    </ReactionRing>
  )
}

export {
  Example,
  react,
  Reaction,
  ReactionHandle,
  ReactionRing,
  type ReactState,
}
Drag from the center toward a reaction and release to choose it. A short stroke chooses nothing. You can also click a reaction directly.

Within the deadZone, sectorOf returns -1; the default is 18 pixels. Check for that before using the index. Pass a start offset, measured in turns, to rotate the wedges.

Options

horizon?number
How far back samples count, in ms before the newest. Defaults to 70.
Example: Past an Edge
staleAfter?number
How old the newest sample can be, in ms, before the pointer counts as stopped and reads 0. Defaults to 40.
Example: Past an Edge
maxSpeed?number
The fastest the tracker reads, in px/ms either way. Defaults to 8.
Example: Past an Edge
capacity?number
How many samples it keeps. Defaults to 20, enough for the horizon at 240 Hz.
Example: Past an Edge