n-uf motion & placement

Two small TypeScript packages for desktop-style web apps: one moves windows, panels and toolbars, the other works out where they go, from docking to drop targets.

@n-uf/motion · how things move@n-uf/placement · where things go

Motion that settles, and never overshoots. Placement that knows where panels belong.

Give it rects; real elements glide there. Drags stay under the pointer.

npm i @n-uf/motion
See the motion rules

Give it state; it returns rects and drop targets. It moves nothing.

npm i @n-uf/placement
See the desk layouts
held here
ω25 /s90 % in, measured93 msovershoot0.0 pxchecks3 of 3 pass
Scope · motion carries the panel
Plan · placement returns rects

Live demo · drag a window by its title onto another to swap them

Desk3 windows
Layout: Navigator + inspector
Preview
Quarterly plan.pdfP 02/04
Meeting notesThu
Budget draftv3
Tempo
Speed
Quarterly plan.pdf · drawn – × – · target – × –––
Drag a window onto another to swap them
  1. 1Where it can go placement: the drop targets
  2. 2What you mean motion: the drag intent (aiming, hold, which target)
  3. 3The new layout placement: the rects of the arrangement the drop makes
  4. 4It moves there motion: the settle
slow motion
The loop

Why they come together

One desktop-style UI, two questions, plain data in between. Placement answers where; motion answers how it gets there and, while you drag, what you mean.

  • What you see is what you get: the preview and the drop come from the same data.
  • Every layout change animates: any rects, one settle.
  • The real element moves: no copies, it lands from where you let go.
  • Neither imports the other: plain data between them, any framework or none.

The demo windows' slots are rects from placement's dock constructions; they glide on motion's settle, and the swap runs motion's own drag intent (createDragIntent), as the hero does along with holdItem. The examples below are compiled against both packages. Young and still changing: both at 26.11.9, versioned together by date, so pin an exact one. Source on GitHub (opens in a new tab) · on npm: @n-uf/motion (opens in a new tab), @n-uf/placement (opens in a new tab)

The packages

What each package does

@n-uf/motion how things move

For desktop-style web apps with windows, panels, docks and toolbars: every change of layout moves the way a good tiling window manager does, and a drag reads what you mean. You give it rects and pointer samples; it moves the real elements.

@n-uf/motion
The frame loop on an injected clock, signals, springs and tweens with the settle, and the gesture recogniser; each also on its own as ./clock, ./signals, ./values, ./gestures.
./layout
Give items rects: the real elements settle there, size changes revealed, never stretched; a held drag (holdItem), FLIP for DOM groups, reduced motion and batched drawing.
./drag
The drag intent: aiming, priority, hysteresis, the switch hold, room after a delay, a drop that lands where it was shown, and the three previews.
./gestures
Gestures alone: the recogniser, wheel classification, a frame coalescer, and dwell, touch-press and paged-swipe machines.
./browser
The requestAnimationFrame clock and the reduced-motion query.
./testing
Frame-sampled checks of the motion rules: no overshoot, no jumps, held under the pointer, monotonic, tempo, and content scale.

It doesn't decide where anything goes, and it draws nothing of its own. The core touches no DOM; no framework.

@n-uf/placement where things go

For the same apps: it works out where windows, panels and toolbar groups go, as the space changes and while something is dragged. You give it state and a size; it gives back rects, notes on what gave way, and drop targets.

@n-uf/placement
Rects and points (rect, overlap, clampInto) and a popup positioner that flips, slides or resizes to fit, as far as its rule allows.
./dock
Nine desk constructions as pure layouts (edge docks, a tiling tree, a navigator, a scrolling strip, drawers, floating palettes, tear-off), with give-way notes that carry a code per rule.
./toolbar
Toolbar groups in five hosts × three zones, or floating; insertion points, and collapse by keep priority (trailing groups first among equals) when a host runs out of room.
./targets
Drop targets in the shape motion's drag takes, for the desk's constructions, toolbar hosts and pane zones. Every host target carries its slot; allows drops places a group may not take, and keyboardStops gives one ordered list for the arrow keys and a "Move to" menu.
./panels
Panels in full-height columns per side, as stacked sections or tabs, that fold to title bars and a rail and give way by age (oldest by use, by default) when room runs out; kept panels, floats clamped to the view, free or pinned to it, drop targets and a stored form.
./desk
Desk presets as data on ./panels: edge docks, edge docks with tear-off, and a navigator: a tabbed column on the left, a stack column on the right.

It doesn't move or draw anything: plain state in, rects and targets out. No DOM, no dependencies.

Alone or together. They compose without importing each other: placement's targets are what motion's drag takes, and its rects are what motion's layout glides to.

Use one, or both

Pick what you need

Motion alone

Animate your own layout: give each element its rect and it glides there.

Placement alone

Compute layouts and drop targets, and draw them your way.

Both

A full desk where the drop is what was shown, and every change glides.

How things move@n-uf/motion /drag + /layout
import { createFrameLoop } from "@n-uf/motion";
import { createBrowserClock } from "@n-uf/motion/browser";
import { createDragIntent, type DragTarget } from "@n-uf/motion/drag";
import { createLayoutMotion, domDrawable, holdItem } from "@n-uf/motion/layout";

const loop = createFrameLoop({ clock: createBrowserClock() });
const motion = createLayoutMotion({ loop });
const node = document.querySelector<HTMLElement>("#panel")!;
const home = { x: 40, y: 40, width: 240, height: 120 };
const panel = motion.add("panel", domDrawable({ node }), home);
const dock: DragTarget = {
  id: "right",
  priority: 2,
  hit: { x: 600, y: 0, width: 280, height: 480 },
  slot: { x: 620, y: 40, width: 240, height: 120 },
};

// How it moves: held 1:1, a drop shown only once the pointer aims,
// then a settle from where it was let go: into the slot, or home.
const held = holdItem(panel, { x: 20, y: 10 });
const intent = createDragIntent({ home });
let pointer = { x: 60, y: 50 };
addEventListener("pointermove", (e) => {
  held.move((pointer = { x: e.clientX, y: e.clientY }));
});
const stop = loop.repeat("input", (frame) => {
  intent.update(pointer, frame.time, [dock]);
});
addEventListener("pointerup", () => {
  stop();
  intent.update(pointer, performance.now(), [dock]);
  const to = intent.release(pointer, [dock]);
  if (to?.slot) held.land(to.slot);
  else held.cancel(home);
});

npm i @n-uf/motion README on GitHub (opens in a new tab)

Where things go@n-uf/placement /toolbar + /targets
import { arrangement, layoutHost } from "@n-uf/placement/toolbar";
import { hostTargets } from "@n-uf/placement/targets";

const bar = arrangement({ top: { start: ["file", "edit"], end: ["share"] } });
const spec = { length: 320, thickness: 42, lead: 8, tail: 8 };
const size = (_id: string, _vertical: boolean, collapsed: boolean) => ({
  along: collapsed ? 30 : 120,
  across: 28,
});

// Where things go: each group's rect in the top bar; when the bar runs
// out of room, trailing groups collapse first.
const top = layoutHost(bar.top, "top", spec, size);
console.log(top.overflow); // [ 'share' ]

// Where "edit" may land: insertion lines and empty zones, the targets
// @n-uf/motion/drag takes. Its own place is home, never a target.
const { targets } = hostTargets({
  group: "edit",
  arrangement: bar,
  host: "top",
  origin: { x: 0, y: 0 },
  spec,
  size,
});
console.log(targets.map((t) => `${t.id} p${t.priority}`));
// g:top:start:0 p2, g:top:center:0 p1, g:top:end:0 p2, g:top:end:1 p2

npm i @n-uf/placement README on GitHub (opens in a new tab)

Use it

Install, then a few lines per job

npm i @n-uf/motion, then pick the job (these examples use motion; placement's are under "What each package does"). Each example is a file this site compiles with the library, so what you copy is what was checked.

import { createFrameLoop, createSettle } from "@n-uf/motion";
import { createBrowserClock } from "@n-uf/motion/browser";

const box = document.querySelector<HTMLElement>("#box")!;
const loop = createFrameLoop({ clock: createBrowserClock() });
const x = createSettle(0); // 90 % of the way in 92 ms, never past the target
let moving = false;

addEventListener("click", (e) => {
  x.setTarget(e.clientX); // a retarget goes on from where the box is drawn
  if (moving) return;
  moving = true;
  loop.repeat("write", (frame) => {
    moving = x.step(frame.delta);
    box.style.transform = `translateX(${x.value}px)`;
    return moving; // the loop idles once it rests
  });
});

Move one element with no overshoot: a settle launched from where it is drawn, stepped by one frame loop that idles at rest. The window motion rules · API on GitHub (opens in a new tab)

import { createFrameLoop } from "@n-uf/motion";
import { createBrowserClock } from "@n-uf/motion/browser";
import { createLayoutMotion, domDrawable } from "@n-uf/motion/layout";

const loop = createFrameLoop({ clock: createBrowserClock() });
const motion = createLayoutMotion({ loop }); // frames run only while something moves

const node = document.querySelector<HTMLElement>("#panel")!;
const panel = motion.add("panel", domDrawable({ node }), { x: 0, y: 0, width: 320, height: 200 });

// Your layout picks the rects. The panel glides from where it is drawn;
// its content is laid out once at 480 px and revealed, never stretched.
document.querySelector("#wider")!.addEventListener("click", () => {
  panel.moveTo({ x: 40, y: 0, width: 480, height: 200 });
});

Give each item a rect: the real element glides there from where it is drawn, and a new size is revealed, never stretched. Stretch versus reveal · API on GitHub (opens in a new tab)

import { createFrameLoop } from "@n-uf/motion";
import { createBrowserClock } from "@n-uf/motion/browser";
import { createDragIntent, type DragTarget } from "@n-uf/motion/drag";

const targets: DragTarget[] = [
  { id: "tabs", priority: 1, hit: { x: 240, y: 0, width: 720, height: 36 } },
  { id: "sidebar", priority: 2, hit: { x: 0, y: 0, width: 240, height: 600 } },
];
const intent = createDragIntent({ home: { x: 600, y: 240, width: 200, height: 120 } });
const indicator = document.querySelector<HTMLElement>("#indicator")!;
let pointer = { x: 700, y: 300 };
addEventListener("pointermove", (e) => (pointer = { x: e.clientX, y: e.clientY }));

// Once a frame, also while the pointer rests: a dwell is time passing.
const loop = createFrameLoop({ clock: createBrowserClock() });
const stop = loop.repeat("input", (frame) => {
  const { shown } = intent.update(pointer, frame.time, targets);
  indicator.hidden = !shown; // only once the pointer aims, no flicker on borders
});
addEventListener("pointerup", () => {
  stop();
  console.log("lands on", intent.release(pointer, targets)?.id ?? "home");
});

Feed it the pointer once a frame and plain-data targets: it says what the drop indicator shows and what a release lands on. The drag-intent model · API on GitHub (opens in a new tab)

Explore

Interactive pages

Move · @n-uf/motion

02 Drag intent

An indicator only once you aim

Drag a panel yourself or replay recorded paths, and count the indicator's flashes in this model and a naive one.

Place · @n-uf/placement

03 Desk layouts

Nine ways to build a desk

Docks, one tiling tree, a strip, drawers and palettes, laid out live as width and height run out.

04 Toolbar groups

One handle per group

Five hosts, three zones, floating and collapsing groups, all from one drag handle each.

Try it all · both packages

05 Lab

The lab

19 interactive demos for both packages, from the settle to panel columns and ten kit cases, and a checks page that runs every scenario.

FAQ

Questions

Can I use @n-uf/motion without React?

Yes. Neither package needs React or any framework. @n-uf/motion has no DOM, no framework and no globals in its core: the host hands in a clock. @n-uf/placement is pure functions over plain data with no DOM and no dependencies, so you can call both from plain TypeScript or from any framework's code.

How do I handle long press, pinch, double click and wheel gestures in JavaScript?

Feed pointer, wheel and trackpad events to createRecognizer from @n-uf/motion. It turns plain objects copied from DOM events into one gesture stream: click or drag, double click, long press, pinch, scroll and zoom. Nothing in it touches the DOM, and by default a touch long press fires after 500 ms.

How do I position a popup or menu next to its anchor?

Use positionPopup from @n-uf/placement. Give it the anchor rect, popup size, bounds and a rule (anchor point, gravity, offset, and whether it may flip, slide or resize), and it flips, slides or resizes the popup to fit, as far as the rule allows, like Wayland's xdg-positioner. It works on plain data and moves nothing, so your app does the drawing.

What license is @n-uf/motion under, and is it stable?

It is under PolyForm Perimeter 1.0.1, and it is not yet stable. Versions are dates (YY.M.R), not semantic versions, so any release may break things, including how springs settle and how gestures are timed. Pin an exact version and read the CHANGELOG before you upgrade. @n-uf/placement has the same license and its own not-yet-stable notice.

How do @n-uf/motion and @n-uf/placement work together?

Placement computes where things go and motion moves them, and neither imports the other. A target list from @n-uf/placement/targets is exactly what the drag intent in @n-uf/motion/drag takes, and a rect from any placement layout is what @n-uf/motion/layout glides to. Each package also works alone.