Component library for high-performance React number pickers with kinetic scrolling
This document provides an in-depth look at the architecture of the @tensil/kinetic-input library.
- Overview
- Core Concepts
- Component Hierarchy
- State Management
- Physics & Gestures
- Rendering Strategy
- Event Flow
- Debug System
- Performance Considerations
Kinetic Input is built around three main component families:
- CollapsiblePicker (
quick/) - Full-featured collapsible input with touch/mouse/wheel support - PickerColumn (
picker/) - Core wheel picker primitive - Picker (
wheel/) - Standalone wheel component
- Performance First: Virtual rendering, memoization, and efficient event handling
- Declarative State: XState v5 state machines for interaction lifecycle
- Physics-Based: Momentum, snapping, and overscroll feel natural
- Accessibility: ARIA attributes, keyboard navigation, screen reader support
- Type Safety: Full TypeScript with strict mode enabled
Only renders visible items plus overscan buffer:
Physical List: [0, 1, 2, ..., 999]
Virtual Window: [47, 48, 49, 50, 51] (visible items)
Overscan: [44, 45, 46, ... 52, 53, 54] (prerendered)
- Window Size: Configurable (default: 5 visible + 3 overscan per side)
- Update Trigger: When scroll position crosses threshold
- Performance: O(1) rendering regardless of list size
Magnetic attraction to center item:
┌─────────────────┐
│ Item N-1 │
├─────────────────┤ ← Snap Zone (configurable)
│ ► Item N ◄ │ ← Center (strongest pull)
├─────────────────┤
│ Item N+1 │
└─────────────────┘
- Enter Threshold: Distance to activate snapping (default: 0.8× item height)
- Exit Threshold: Distance to deactivate (default: 0.68× item height - hysteresis)
- Pull Strength: Configurable magnetic force (default: 1.4)
- Velocity Scaling: Reduces snap strength during fast scrolling
See packages/number-picker/src/config/physics.ts for tunable constants.
Unified handling of pointer, wheel, and keyboard input:
Input Sources → Gesture Handler → Physics Engine → Animation → DOM- Multi-touch Support: Tracks multiple pointer IDs
- Device Detection: Auto-detects touchpad vs mouse wheel
- Velocity Tracking: Samples last 250ms of motion
- Click vs Drag: Distinguishes taps from swipes
CollapsiblePicker
├── ThemedNumberInput (closed state)
│ └── Formatted display value
└── Picker Surface (open state)
├── PickerGroup
│ └── PickerColumn (for each digit)
│ ├── Virtual Window Manager
│ ├── PickerItem × N (visible items)
│ └── Highlight Overlay
└── Feedback System
├── Haptics (Vibration API)
└── Audio (Web Audio API)
usePickerStateMachine (XState)
↓
usePickerCoordinator
├→ useGestureCoordination
├→ usePickerFeedback
├→ useFormattedValues
├→ useHighlightMetrics
└→ useKeyboardControls
usePickerPhysics
├→ useSnapPhysics
├→ useVirtualWindow
└→ useSnappedIndexStore
The picker lifecycle is modeled as a finite state machine:
┌─────────┐ POINTER_DOWN/WHEEL_START ┌──────────────┐
│ closed │ ────────────────────────→ │ interacting │
└─────────┘ └──────────────┘
↑ │
│ │ POINTER_UP/WHEEL_IDLE
│ ↓
│ ┌──────────┐
│ │ settling │
│ └──────────┘
│ │
│ │ MOMENTUM_END
│ ↓
│ idleTimeout (4s default) ┌──────┐
└──────────────────────────────────│ idle │
└──────┘
States:
closed- Picker is not visibleinteracting- User is actively dragging/scrollingsettling- Momentum animation in progressidle- Waiting for auto-close timeout
Events:
POINTER_DOWN/UP- Touch/mouse interactionsWHEEL_START/IDLE- Scroll wheel eventsMOMENTUM_END- Physics animation completedFORCE_CLOSE- Programmatic closeEXTERNAL_CLOSE- User clicked outside
Context:
{
activeInputs: Set<'pointer' | 'wheel'> // Currently active inputs
interactionCount: number // Total gestures this session
isSingleGesture: boolean // Opened with one continuous gesture
openedViaWheel: boolean // Session started with wheel
atBoundary: boolean // Last snap was at min/max
config: { /* timing, callbacks */ }
}Located in packages/number-picker/src/config/timing.ts:
- instant: 50ms settle, 300ms wheel idle, 1500ms close (power users)
- fast: 100ms settle, 500ms wheel idle, 2500ms close (desktop workflows)
- balanced: 150ms settle, 800ms wheel idle, 4000ms close (DEFAULT, general use)
- patient: 300ms settle, 1200ms wheel idle, 6000ms close (mobile/accessibility)
class VelocityTracker {
samples: Array<{ y: number; timestamp: number }>
addSample(y: number): void
getVelocity(): number // px/ms over last 250ms
reset(): void
}Samples are stored as [y, timestamp] pairs and velocity is calculated using linear regression over the last 250ms window.
When released with velocity:
const projection = projectReleaseTranslate({
currentY,
velocityY,
itemHeight,
lastIndex,
minY,
maxY,
config: {
friction: 0.002, // Deceleration rate
stopThreshold: 0.5, // Minimum velocity to continue
boundary: 0.4 // Damping at edges
}
})Algorithm:
- Apply friction each frame:
v = v × (1 - friction) - Project final position:
y_final = y + v / friction - Clamp to boundaries with damping
- Snap to nearest item
When dragging beyond min/max bounds:
const distance = Math.abs(rawY - boundaryY)
const limitedDistance = Math.min(distance, MAX_OVERSCROLL_PIXELS) // Cap at 80px
const damped = Math.pow(limitedDistance, OVERSCROLL_DAMPING_EXPONENT) // 0.8 exponentThis creates a "rubber band" effect that provides resistance feedback.
const useVirtualWindow = (
centerY: number, // Current scroll position
itemHeight: number, // Height of each item
totalItems: number, // Total items in list
slotCount: number, // Visible items (default: 5)
overscan: number // Extra items (default: 3)
) => {
const centerIndex = Math.round(centerY / itemHeight)
const halfSlots = Math.floor(slotCount / 2)
const startIndex = centerIndex - halfSlots - overscan
const endIndex = centerIndex + halfSlots + overscan
return {
startIndex: clamp(startIndex, 0, totalItems - 1),
windowLength: endIndex - startIndex + 1,
virtualOffsetY: startIndex * itemHeight
}
}Uses transform: translateY() for hardware-accelerated scrolling:
<motion.div
style={{
transform: ySnap.use((y) => `translateY(${y}px)`)
}}
>
{visibleItems.map((item, i) => (
<PickerItem
key={item.value}
style={{
transform: `translateY(${(startIndex + i) * itemHeight}px)`
}}
/>
))}
</motion.div>- Motion Values:
useMotionValue()for 60fps updates without re-renders - Animations:
animate()for momentum and snapping - Spring Physics: Configurable stiffness/damping for natural feel
User touches screen
↓
handlePointerDown
├→ setPointerCapture(pointerId)
├→ capturedPointers.add(pointerId)
├→ velocityTracker.reset()
├→ emitter.dragStart('pointer')
└→ stateMachine.send('POINTER_DOWN')
↓
handlePointerMove (during drag)
├→ velocityTracker.addSample(clientY)
├→ calculateDelta()
├→ snapPhysics.calculate()
└→ yRaw.set(newY)
↓
handlePointerUp
├→ capturedPointers.delete(pointerId)
├→ releasePointerCapture(pointerId)
├→ velocity = velocityTracker.getVelocity()
├→ projectMomentum()
├→ animate() to final position
├→ emitter.dragEnd(velocity)
└→ stateMachine.send('POINTER_UP')
Auto-detects input device based on event.deltaMode:
if (event.deltaMode === DOM_DELTA_MODE.PIXEL) {
// Touchpad: Fine-grained, natural scrolling
delta = -event.deltaY * 0.35
} else if (event.deltaMode === DOM_DELTA_MODE.LINE) {
// Mouse wheel: Coarse, inverted scrolling
delta = event.deltaY * itemHeight
}Delta Accumulation: Sub-pixel deltas are accumulated to prevent lost precision:
wheelRemainder += delta
const quantized = Math.round(wheelRemainder / itemHeight) * itemHeight
wheelRemainder -= quantizedProduction-safe opt-in debugging system in packages/number-picker/src/utils/debug.ts.
window.__QNI_DEBUG__ = true // CollapsiblePicker
window.__QNI_SNAP_DEBUG__ = true // Snap physics
window.__QNI_STATE_DEBUG__ = true // State machine
window.__QNI_WHEEL_DEBUG__ = true // Wheel picker
window.__QNI_ANIMATION_DEBUG__ = true // Animations
window.__QNI_PICKER_DEBUG__ = true // Picker physicsOr enable all at once:
enableAllDebugNamespaces()import { debugPickerLog } from '@tensil/kinetic-input/utils';
debugPickerLog('Pointer down', { pointerId, clientY })
// No-op in production, logs only if __QNI_PICKER_DEBUG__ = true in devProduction Safety:
- All debug code is tree-shaken in production builds
- Zero runtime overhead when not enabled
- No console spam in development unless explicitly enabled
-
Virtual Rendering
- Only renders ~11 items regardless of list size
- O(1) rendering complexity
-
Memoization
useMemo()for expensive calculationsuseCallback()for event handlersReact.memo()for item components
-
RAF Throttling
- Motion values update at 60fps
- No re-renders during drag/scroll
-
CSS Hardware Acceleration
transform: translateY()instead oftopwill-change: transformon active elements
-
Event Delegation
- Single event listener per column
- Pointer capture for reliable events
- Core Library: ~45KB minified + gzipped
- XState: ~15KB (peer dependency)
- Framer Motion: ~30KB (peer dependency)
- Total: ~90KB for full-featured picker
- Modern Browsers: Chrome 90+, Firefox 88+, Safari 14+, Edge 90+
- Mobile: iOS 14+, Android Chrome 90+
- Pointer Events: Required (all modern browsers)
- CSS Grid: Required (all modern browsers)
packages/number-picker/src/
├── config/ # Configuration & constants
│ ├── physics.ts # Physics constants (snap, overscroll, gestures)
│ ├── timing.ts # Timing presets (quick, default, relaxed)
│ └── ui.ts # UI constants (dimensions, colors)
│
├── picker/ # Core wheel picker primitives
│ ├── PickerGroup.tsx # Container for multiple columns
│ ├── PickerColumn.tsx # Single scrollable column
│ ├── PickerItem.tsx # Individual item renderer
│ ├── hooks/
│ │ ├── usePickerPhysics.ts # Main physics & gesture handling
│ │ ├── useSnapPhysics.ts # Magnetic snapping logic
│ │ └── useVirtualWindow.ts # Virtual list bookkeeping
│ ├── gestures/
│ │ ├── eventEmitter.ts # Gesture event system
│ │ ├── velocityTracker.ts # Velocity calculation
│ │ └── pointerCapture.ts # Multi-touch handling
│ └── utils/
│ ├── math.ts # Y ↔ index conversions
│ └── releaseMomentum.ts # Momentum projection
│
├── quick/ # CollapsiblePicker component
│ ├── CollapsiblePicker.tsx
│ ├── ThemedNumberInput.tsx # Closed state input
│ ├── theme.ts # Color & typography tokens
│ ├── hooks/
│ │ ├── pickerStateMachine.machine.ts # XState definition
│ │ ├── pickerStateMachine.actions.ts # State actions
│ │ ├── pickerStateMachine.shared.ts # Types & guards
│ │ ├── usePickerCoordinator.ts # Gesture orchestration
│ │ ├── usePickerFeedback.ts # Haptics & audio
│ │ └── [15+ other hooks]
│ └── feedback/
│ ├── haptics.ts # Vibration API wrapper
│ └── audio.ts # Web Audio API wrapper
│
├── wheel/ # Picker
│ └── Picker.tsx
│
├── utils/ # Shared utilities
│ ├── debug.ts # Production-safe debug system
│ └── pickerOptions.ts # Decimal scaling utilities
│
└── styles/ # Component CSS
├── picker-base.css
├── quick-number-input.css
└── wheel-picker.css
See CONTRIBUTING.md for development setup and guidelines.
MIT © Tensil AI