High-performance numeric scrubber components for React. The package exposes:
CollapsiblePicker– animated momentum picker with modal expansionPicker– lightweight list/range picker without modal chromePickerGroup– bare-bones wheel primitive that powers both components- Supporting hooks, theme builders, and configuration presets
📚 Documentation:
- ARCHITECTURE.md - In-depth architecture guide
- CONTRIBUTING.md - Development setup and contributing guidelines
All component docs now live in this README.
npm install @tensil/kinetic-input
# or
yarn add @tensil/kinetic-inputPeer dependencies you must provide in your host app:
react/react-dom(18 or 19)framer-motion(^11.0.0)xstate(^5.0.0)@xstate/react(^6.0.0)
Import the styles in your app's entry point (e.g., main.tsx or App.tsx):
Option 1: Convenience bundle (recommended)
import '@tensil/kinetic-input/styles/all.css'
Option 2: Granular imports (for optimization)
// Pick only what you need: import '@tensil/kinetic-input/styles/picker.css' // Base (required for all) import '@tensil/kinetic-input/styles/quick.css' // CollapsiblePicker import '@tensil/kinetic-input/styles/wheel.css' // Picker
The convenience bundle includes all styles (~6KB gzipped). Use granular imports if you only need specific components.
import CollapsiblePicker from '@tensil/kinetic-input' export function WeightField() { const [weight, setWeight] = useState(70) return ( <CollapsiblePicker label="Weight" value={weight} onChange={setWeight} unit="kg" min={40} max={200} step={0.5} /> ) }
Need lower-level control? Import the named utilities:
import { CollapsiblePicker, Picker, PickerGroup, DEFAULT_THEME, buildTheme, BOUNDARY_SETTLE_DELAY, } from '@tensil/kinetic-input'
import { Picker } from '@tensil/kinetic-input' const colorOptions = [ { value: 'rest', label: 'Rest Day', accentColor: '#8E77B5' }, { value: 'short', label: 'Short Run', accentColor: '#3EDCFF' }, { value: 'long', label: 'Long Run', accentColor: '#31E889' }, ] export function SessionPicker({ value, onChange }) { return ( <Picker value={value} onChange={onChange} options={colorOptions} visibleItems={5} highlightColor="#3EDCFF" /> ) }
- Momentum-based wheel/touch scrolling with mixed pointer + wheel support
- Smart auto-close timing (150 ms pointer, 800 ms wheel, 2.5 s idle - "balanced" preset)
- Controlled & uncontrolled modes
- Integer-scaled decimal arithmetic to avoid float drift
- Full theming + custom render hooks for values/items
- Optional backdrop + helper text support
| Prop | Type | Default | Description |
|---|---|---|---|
label |
string |
required | Label text |
value |
number | undefined |
required | Current value |
onChange |
(value: number) => void |
required | Change handler |
unit |
string |
'' |
Unit suffix (kg, cm, etc.) |
min / max |
number |
0 / 500 |
Range bounds |
step |
number |
1 |
Increment step |
lastValue |
number |
- | Fallback when provided value is out-of-range |
placeholder |
string |
'—' |
Display when value is undefined |
isOpen |
boolean |
uncontrolled | Controlled open state |
onRequestOpen / onRequestClose |
() => void |
- | Required when isOpen is provided |
itemHeight |
number |
40 |
Row height (px) |
theme |
Partial<CollapsiblePickerTheme> |
- | Override palette/typography |
renderValue / renderItem |
custom renderers | default layout | Hook into value/item rendering |
helperText |
ReactNode |
- | Optional caption below the input |
enableSnapPhysics |
boolean |
false |
Experimental magnetic snap for slow drags |
snapPhysicsConfig |
Partial<SnapPhysicsConfig> |
defaults | Override snap parameters |
wheelSensitivity |
number |
1 |
Wheel/trackpad scroll speed multiplier (higher = faster) |
wheelDeltaCap |
number |
1.25 |
Maximum wheel delta per frame in rows (prevents jumps) |
enableHaptics |
boolean |
true |
Vibration feedback on selection (mobile) |
enableAudioFeedback |
boolean |
true |
Audio clicks on selection |
feedbackConfig |
QuickPickerFeedbackConfig |
- | Override audio/haptic adapters or patterns |
Every color, font, and spacing can be customized via the theme prop. The library ships with sensible defaults (cyan accents on dark backgrounds), but you can override any property to match your design system.
interface CollapsiblePickerTheme { // Picker rows (when open) textColor: string // Non-selected rows activeTextColor: string // Currently selected row unitColor: string // Unit label (e.g., "kg", "lbs") // Closed state (when collapsed) closedBorderColor: string // Border when has value closedBorderColorEmpty: string // Border when empty closedBackgroundColor: string // Background when has value closedBackgroundColorEmpty: string // Background when empty // Interactive elements labelColor: string // Field label above picker lastValueButtonColor: string // "↺ LAST" restore button focusRingColor: string // Keyboard focus indicator // Open state (when expanded) highlightBorderColor: string // Border around picker window highlightFillColor: string // Fill behind selected row backdropColor: string // Dark overlay behind picker fadeColor: string // Gradient fade at top/bottom // Advanced (rarely changed) selectedColor: string // Internal selection state pendingColor: string // Transition state hoverColor: string // Hover highlights flashColor: string // Success flash animation deselectColorA: string // Deselection gradient start deselectColorB: string // Deselection gradient end deselectColorOff: string // Deselection disabled // Typography fontSize: string // Picker text size fontFamily: string // Picker font family }
import { DEFAULT_THEME } from '@tensil/kinetic-input' // Default values: { textColor: '#9DB1BE', // Muted gray activeTextColor: '#3EDCFF', // Cyan accent unitColor: '#8E77B5', // Purple closedBorderColor: 'rgba(62,220,255,0.5)', closedBackgroundColor: 'rgba(0,0,0,0.5)', highlightBorderColor: 'rgba(62,220,255,0.5)', labelColor: '#8E77B5', focusRingColor: 'rgba(62,220,255,0.7)', fontSize: 'clamp(24px, 6vw, 32px)', fontFamily: "'Geist Mono', monospace", // ... (see theme.ts for complete defaults) }
Minimal override (just accent color):
<CollapsiblePicker value={weight} onChange={setWeight} theme={{ activeTextColor: '#10b981', // Green-500 closedBorderColor: '#10b981', highlightBorderColor: '#10b981', }} />
Complete custom theme:
// iOS-inspired light theme const iosTheme = { activeTextColor: '#3b82f6', // Blue textColor: '#64748b', // Slate-500 closedBorderColor: 'rgba(59,130,246,0.5)', closedBackgroundColor: 'rgba(241,245,249,0.8)', closedBackgroundColorEmpty: 'rgba(226,232,240,0.6)', labelColor: '#64748b', lastValueButtonColor: '#3b82f6', focusRingColor: 'rgba(59,130,246,0.7)', highlightBorderColor: 'rgba(59,130,246,0.5)', highlightFillColor: 'rgba(59,130,246,0.1)', backdropColor: 'rgba(0,0,0,0.2)', fadeColor: '#f1f5f9', fontSize: '18px', fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', } <CollapsiblePicker theme={iosTheme} />
Design system integration:
// Match your existing design tokens const theme = { activeTextColor: 'var(--color-primary)', closedBorderColor: 'var(--color-border-focus)', closedBackgroundColor: 'var(--color-surface)', labelColor: 'var(--color-text-secondary)', fontSize: 'var(--font-size-lg)', fontFamily: 'var(--font-sans)', } <CollapsiblePicker theme={theme} />
Use buildTheme for type-safe overrides:
import { buildTheme } from '@tensil/kinetic-input' const myTheme = buildTheme({ activeTextColor: '#ff0000', // Unspecified properties use DEFAULT_THEME }) <CollapsiblePicker theme={myTheme} />
Match modal background:
// If your picker opens in a yellow modal <div className="bg-yellow-400"> <CollapsiblePicker theme={{ fadeColor: '#facc15', // yellow-400 closedBackgroundColor: 'rgba(250,204,21,0.9)', backdropColor: 'rgba(250,204,21,0.3)', }} /> </div>
Dark mode toggle:
const lightTheme = { activeTextColor: '#2563eb', closedBorderColor: 'rgba(37,99,235,0.5)', fadeColor: '#ffffff', } const darkTheme = { activeTextColor: '#60a5fa', closedBorderColor: 'rgba(96,165,250,0.5)', fadeColor: '#0a0b0d', } <CollapsiblePicker theme={isDark ? darkTheme : lightTheme} />
Brutalist high contrast:
<CollapsiblePicker theme={{ activeTextColor: '#000000', textColor: '#000000', closedBorderColor: '#000000', closedBackgroundColor: '#ffff00', highlightBorderColor: '#000000', fadeColor: '#ffff00', fontSize: '28px', fontFamily: '"Courier New", monospace', }} />
| Interaction | Timeout | Notes |
|---|---|---|
| Pointer drag released | 150 ms | Ideal for quick scrubs (settleGracePeriod) |
| Wheel / trackpad scroll | 800 ms | Allows momentum to finish (wheelIdleTimeout) |
| Idle (no interactions) | 2.5 s | Auto-closes after browsing (idleTimeout) |
| ESC / click outside | Immediate | Hard close via state machine |
Timing Presets: The default "balanced" preset is shown above. Other presets available:
instant: 50ms/300ms/1.5s (fast data entry)fast: 100ms/500ms/2.5s (desktop workflows - same as balanced)balanced: 150ms/800ms/2.5s (default - general use)patient: 300ms/1200ms/6s (mobile/accessibility)
The BOUNDARY_SETTLE_DELAY constant (150 ms) is exported for tweaking the overscroll bounce timing.
const [isOpen, setIsOpen] = useState(false) const [reps, setReps] = useState(10) <CollapsiblePicker label="Reps" value={reps} onChange={setReps} isOpen={isOpen} onRequestOpen={() => setIsOpen(true)} onRequestClose={() => setIsOpen(false)} enableSnapPhysics snapPhysicsConfig={{ snapRange: 0.2, pullStrength: 0.55 }} />
The hook uses integer scaling, so step={0.1} or step={0.125} produces 0.3 not 0.3000000004. The number of decimals is inferred from min, max, and step, and every value is formatted consistently.
Debug logging is disabled by default to prevent console spam. Enable it when needed:
In browser console:
window.__QNI_DEBUG__ = true; // CollapsiblePicker events window.__QNI_PICKER_DEBUG__ = true; // Picker physics & pointer events window.__QNI_SNAP_DEBUG__ = true; // Snap physics calculations window.__QNI_STATE_DEBUG__ = true; // State machine transitions window.__QNI_WHEEL_DEBUG__ = true; // Wheel picker events window.__QNI_ANIMATION_DEBUG__ = true; // Animation lifecycle // Then reload the page location.reload();
Programmatically (before app initialization):
// Set debug flags before your app loads if (typeof window !== 'undefined' && import.meta.env.DEV) { window.__QNI_DEBUG__ = true; window.__QNI_SNAP_DEBUG__ = true; // ... set other flags as needed }
Control auto-close behavior with presets:
<CollapsiblePicker timingPreset="fast" // Quick auto-close timing // Available: "instant", "fast", "balanced" (default), "patient" />
Auto-detect based on device + user preferences:
import { getRecommendedTiming } from '@tensil/kinetic-input/config'; // Auto-selects timing based on: // - prefers-reduced-motion setting // - Touch device detection // - Screen size (mobile vs desktop) <CollapsiblePicker timingPreset={getRecommendedTiming()} />
<CollapsiblePicker timingConfig={{ settleGracePeriod: 200, // ms after pointer release wheelIdleTimeout: 1000, // ms after wheel scroll idleTimeout: 2000, // ms for multi-gesture browsing }} />
Enable magnetic snapping for slow drags:
<CollapsiblePicker enableSnapPhysics snapPhysicsConfig={{ snapRange: 0.3, // 30% of item height pullStrength: 0.6, // Magnetic strength (0-1) velocityThreshold: 120, // px/s to override snap }} />
See the components in action at the live demo site or run it locally:
# From repo root npm install npm run build # Build the library first npm run dev:demo # Run demo at http://localhost:3001
The demo app lives in demo/ and showcases all features with an interactive code playground.
This is an npm workspaces monorepo with unified tooling and configuration:
Kinetic-Input/ # Root workspace
├── packages/
│ └── number-picker/ # Core library (@tensil/kinetic-input npm package)
│ ├── src/ # Source code
│ ├── dist/ # Build output (gitignored)
│ ├── package.json # Package dependencies & scripts
│ ├── tsconfig.json # TypeScript config (extends root)
│ ├── tsup.config.ts # Build config (ESM bundles)
│ └── vitest.config.ts # Test config
├── demo/ # Demo application (Vite + React)
│ ├── src/ # Demo source
│ ├── dist/ # Build output (gitignored)
│ ├── package.json # Demo dependencies & scripts
│ ├── vite.config.ts # Vite config with HMR for library
│ └── tsconfig.json # TypeScript config (extends root)
├── package.json # Root workspace orchestration
├── tsconfig.json # Base TypeScript config
├── .oxlintrc.json # Unified linting rules
├── .lintstagedrc.json # Pre-commit linting
└── .husky/ # Git hooks
Unified at root level (single source of truth):
- ✅ Linting (oxlint) - Same rules for library & demo
- ✅ Type checking (TypeScript base config) - Shared compiler options
- ✅ Git hooks (husky) - Pre-commit quality gates
- ✅ Dev dependencies - Shared testing & linting tools
Separate per workspace (domain-specific):
- 📦 Build tools -
tsupfor library,Vitefor demo app - 📦 Runtime dependencies - Demo uses Tailwind, library doesn't
- 📦 Scripts - Each workspace has its own dev/build workflows
This structure enables:
- Consistency - Same lint & type rules across all code
- Efficiency - Shared
node_modulesfor faster installs - Flexibility - Each workspace optimized for its purpose
- Portability - Library can be easily extracted if needed
# Install all workspace dependencies npm install # Start demo with live library reloading npm run dev:demo # Open http://localhost:3001
| Command | Description |
|---|---|
npm run dev |
Alias for dev:demo - start demo |
npm run build |
Build library package only |
npm run build:demo |
Build demo for production |
npm run build:all |
Build both library and demo |
npm test |
Run all tests |
npm run lint |
Lint all code with oxlint |
npm run lint:fix |
Auto-fix linting issues |
npm run typecheck |
Type check all workspaces |
npm run validate |
Full check (typecheck + lint + test) |
Working on the library:
# Edit files in packages/number-picker/src/ # Tests run automatically via git hooks npm test # Run all tests manually npm run test:ui # Open Vitest UI for TDD
Working on the demo:
npm run dev:demo # Start with HMR # Edit files in demo/src/ # Changes hot-reload automatically
Library changes reflect in demo instantly via Vite workspace configuration (no rebuild needed during development).
Tests are in the library package only (demo is a showcase, not production code):
npm test # Run all tests npm run test:ui # Interactive UI npm run test:coverage # Coverage report
All tests must pass before commits (enforced by git hooks).
Pre-commit hooks automatically:
- ✅ Lint staged files with oxlint
- ✅ Type check all workspaces (manual:
npm run typecheck) - ✅ Run tests on changed files (manual:
npm test)
npm run lint # Check all files npm run lint:fix # Auto-fix issues npm run lint:dead-code # Find unused exports
This library works in all modern browsers with:
- Desktop: Chrome 90+, Firefox 88+, Safari 14+, Edge 90+
- Mobile: iOS 14+, Android Chrome 90+
- Required APIs: Pointer Events, CSS Grid (supported by all modern browsers)
See ARCHITECTURE.md for detailed compatibility information.
MIT - See LICENSE for details.