High-performance React number picker components with momentum scrolling, smart auto-close, and full theming support
npm install @tensil/kinetic-inputHigh-performance numeric scrubber components for React. The package exposes:
- CollapsiblePicker – animated momentum picker with modal expansion
- Picker – lightweight list/range picker without modal chrome
- PickerGroup – bare-bones wheel primitive that powers both components
- Supporting hooks, theme builders, and configuration presets
All component docs now live in this README.
This package is in active development (v0.x). We're publishing early to gather real-world feedback and validate the API design.
What this means:
- ✅ Production-ready code: All tests passing, comprehensive documentation, no known bugs
- ⚠️ API may change: Breaking changes can occur between minor versions (0.1 → 0.2) until we reach v1.0
- 🐛 Report issues: Found a bug or have feedback? Open an issue
We'll follow semantic versioning once we hit v1.0.0. Until then, pin to exact versions or use ~0.1.0 in your package.json to avoid unexpected breaking changes.
``bash`
npm install @tensil/kinetic-inputor
yarn add @tensil/kinetic-input
Peer 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)
`tsx`
import '@tensil/kinetic-input/styles/all.css'
Option 2: Granular imports (for optimization)
`tsx`
// 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.
`tsx
import CollapsiblePicker from '@tensil/kinetic-input'
export function WeightField() {
const [weight, setWeight] = useState(70)
return (
value={weight}
onChange={setWeight}
unit="kg"
min={40}
max={200}
step={0.5}
/>
)
}
`
Need lower-level control? Import the named utilities:
`ts`
import {
CollapsiblePicker,
Picker,
PickerGroup,
DEFAULT_THEME,
buildTheme,
BOUNDARY_SETTLE_DELAY,
} from '@tensil/kinetic-input'
`tsx
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 (
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 | - | 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 | defaults | Override snap parameters |wheelSensitivity
| | number | 1 | Wheel/trackpad scroll speed multiplier. Raise it (>1) to make slow trackpads move further per gesture, lower it (<1) to tame hypersensitive hardware. |wheelDeltaCap
| | number | 1.25 | Upper bound (in rows) per wheel frame to prevent touchpad spikes from skipping multiple rows. Excess delta is carried over to the next frame so fast scrubs stay responsive. |enableHaptics
| | boolean | true | Vibration feedback on selection (mobile) |enableAudioFeedback
| | boolean | true | Audio clicks on selection |feedbackConfig
| | QuickPickerFeedbackConfig | - | Override audio/haptic adapters, patterns, or disable features per instance |
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.
#### Theme Interface
`ts
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
}
`
#### Default Theme
`ts
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)
}
`
#### Custom Themes
Minimal override (just accent color):
`tsx`
onChange={setWeight}
theme={{
activeTextColor: '#10b981', // Green-500
closedBorderColor: '#10b981',
highlightBorderColor: '#10b981',
}}
/>
The package ships two scoped style sheets:
- quick-number-input.css – used by CollapsiblePickerwheel-picker.css
- – used by Picker
Both root selectors (.quick-number-input-root and .np-wheel-picker) define a
small set of CSS custom properties. Everything else is expressed relative to
those tokens, so theming the component means touching a handful of values instead
of copy/pasting large swaths of CSS.
#### Quick number input tokens
| Token | Purpose |
|-------|---------|
| --qni-row-height | Controls each row’s height and the highlight band thickness |--qni-visible-rows
| | Sets the viewport height (defaults to 5 rows) |--qni-font-family
| / --qni-font-size | Typography for rows and the closed value |--qni-unit-font-family
| / --qni-unit-font-size | Typography for the value suffix ("kg", "lbs") |--qni-gap
| / --qni-padding-inline | Spacing between value + unit and the row padding |--qni-color-muted
| / --qni-color-active | Non-selected vs. selected text color |--qni-color-unit
| | Unit label color in both open and closed states |--qni-highlight-fill
| | Semi-transparent fill that sits behind the center row |--qni-fade-color
| | Top/bottom gradient color for the ambient fades |--qni-backdrop-color
| | Full-screen scrim color when the picker is modal |--qni-active-scale
| / --qni-selected-scale | Scale factor for the focused row vs. the surrounding trail |--qni-selected-opacity
| | Dimmed opacity for the previously selected row |--qni-accent-letter-spacing
| / --qni-accent-shadow | Shared accent text cosmetics for both states |--qni-chevron-size
| | Closed-state chevron icon size |--qni-viewport-offset
| | Derived placement for fades + highlight (auto-calculated) |
The presenter sets --qni-row-height/--qni-visible-rows at runtime soitemHeight
highlight math automatically tracks your + visibleItems props.calc(((visibleRows - 1) / 2) * rowHeight)
Geometry is derived from those tokens. For example, the highlight band is placed
with so the math stays correct even
when you change the number of visible rows.
Structural selectors:
- .picker-surface and .picker-container – wrap the scrollable column.picker-item
- , .picker-item-active, .picker-item-selected – individual rows.picker-item-unit
- and .qni-unit – unit text in both states
Overlay selectors:
- .picker-highlight-fill / .picker-highlight-hitbox – selection band & click.picker-fade-top
target
- / .picker-fade-bottom – ambient fades above/below the--qni-fade-color
list, tinted by .picker-backdrop
- – optional modal scrim (--qni-backdrop-color)
The closed state is scoped under .quick-number-input-root, so it reuses the
same font + unit tokens and never leaks global selectors.
#### Standalone wheel tokens
Picker exposes matching variables on .np-wheel-picker. The
component only reads:
- --np-wheel-item-height--np-wheel-font-family
- --np-wheel-font-size
- --np-wheel-color
- --np-wheel-accent-color
- --np-wheel-unit-color
- --np-wheel-unit-font-size
- --np-wheel-gap
- --np-wheel-padding-inline
- --np-wheel-ease
- --np-wheel-active-scale
- --np-wheel-active-weight
- --np-wheel-transition
-
Override those to customize spacing, fonts, and accent colors without touching
the internal selectors.
- Scoped selectors only. Both style sheets hang entirely off their root
class, so they never trigger restyles elsewhere in the host app.
- Minimal custom properties. Only geometry, typography, and color tokens are
exposed; animation timing and scaling stay constant to avoid recalculating
transitions on every render.
- Shared typography. The open and closed states reference the same font
tokens, cutting duplicate declarations and ensuring text is only painted once
per change.
- Reduced stacking contexts. Overlay/fade elements share absolute-positioning
rules via :where(...), which trims selector cost and keeps the layer tree--qni-row-height
shallow.
- Automatic layout math. The highlight position and fade heights are derived
from /--qni-visible-rows, so changing row counts doesn’tprefers-reduced-motion
require extra DOM reads or manual CSS overrides.
- Respect . Both pickers disable their scale
animations when the OS requests reduced motion, preventing unnecessary paints
while keeping colors and layout intact.
Complete custom theme:
`tsx
// 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',
}
`
Design system integration:
`tsx
// 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)',
}
`
#### Theme Builder
Use buildTheme for type-safe overrides:
`tsx
import { buildTheme } from '@tensil/kinetic-input'
const myTheme = buildTheme({
activeTextColor: '#ff0000',
// Unspecified properties use DEFAULT_THEME
})
`
#### Common Patterns
Match modal background:
`tsx`
// If your picker opens in a yellow modal
fadeColor: '#facc15', // yellow-400
closedBackgroundColor: 'rgba(250,204,21,0.9)',
backdropColor: 'rgba(250,204,21,0.3)',
}}
/>
Dark mode toggle:
`tsx
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',
}
`
Brutalist high contrast:
`tsx`
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 idle timeout 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.
`tsx
const [isOpen, setIsOpen] = useState(false)
const [reps, setReps] = useState(10)
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:
`javascript
window.__QNI_DEBUG__ = true; // CollapsiblePicker events
window.__QNI_SNAP_DEBUG__ = true; // Snap physics calculations
window.__QNI_STATE_DEBUG__ = true; // State machine transitions
window.__QNI_WHEEL_DEBUG__ = true; // Picker events
// Then reload the page
location.reload();
`
Programmatically (before app initialization):
`typescript`
// 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:
`tsx`
// Available: "instant", "fast", "balanced" (default), "patient"
/>
Auto-detect based on device + user preferences:
`typescript
import { getRecommendedTiming } from '@tensil/kinetic-input/config';
`
`tsx`
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:
`tsx`
snapPhysicsConfig={{
snapRange: 0.3, // 30% of item height
pullStrength: 0.6, // Magnetic strength (0-1)
velocityThreshold: 120, // px/s to override snap
rangeScaleIntensity: 0.12, // Base flick projection window (seconds)
rangeScaleVelocityBoost: 1.25, // Multiply projection once velocity crosses the threshold
rangeScaleVelocityCap: 3200, // Clamp release velocity (px/s)
}}
/>
The release scaler works in two stages:
1. Base projection (rangeScaleIntensity) gives every flick ~120 ms of extra coast, so a 500 px/s scrub glides ~60 px after you let go.
2. Velocity boost (rangeScaleVelocityBoost) measures how far the release speed exceeds velocityThreshold and multiplies the projection window up to (1 + boost)x. Faster flicks now reliably skip more values instead of instantly snapping back.
Pair the boost with rangeScaleVelocityCap if you want to keep runaway scroll wheels from skipping the entire dataset.
This package lives in a monorepo. From repo root:
| Command | Description |
| ------- | ----------- |
| npm run build:number-picker | Bundle ESM/CJS + types |npm run dev
| | Run demo app with HMR |
Changes in packages/number-picker/src hot-reload in the dev app via Vite path aliases.
See LICENSE for details.
feedbackConfig exposes a single object for tuning sound/vibration without reaching into internal hooks:
`tsx`
value={72}
onChange={setSpeed}
unit="mph"
feedbackConfig={{
enableAudioFeedback: false, // disable audio globally for this picker
haptics: { pattern: [8, 4, 8] }, // custom vibrate pattern per tick
audio: { frequency: 660, waveform: 'sine' },
adapters: { // inject bespoke adapters if you already own a feedback system
audio: customAudioAdapter,
},
}}
/>
QuickPickerFeedbackConfig mirrors the exported adapter options:
`ts``
type QuickPickerFeedbackConfig = {
enableHaptics?: boolean; // override legacy props per instance
enableAudioFeedback?: boolean;
haptics?: { pattern?: number | number[] };
audio?: {
frequency?: number;
waveform?: OscillatorType;
attackMs?: number;
decayMs?: number;
durationMs?: number;
peakGain?: number;
};
adapters?: {
haptics?: HapticAdapter | null;
audio?: AudioAdapter | null;
};
};
When you provide adapters the built-in modules are never instantiated, so host apps can plug into shared audio/haptic controllers or stub them entirely for tests.