15 KiB
Gesture Layer — Architecture & Design
Purpose
A shared gesture primitive for soma components that need drag/swipe/resize behavior. Provides pointer tracking, axis locking, velocity calculation, and cancel semantics. Components build on top of it — the layer measures movement, the component decides what it means.
Context
4 existing components implement drag manually (inline pointerdown/move/up):
- Slider — drag thumb to set value
- Splitter — drag handle to resize panels
- ScrollArea — drag thumb to scroll
- Toast — swipe to dismiss
New components that need it:
- Drawer — swipe to dismiss + snap points
- Future: ColorPicker (area drag), RangeSlider, etc.
The gesture layer extracts the shared logic so each component doesn't reimplement pointer capture, axis locking, and velocity tracking.
Reference Analysis
How other frameworks handle gestures
| Framework | Architecture | Shared | Velocity | Axis lock |
|---|---|---|---|---|
| Vaul | Hardcoded in drawer (~1000 lines inline) | No | distance/time basic | No |
| Zag.js | trackPointerMove utility + per-component session classes |
Utility yes, sessions no | Sliding window, px/s, staleness-aware | Per-component |
| @use-gesture | Engine hierarchy: Engine → CoordinatesEngine → DragEngine | Yes — all share base | Kinematics in Engine base | CoordinatesEngine.axisIntent() shared |
| Framer Motion | Baked into animation system | No — coupled to VisualElement | Via motion values | Via drag="x"/"y" prop |
| dnd-kit | Sensor plugin (PointerSensor, KeyboardSensor) | Sensors yes, kinematics no | No | No |
@use-gesture model (most abstracted)
Engine (base)
├── kinematics (velocity, direction, distance, deltas)
├── threshold detection
├── rubberband/bounds clamping
├── active/blocked state machine
│
├── CoordinatesEngine (extends Engine)
│ ├── axis locking (|dx| vs |dy|, zeroes locked axis)
│ ├── offset tracking
│ │
│ ├── DragEngine → pointer capture, swipe detection, tap detection
│ ├── ScrollEngine
│ └── MoveEngine
│
└── PinchEngine (extends Engine directly — distance+angle)
Zag.js model (pragmatic, closest to soma)
trackPointerMove (shared utility)
├── binds pointermove/pointerup/pointercancel on document
├── move buffer (5px mouse, 10px touch)
├── detects released buttons during move
├── manages text selection
SwipeSession (drawer-specific, uses trackPointerMove)
├── velocity: sliding window of samples over 100ms
├── axis determination, cross-axis bias detection
├── deferred mouse/pen drag arming (6px threshold)
├── release velocity with staleness checks
DrawerSwipeSession (wraps SwipeSession)
├── drag offset with square-root dampening
├── snap point resolution (velocity projection: v * 0.4, capped 4000px/s)
├── dismiss detection
├── sequential snap advancement
├── scroll-vs-drag arbitration
Decision for soma
Zag.js model — thin shared base, rich per-component specializations. Reasons:
- Soma already uses this pattern (shared layers + per-provider config)
- @use-gesture is overkill for 5 components
- The base should be thin — each component adds what it needs
Reactive Primitives
Active<T> is soma's reactive reference — a box with a .current accessor that can be read and (for WritableActive) written. readableActive(() => expr) creates a read-only derived box; writableActive(initial) creates a read-write box. This is how opts stay reactive without requiring Svelte $state in plain .ts files. All gesture opts that may change at runtime are Active<T>.
Architecture: 3 Specializations
Gesture.base() → pointer tracking + axis lock + velocity + cancel
Gesture.drag() → base + offset + progress + snap points + threshold
Gesture.resize() → base + delta forwarding + min/max constraints
Gesture.base()
The foundation. Handles raw pointer mechanics. All specializations build on this.
interface GestureBaseOpts {
/** Element ref to track */
ref: Active<HTMLElement | null>;
/** Whether gesture is active */
enabled: Active<boolean>;
/** Axis constraint */
axis?: Active<'x' | 'y' | 'both'>;
/** Lock to primary axis after this many px of movement. @default 10 */
lockThreshold?: number;
/** Move buffer before gesture activates (prevents accidental drags). @default 5 (mouse), 10 (touch) */
moveBuffer?: number;
/** Callback on cancel */
onCancel?: () => void;
}
interface GestureBaseInstance {
/** Spread into provider props — pointer event handlers */
readonly props: {
onpointerdown: (e: PointerEvent) => void;
onpointermove: (e: PointerEvent) => void;
onpointerup: (e: PointerEvent) => void;
onpointercancel: (e: PointerEvent) => void;
};
/** Whether a drag is in progress */
readonly isDragging: boolean;
/** Current velocity in px/ms */
readonly velocityX: number;
readonly velocityY: number;
/** Raw offset from start position in px */
readonly offsetX: number;
readonly offsetY: number;
/** Detected primary axis (after lock threshold) */
readonly lockedAxis: 'x' | 'y' | null;
/** Programmatic cancel — resets to initial state */
cancel(): void;
}
What it does:
onpointerdown: captures pointer, records start position, starts velocity samplingonpointermove: tracks delta, detects axis (after moveBuffer px), locks axis (after lockThreshold px), calculates velocity (sliding window, last 100ms of samples)onpointerup: releases capture, fires completiononpointercancel: releases capture, fires onCancel- Velocity: sliding window of
{ x, y, time }samples, oldest evicted after 100ms. Velocity = weighted average of deltas / time deltas. Staleness check: if last sample > 50ms old, velocity = 0 (finger stopped).
What it does NOT do: offset clamping, progress calculation, snap points, dismiss logic, CSS vars.
Gesture.drag()
For components where drag translates to visual displacement + optional dismiss/snap (Drawer, Toast).
interface GestureDragOpts extends GestureBaseOpts {
/** Primary drag direction — determines which axis maps to progress */
direction: Active<'up' | 'down' | 'left' | 'right'>;
/** Where drag can be initiated from. When 'handle-only', only pointerdown on handle starts tracking. */
dragFrom?: Active<'anywhere' | 'handle-only'>;
/** Handle element ref (required when dragFrom is 'handle-only') */
handle?: Active<HTMLElement | null>;
/** Snap points: 0-1 = viewport fraction, >1 or string = px */
snapPoints?: Active<(number | string)[] | undefined>;
/** Only snap to adjacent points (no skipping). When true, velocity projection is clamped
* to never skip past the immediate next/prev snap point, regardless of flick speed. */
snapToSequential?: Active<boolean>;
/** Dismiss threshold: 0-1 fraction of total travel. @default 0.25 */
threshold?: Active<number>;
/** Velocity threshold for fast-swipe dismiss in px/ms. @default 0.5 */
velocityThreshold?: Active<number>;
/** What happens when drag doesn't meet threshold */
resetBehavior?: Active<'snap-back' | 'stay'>;
/** Dampening function for over-drag (e.g., square root) */
dampen?: (offset: number) => number;
/** Callback during drag */
onDrag?: Active<(event: PointerEvent, state: DragState) => void>;
/** Callback on release */
onRelease?: Active<(event: PointerEvent, state: ReleaseState) => void>;
/** Callback when dismiss threshold met */
onDismiss?: Active<() => void>;
/** Callback when drag cancels (didn't meet threshold, reset) */
onCancel?: Active<() => void>;
/** Callback when snap point changes */
onSnapChange?: Active<(snap: number | string) => void>;
}
interface DragState {
/** 0-1 progress toward dismiss */
progress: number;
/** Raw offset in px */
offsetX: number;
offsetY: number;
/** Velocity in px/ms */
velocityX: number;
velocityY: number;
}
interface ReleaseState extends DragState {
/** Whether dismiss threshold was met */
dismissed: boolean;
/** Which snap point was resolved */
snappedTo: number | string | null;
}
interface GestureDragInstance extends GestureBaseInstance {
/** 0-1 progress toward dismiss */
readonly progress: number;
/** Current active snap point */
readonly activeSnapPoint: number | string | null;
}
What it adds over base:
progress: maps directional offset to 0-1 based on element size- Snap point resolution: on release, finds nearest snap point considering velocity projection (
velocity * 0.4, capped at 4000px/s) - Dismiss detection:
progress > thresholdORvelocity > velocityThreshold - Dampening: when dragging past bounds, applies dampening function (default:
Math.sign(v) * 8 * Math.log(Math.abs(v) + 1)— always same sign as offset, never negative for positive v) dragFrom: restricts where a drag can start. Dismiss is not a separate gesture — it's a drag that crossed the threshold. One restriction controls both.resetBehavior: on cancel, either snap back to original position or stay where released- No CSS vars, no style output — only state and handlers
Gesture.resize()
For components where drag changes a boundary (Splitter, future column resize).
interface GestureResizeOpts extends GestureBaseOpts {
/** Resize direction */
orientation: Active<'horizontal' | 'vertical'>;
/** Minimum delta (px) */
minDelta?: Active<number>;
/** Maximum delta (px) */
maxDelta?: Active<number>;
/** Callback with constrained delta */
onResize?: Active<(event: PointerEvent, delta: number) => void>;
/** Callback on resize end */
onResizeEnd?: Active<(event: PointerEvent, totalDelta: number) => void>;
}
interface GestureResizeInstance extends GestureBaseInstance {
/** Constrained delta from start in px */
readonly delta: number;
/** Total accumulated delta */
readonly totalDelta: number;
}
What it adds over base:
- Constrains delta to min/max bounds
- Fires
onResizewith clamped delta on each move - Fires
onResizeEndwith total delta on release - No progress, no snap points, no dismiss
Usage Pattern
// Drawer — uses Gesture.drag()
class DrawerContentProvider {
readonly gesture = Gesture.drag({
ref: opts.ref,
direction: opts.side,
threshold: opts.closeThreshold,
snapPoints: opts.snapPoints,
handle: opts.handleRef,
dragFrom: opts.handleOnly ? 'handle-only' : 'anywhere',
enabled: readableActive(() => this.provider.opts.dismissible.current),
onDismiss: readableActive(() => () => this.provider.handleClose()),
onSnapChange: opts.onActiveSnapPointChange
});
readonly props = $derived.by(() => ({
...this.runtimePart.props,
...this.gesture.props, // pointer handlers only, no CSS
// Component decides how to use gesture state:
'data-dragging': boolToEmptyStrOrUndef(this.gesture.isDragging)
}));
}
// Splitter — uses Gesture.resize()
class SplitterTriggerProvider {
readonly gesture = Gesture.resize({
ref: opts.ref,
orientation: this.provider.opts.orientation,
onResize: readableActive(() => (e, delta) => this.provider.resizePanels(delta)),
onResizeEnd: readableActive(() => () => this.provider.notifyResizeEnd())
});
}
// Slider — uses Gesture.base() directly
class SliderProvider {
readonly gesture = Gesture.base({
ref: opts.ref,
axis: readableActive(() => (opts.orientation.current === 'horizontal' ? 'x' : 'y')),
enabled: readableActive(() => !opts.disabled.current)
});
// Slider maps offset to value itself — base just tracks pointer
readonly onpointermove = (e: PointerEvent) => {
if (!this.gesture.isDragging) return;
const value = this.getValueFromPointer(e.clientX, e.clientY);
this.updateValue(value);
};
}
Velocity Algorithm
Based on Zag.js approach (proven in production):
- Sampling: on each pointermove, push
{ x, y, time: performance.now() }to a ring buffer - Window: keep last 100ms of samples, evict older
- Calculation: weighted average of
(delta_position / delta_time)across window - Staleness: if last sample is >50ms old at release time, velocity = 0 (finger stopped before release)
- Projection: for snap resolution, project
velocity * 0.4seconds ahead, cap at 4000px/s equivalent
File Structure
src/uix/soma/layers/
├── gesture/
│ ├── index.ts ← exports Gesture.base, Gesture.drag, Gesture.resize
│ ├── gesture.svelte.ts ← GestureBase, GestureDrag, GestureResize (consolidated — 3 classes share inheritance)
│ ├── velocity.ts ← velocity ring buffer + calculation (pure, no Svelte deps)
│ └── types.ts ← shared types
Notes on implementation vs design:
propsonly exposesonpointerdown— move/up/cancel bind ondocumentafter pointer captureresetBehavioris in the type contract but not enforced internally — the consumer decides snap-back vs stay inonRelease
## Migration Plan
After implementation, existing components migrate in order of risk:
1. **Slider** → `Gesture.base()` replaces inline pointer handlers (low risk — simple axis-locked drag)
2. **Splitter** → `Gesture.resize()` replaces inline pointer handlers (low risk — constrained delta)
3. **ScrollArea** → `Gesture.base()` replaces thumb drag handlers (low risk — offset mapping only)
4. **Toast** → `Gesture.drag()` replaces swipe handlers (medium risk — validates velocity + dismiss path before Drawer)
5. **Drawer** → new, built on `Gesture.drag()` from the start (highest complexity — snap points + dampening + dismiss)
Toast is migrated before Drawer intentionally: it exercises the velocity → dismiss path in a simpler component, validating that Gesture.drag() works before building Drawer on top of it.
## Design Rules
1. **Gesture layer measures, component decides.** The layer doesn't know what "dismiss" means. It reports progress > threshold. The component calls handleClose().
2. **No CSS vars in gesture layer.** Soma exposes numbers (progress, offsetX, offsetY). The visual layer (eidos) converts to CSS custom properties.
3. **No semantic intent in gesture layer.** Sema reads component state changes (data-state transitions), not gesture state. Whether the state change came from swipe, click, or Escape — sema treats it the same.
4. **Specializations don't pay for what they don't use.** Gesture.base() has no snap points. Gesture.resize() has no dismiss. Only Gesture.drag() has the full feature set.
5. **Cancel is a first-class operation.** Every specialization supports programmatic cancel() and onCancel callback. resetBehavior controls what happens: snap-back or stay.