npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@tensil/kinetic-input

v0.0.6

Published

High-performance React number picker components with momentum scrolling, smart auto-close, and full theming support

Readme

@tensil/kinetic-input

High-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.

⚠️ Beta Release Notice

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.

Installation

npm install @tensil/kinetic-input
# or
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)

CSS Import (Required)

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.

Usage

CollapsiblePicker

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'

Picker example

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"
    />
  )
}

CollapsiblePicker Features

  • 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

Props

| 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. 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 |

Theming

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

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

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):

<CollapsiblePicker
  value={weight}
  onChange={setWeight}
  theme={{
    activeTextColor: '#10b981',        // Green-500
    closedBorderColor: '#10b981',
    highlightBorderColor: '#10b981',
  }}
/>

CSS surface area

The package ships two scoped style sheets:

  • quick-number-input.css – used by CollapsiblePicker
  • wheel-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 so highlight math automatically tracks your itemHeight + visibleItems props. Geometry is derived from those tokens. For example, the highlight band is placed with calc(((visibleRows - 1) / 2) * rowHeight) 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 target
  • .picker-fade-top / .picker-fade-bottom – ambient fades above/below the list, tinted by --qni-fade-color
  • .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.

Performance

  • 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 shallow.
  • Automatic layout math. The highlight position and fade heights are derived from --qni-row-height/--qni-visible-rows, so changing row counts doesn’t require extra DOM reads or manual CSS overrides.
  • Respect prefers-reduced-motion. Both pickers disable their scale animations when the OS requests reduced motion, preventing unnecessary paints while keeping colors and layout intact.

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} />

Theme Builder

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} />

Common Patterns

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',
  }}
/>

Auto-Close Behavior

| 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.

Controlled Mode

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 }}
/>

Decimal Precision

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.

Debugging

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_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):

// 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
}

Advanced Configuration

Timing Presets

Control auto-close behavior with presets:

<CollapsiblePicker
  timingPreset="fast"    // 100ms pointer, 500ms wheel, 2.5s idle
  // Available: "instant", "fast", "balanced" (default), "patient"
/>

Auto-detect based on device + user preferences:

import { getRecommendedTiming } from '@tensil/kinetic-input/config';

<CollapsiblePicker timingPreset={getRecommendedTiming()} />

Custom Timing

<CollapsiblePicker
  timingConfig={{
    settleGracePeriod: 200,  // ms after pointer release
    wheelIdleTimeout: 1000,  // ms after wheel scroll
    idleTimeout: 2000,       // ms for multi-gesture browsing
  }}
/>

Snap Physics

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
    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.

Local Development

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.

License

See LICENSE for details.

Audio & Haptic Configuration

feedbackConfig exposes a single object for tuning sound/vibration without reaching into internal hooks:

<CollapsiblePicker
  label="Speed"
  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:

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.