Animated Number Rails Components
Create engaging animated number displays with smooth slot-machine style animations. Perfect for statistics, counters, dashboards, and data visualization.
Installation
1. Stimulus Controller Setup
Start by adding the following controller to your project:
import { Controller } from "@hotwired/stimulus"; import "number-flow"; import { continuous } from "number-flow"; export default class extends Controller { static values = { start: { type: Number, default: 0 }, // Start value of the animation end: { type: Number, default: 0 }, // End value of the animation duration: { type: Number, default: 700 }, // Duration of the animation for each number change trigger: { type: String, default: "viewport" }, // Trigger for the animation (load, viewport, manual) prefix: String, // Prefix for the number suffix: String, // Suffix for the number formatOptions: String, // Go here to learn more: https://number-flow.barvian.me/vanilla#properties trend: Number, // Trend for the animation realtime: { type: Boolean, default: false }, // If true, uses interval for timed updates updateInterval: { type: Number, default: 1000 }, // Interval in ms for realtime updates continuous: { type: Boolean, default: true }, // If true, uses continuous plugin for smooth transitions spinEasing: { type: String, default: "ease-in-out" }, // Easing for digit spin animations transformEasing: { type: String, default: "ease-in-out" }, // Easing for layout transforms opacityEasing: { type: String, default: "ease-out" }, // Easing for fade in/out }; connect() { this.element.innerHTML = "<number-flow></number-flow>"; this.flow = this.element.querySelector("number-flow"); this.currentValue = this.startValue || 0; // Set initial properties from data attributes if (this.hasPrefixValue) this.flow.numberPrefix = this.prefixValue; if (this.hasSuffixValue) this.flow.numberSuffix = this.suffixValue; if (this.hasFormatOptionsValue) { try { this.flow.format = JSON.parse(this.formatOptionsValue); } catch (e) { console.error("Error parsing formatOptions JSON:", e); // Apply default or no formatting if parsing fails } } if (this.hasTrendValue) { this.flow.trend = this.trendValue; } else { // Default trend for continuous plugin if not specified this.flow.trend = Math.sign(this.endValue - this.currentValue) || 1; } // Initialize with start value without animation for non-realtime, or let realtime handle first update if (!this.realtimeValue) { this.flow.update(this.currentValue); } // Configure timing with customizable easing this.configureTimings(); // Apply continuous plugin if enabled if (this.continuousValue) { this.flow.plugins = [continuous]; } this.handleTrigger(); } configureTimings() { const animationDuration = this.durationValue || 700; // Configure spin timing (for digit animations) this.flow.spinTiming = { duration: animationDuration, easing: this.spinEasingValue, }; // Configure transform timing (for layout changes) this.flow.transformTiming = { duration: animationDuration, easing: this.transformEasingValue, }; // Configure opacity timing (for fade effects) this.flow.opacityTiming = { duration: 350, easing: this.opacityEasingValue, }; } handleTrigger() { const trigger = this.triggerValue || "viewport"; switch (trigger) { case "load": this.startAnimation(); break; case "viewport": this.observeViewport(); break; case "manual": // Don't auto-start for manual trigger break; default: this.startAnimation(); break; } } startAnimation() { if (this.realtimeValue) { this.flow.update(this.currentValue); // Initial update for realtime this.timerInterval = setInterval(() => { this.tick(); }, this.updateIntervalValue); } else { this.animateToEnd(); } } tick() { const step = Math.sign(this.endValue - this.startValue) || (this.startValue > this.endValue ? -1 : 1); this.currentValue += step; this.flow.update(this.currentValue); if ((step > 0 && this.currentValue >= this.endValue) || (step < 0 && this.currentValue <= this.endValue)) { clearInterval(this.timerInterval); } } animateToEnd() { if (!this.flow) return; // For non-realtime, the duration value from HTML (or default) applies to the whole animation. // Reconfigure timings if duration was specifically set for the full range if (!this.realtimeValue && this.hasDurationValue) { const overallDuration = this.durationValue || 2000; this.flow.spinTiming = { duration: overallDuration, easing: this.spinEasingValue, }; this.flow.transformTiming = { duration: overallDuration, easing: this.transformEasingValue, }; this.flow.opacityTiming = { duration: 350, easing: this.opacityEasingValue, }; } this.flow.update(this.endValue); } observeViewport() { this.observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { this.startAnimation(); this.observer.unobserve(this.element); } }); }); this.observer.observe(this.element); } // Method for manual triggering (can be called externally) triggerAnimation() { // Reset to start value first this.currentValue = this.startValue || 0; this.flow.update(this.currentValue); // Small delay to ensure reset is visible setTimeout(() => { this.startAnimation(); }, 50); } disconnect() { if (this.timerInterval) { clearInterval(this.timerInterval); } if (this.flow && typeof this.flow.destroy === "function") { // Assuming number-flow might have a cleanup/destroy method // If not, this line might need adjustment based on the library's API // For now, we'll assume it doesn't to prevent errors if 'destroy' doesn't exist. } if (this.observer) { this.observer.disconnect(); } } }
2. Number Flow Installation
The animated number component relies on Number Flow for smooth slot-machine style animations. Choose your preferred installation method:
pin "number-flow", to: "https://esm.sh/number-flow" pin "number-flow/group", to: "https://esm.sh/number-flow/group"
npm install number-flow
yarn add number-flow
Simple Examples
Basic animated number
A simple animated number that counts from a start value to an end value on page load.
Users registered
<div class="space-y-4"> <div class="text-center"> <div class="text-4xl font-bold text-neutral-800 dark:text-neutral-200 mb-2"> <span data-controller="animated-number" data-animated-number-start-value="0" data-animated-number-end-value="1250" data-animated-number-duration-value="2000" ></span> </div> <p class="text-sm text-neutral-600 dark:text-neutral-400">Users registered</p> </div> </div>
Formatted currency
An animated number formatted as currency with prefix and suffix support.
Monthly revenue
<div class="space-y-4"> <div class="text-center"> <div class="text-4xl font-bold text-neutral-800 dark:text-neutral-200 mb-2"> <span data-controller="animated-number" data-animated-number-start-value="50" data-animated-number-end-value="850.99" data-animated-number-duration-value="2500" data-animated-number-format-options-value='{"style":"currency","currency":"USD","minimumFractionDigits":2}' data-animated-number-suffix-value=" / month" ></span> </div> <p class="text-sm text-neutral-600 dark:text-neutral-400">Monthly revenue</p> </div> </div>
Compact notation for large numbers
Large numbers displayed in compact format (K, M, B) for better readability.
Total downloads
<div class="space-y-4"> <div class="text-center"> <div class="text-4xl font-bold text-neutral-800 dark:text-neutral-200 mb-2"> <span data-controller="animated-number" data-animated-number-start-value="0" data-animated-number-end-value="125600" data-animated-number-duration-value="2000" data-animated-number-format-options-value='{"notation":"compact","compactDisplay":"short"}' ></span> </div> <p class="text-sm text-neutral-600 dark:text-neutral-400">Total downloads</p> </div> </div>
Percentage growth with prefix/suffix
Growth percentages with custom prefix and suffix formatting.
Year-over-year growth
<div class="space-y-4"> <div class="text-center"> <div class="text-4xl font-bold text-neutral-800 dark:text-neutral-200 mb-2"> <span data-controller="animated-number" data-animated-number-start-value="0" data-animated-number-end-value="147.5" data-animated-number-duration-value="2800" data-animated-number-format-options-value='{"minimumFractionDigits":1,"maximumFractionDigits":1}' data-animated-number-suffix-value="%" data-animated-number-prefix-value="+" ></span> </div> <p class="text-sm text-neutral-600 dark:text-neutral-400">Year-over-year growth</p> </div> </div>
Continuous vs discrete animation
Compare continuous smooth transitions with discrete slot-machine style animations.
Continuous Animation
Discrete Animation
<div class="space-y-6"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <!-- Continuous Animation --> <div class="text-center p-6 bg-neutral-50 dark:bg-neutral-800/50 rounded-lg border border-black/5 dark:border-white/10"> <h4 class="text-sm font-medium text-neutral-600 dark:text-neutral-400 mb-4">Continuous Animation</h4> <div class="text-3xl font-bold text-neutral-900 dark:text-white mb-2" data-controller="animated-number" data-animated-number-start-value="0" data-animated-number-end-value="100" data-animated-number-duration-value="1500" data-animated-number-continuous-value="true"> </div> </div> <!-- Discrete Animation --> <div class="text-center p-6 bg-neutral-50 dark:bg-neutral-800/50 rounded-lg border border-black/5 dark:border-white/10"> <h4 class="text-sm font-medium text-neutral-600 dark:text-neutral-400 mb-4">Discrete Animation</h4> <div class="text-3xl font-bold text-neutral-900 dark:text-white mb-2" data-controller="animated-number" data-animated-number-start-value="0" data-animated-number-end-value="100" data-animated-number-duration-value="1500" data-animated-number-continuous-value="false"> </div> </div> </div> </div>
Easing examples
Compare different easing functions for the animation.
Linear
Constant speed
Ease-in-out
Slow start & end
Bounce
Overshoots & settles
Ease-out
Fast start, slow end
Ease-in
Slow start, fast end
Spring
Natural spring motion
💡 Click any number above to replay its animation
<div class="space-y-8"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <!-- Linear Easing --> <div class="group"> <h4 class="text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-3">Linear</h4> <div class="bg-neutral-50 dark:bg-neutral-800/50 rounded-lg p-4 text-center group-hover:bg-neutral-100 border border-black/5 dark:border-white/10 dark:group-hover:bg-neutral-700 transition-colors cursor-pointer" onclick="this.querySelector('[data-controller]')?.click()"> <button data-controller="animated-number" data-animated-number-start-value="0" data-animated-number-end-value="1000" data-animated-number-duration-value="2000" data-animated-number-spin-easing-value="linear" data-animated-number-transform-easing-value="linear" data-animated-number-trigger-value="viewport" data-action="click->animated-number#triggerAnimation" class="text-3xl font-bold text-neutral-900 dark:text-white"> </button> <p class="text-xs text-neutral-500 dark:text-neutral-400 mt-2">Constant speed</p> </div> </div> <!-- Ease-in-out (Default) --> <div class="group"> <h4 class="text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-3">Ease-in-out</h4> <div class="bg-neutral-50 dark:bg-neutral-800/50 rounded-lg p-4 text-center group-hover:bg-neutral-100 border border-black/5 dark:border-white/10 dark:group-hover:bg-neutral-700 transition-colors cursor-pointer" onclick="this.querySelector('[data-controller]')?.click()"> <button data-controller="animated-number" data-animated-number-start-value="0" data-animated-number-end-value="1000" data-animated-number-duration-value="2000" data-animated-number-spin-easing-value="ease-in-out" data-animated-number-transform-easing-value="ease-in-out" data-animated-number-trigger-value="viewport" data-action="click->animated-number#triggerAnimation" class="text-3xl font-bold text-neutral-900 dark:text-white"> </button> <p class="text-xs text-neutral-500 dark:text-neutral-400 mt-2">Slow start & end</p> </div> </div> <!-- Cubic Bezier - Bounce --> <div class="group"> <h4 class="text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-3">Bounce</h4> <div class="bg-neutral-50 dark:bg-neutral-800/50 rounded-lg p-4 text-center group-hover:bg-neutral-100 border border-black/5 dark:border-white/10 dark:group-hover:bg-neutral-700 transition-colors cursor-pointer" onclick="this.querySelector('[data-controller]')?.click()"> <button data-controller="animated-number" data-animated-number-start-value="0" data-animated-number-end-value="1000" data-animated-number-duration-value="2000" data-animated-number-spin-easing-value="cubic-bezier(0.68, -0.55, 0.265, 1.55)" data-animated-number-transform-easing-value="cubic-bezier(0.68, -0.55, 0.265, 1.55)" data-animated-number-trigger-value="viewport" data-action="click->animated-number#triggerAnimation" class="text-3xl font-bold text-neutral-900 dark:text-white"> </button> <p class="text-xs text-neutral-500 dark:text-neutral-400 mt-2">Overshoots & settles</p> </div> </div> <!-- Ease-out --> <div class="group"> <h4 class="text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-3">Ease-out</h4> <div class="bg-neutral-50 dark:bg-neutral-800/50 rounded-lg p-4 text-center group-hover:bg-neutral-100 border border-black/5 dark:border-white/10 dark:group-hover:bg-neutral-700 transition-colors cursor-pointer" onclick="this.querySelector('[data-controller]')?.click()"> <button data-controller="animated-number" data-animated-number-start-value="0" data-animated-number-end-value="1000" data-animated-number-duration-value="2000" data-animated-number-spin-easing-value="ease-out" data-animated-number-transform-easing-value="ease-out" data-animated-number-trigger-value="viewport" data-action="click->animated-number#triggerAnimation" class="text-3xl font-bold text-neutral-900 dark:text-white"> </button> <p class="text-xs text-neutral-500 dark:text-neutral-400 mt-2">Fast start, slow end</p> </div> </div> <!-- Ease-in --> <div class="group"> <h4 class="text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-3">Ease-in</h4> <div class="bg-neutral-50 dark:bg-neutral-800/50 rounded-lg p-4 text-center group-hover:bg-neutral-100 border border-black/5 dark:border-white/10 dark:group-hover:bg-neutral-700 transition-colors cursor-pointer" onclick="this.querySelector('[data-controller]')?.click()"> <button data-controller="animated-number" data-animated-number-start-value="0" data-animated-number-end-value="1000" data-animated-number-duration-value="2000" data-animated-number-spin-easing-value="ease-in" data-animated-number-transform-easing-value="ease-in" data-animated-number-trigger-value="viewport" data-action="click->animated-number#triggerAnimation" class="text-3xl font-bold text-neutral-900 dark:text-white"> </button> <p class="text-xs text-neutral-500 dark:text-neutral-400 mt-2">Slow start, fast end</p> </div> </div> <!-- Spring-like --> <div class="group"> <h4 class="text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-3">Spring</h4> <div class="bg-neutral-50 dark:bg-neutral-800/50 rounded-lg p-4 text-center group-hover:bg-neutral-100 border border-black/5 dark:border-white/10 dark:group-hover:bg-neutral-700 transition-colors cursor-pointer" onclick="this.querySelector('[data-controller]')?.click()"> <button data-controller="animated-number" data-animated-number-start-value="0" data-animated-number-end-value="1000" data-animated-number-duration-value="2500" data-animated-number-spin-easing-value="cubic-bezier(0.175, 0.885, 0.32, 1.275)" data-animated-number-transform-easing-value="cubic-bezier(0.175, 0.885, 0.32, 1.275)" data-animated-number-trigger-value="viewport" data-action="click->animated-number#triggerAnimation" class="text-3xl font-bold text-neutral-900 dark:text-white"> </button> <p class="text-xs text-neutral-500 dark:text-neutral-400 mt-2">Natural spring motion</p> </div> </div> </div> <!-- Click any number to replay animation --> <div class="text-center mt-8"> <p class="text-sm text-neutral-500 dark:text-neutral-400"> 💡 Click any number above to replay its animation </p> </div> </div> <script> // Add trigger method to animated-number controller if it doesn't exist document.addEventListener('DOMContentLoaded', function() { const AnimatedNumberController = window.Application?.getControllerForElementAndIdentifier?.( document.querySelector('[data-controller*="animated-number"]'), 'animated-number' )?.constructor; if (AnimatedNumberController && !AnimatedNumberController.prototype.startAnimation) { AnimatedNumberController.prototype.startAnimation = function() { // Reset and start animation this.currentValue = this.startValue || 0; this.flow.update(this.currentValue); setTimeout(() => { this.animateFullRange(); }, 50); }; } }); </script>
Page load triggered animation
Numbers that animate as soon as the page loads.
Lines of code
Press "Refresh" button to trigger animation
<div class="space-y-4"> <div class="text-center"> <div class="text-5xl font-bold mb-2"> <span data-controller="animated-number" data-animated-number-start-value="0" data-animated-number-end-value="100000" data-animated-number-duration-value="3500" data-animated-number-trigger-value="load" data-animated-number-suffix-value="+" ></span> </div> <p class="text-sm text-neutral-800 dark:text-neutral-200">Lines of code</p> <p class="text-xs text-neutral-600 dark:text-neutral-400 mt-2">Press "Refresh" button to trigger animation</p> </div> </div>
Realtime countdown timer
A countdown timer that decrements in real-time with each tick representing a real second.
Countdown timer
<div class="space-y-4"> <div class="text-center"> <div class="text-4xl font-bold text-neutral-800 dark:text-neutral-200 mb-2"> <span data-controller="animated-number" data-animated-number-start-value="10" data-animated-number-end-value="0" data-animated-number-duration-value="500" data-animated-number-trend-value="-1" data-animated-number-format-options-value='{"minimumIntegerDigits":2}' data-animated-number-suffix-value="s" data-animated-number-realtime-value="true" data-animated-number-update-interval-value="1000" ></span> </div> <p class="text-sm text-neutral-600 dark:text-neutral-400">Countdown timer</p> </div> </div>
Configuration
The animated number component is powered by Number Flow and provides smooth slot-machine style animations with flexible formatting options through a Stimulus controller.
Controller Setup
Basic animated number structure with required data attributes:
<span data-controller="animated-number" data-animated-number-start-value="0" data-animated-number-end-value="1000" data-animated-number-duration-value="2000" data-animated-number-trigger-value="viewport"> </span>
Configuration Values
Prop | Description | Type | Default |
---|---|---|---|
start | The starting value for the animation | Number | 0 |
end | The ending value for the animation | Number | Required |
duration | Animation duration in milliseconds | Number | 700 |
trigger | When to trigger the animation (load, viewport, manual) | String | viewport |
prefix | Text to display before the number | String | None |
suffix | Text to display after the number | String | None |
formatOptions | Number formatting options (Intl.NumberFormat) | String (JSON) | None |
trend | Direction trend for continuous animation (-1, 0, 1) | Number | Auto |
realtime | Use real-time ticking instead of smooth animation | Boolean | false |
updateInterval | Interval in milliseconds for realtime updates | Number | 1000 |
continuous | Enable continuous plugin for smooth transitions | Boolean | true |
spinEasing | Easing function for digit spin animations | String | ease-in-out |
transformEasing | Easing function for layout transforms | String | ease-in-out |
opacityEasing | Easing function for fade in/out effects | String | ease-out |
Animation Types
Type | Description |
---|---|
Standard | Smooth animation from start to end value over the specified duration |
Realtime | Step-by-step animation where each number change takes real time (useful for countdowns) |
Viewport Triggered | Animation starts when the element enters the browser viewport |
Continuous / Discrete | Continuous shows smooth transitions through all numbers, while discrete uses classic slot-machine effects |
Number Formatting
The component supports extensive number formatting through the formatOptions
value using the Intl.NumberFormat API:
Format Options | Result |
---|---|
{"style":"currency","currency":"USD"} | Format as currency ($1,000.00) |
{"notation":"compact","compactDisplay":"short"} | Compact notation (1K, 1M, 1B) |
{"minimumFractionDigits":2,"maximumFractionDigits":2} | Fixed decimal places (123.45) |
{"style":"percent"} | Percentage format (75%) |
{"minimumIntegerDigits":2} | Zero-padded integers (01, 02, 03) |
Easing Options
The component supports different easing functions for various parts of the animation. You can use standard CSS easing keywords or custom cubic-bezier curves:
Type | Examples | Description |
---|---|---|
Keywords | linear, ease, ease-in, ease-out, ease-in-out | Built-in CSS easing functions |
Cubic Bezier | cubic-bezier(0.25, 0.1, 0.25, 1.0) | Custom timing functions for precise control |
Bounce Effect | cubic-bezier(0.68, -0.55, 0.265, 1.55) | Overshoot and settle back for playful animations |
Spring Motion | cubic-bezier(0.175, 0.885, 0.32, 1.275) | Natural spring-like movement |
Performance Features
- Slot Machine Animation: Smooth rolling animations powered by Number Flow
- Intersection Observer: Viewport-triggered animations for performance optimization
- Automatic Cleanup: Properly cleans up intervals and observers on disconnect
- Flexible Timing: Customizable animation duration and easing functions
Browser Support
- Modern Browsers: Full support in all modern browsers with ES6+ support
- Number Flow: Requires modern browser support for custom elements
- Intersection Observer: Polyfill may be needed for older browsers for viewport triggers
Advanced Examples
Here are a few more advanced examples inspired by the official Number Flow documentation with dedicated stimulus controllers to give you some inspiration.
Interactive activity stats
Social media-style engagement metrics with interactive animated counters.
Click the icons to see animated engagement metrics
<div class="space-y-4"> <div class="p-0 sm:p-6"> <div data-controller="activity-stats" data-activity-stats-initial-views-value="76543" data-activity-stats-initial-reposts-value="102" data-activity-stats-initial-likes-value="356" data-activity-stats-initial-bookmarks-value="88" class="text-sm text-neutral-600 dark:text-neutral-400"> <div class="flex w-full select-none items-center"> <!-- Views --> <div class="flex flex-1 items-center gap-1.5"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-5 h-5"><line x1="18" x2="18" y1="20" y2="10"></line><line x1="12" x2="12" y1="20" y2="4"></line><line x1="6" x2="6" y1="20" y2="14"></line></svg> <span data-activity-stats-target="viewsCount" class="-ml-1 font-medium"></span> </div> <!-- Reposts --> <div class="flex-1"> <button data-action="click->activity-stats#toggleReposts" data-activity-stats-target="repostButton" class="group flex items-center gap-1.5 pr-1.5 transition-colors hover:text-emerald-500"> <div class="relative p-1 flex items-center justify-center rounded-full ease-in-out duration-300 transition-[background-color] group-hover:bg-emerald-500/10"> <svg data-activity-stats-target="repostIcon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-5 h-5 group-active:scale-90 transition-transform"><path d="m17 2 4 4-4 4"></path><path d="M3 11v-1a4 4 0 0 1 4-4h14"></path><path d="m7 22-4-4 4-4"></path><path d="M21 13v1a4 4 0 0 1-4 4H3"></path></svg> </div> <span data-activity-stats-target="repostsCount" class="-ml-1 font-medium"></span> </button> </div> <!-- Likes --> <div class="flex-1"> <button data-action="click->activity-stats#toggleLikes" data-activity-stats-target="likeButton" class="group flex items-center gap-1.5 pr-1.5 transition-colors hover:text-pink-500"> <div class="relative p-1 flex items-center justify-center rounded-full ease-in-out duration-300 transition-[background-color] group-hover:bg-pink-500/10"> <svg data-activity-stats-target="likeIcon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-5 h-5 group-active:scale-90 transition-transform"><path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"></path></svg> </div> <span data-activity-stats-target="likesCount" class="-ml-1 font-medium"></span> </button> </div> <!-- Bookmarks --> <div class="min-[30rem]:flex-1 max-[24rem]:hidden flex shrink-0 items-center gap-1.5"> <button data-action="click->activity-stats#toggleBookmarks" data-activity-stats-target="bookmarkButton" class="group flex items-center gap-1.5 pr-1.5 transition-colors hover:text-blue-500"> <div class="relative p-1 flex items-center justify-center rounded-full ease-in-out duration-300 transition-[background-color] group-hover:bg-blue-500/10"> <svg data-activity-stats-target="bookmarkIcon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-5 h-5 group-active:scale-90 transition-transform"><path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z"></path></svg> </div> <span data-activity-stats-target="bookmarksCount" class="-ml-1 font-medium"></span> </button> </div> <!-- Share Icon (decorative) --> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-5 h-5 shrink-0 ml-2 text-neutral-400"><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"></path><polyline points="16 6 12 2 8 6"></polyline><line x1="12" x2="12" y1="2" y2="15"></line></svg> </div> </div> <p class="text-sm text-neutral-600 dark:text-neutral-400 text-center mt-4">Click the icons to see animated engagement metrics</p> </div> </div>
import { Controller } from "@hotwired/stimulus"; import "number-flow"; import { continuous } from "number-flow"; export default class extends Controller { static targets = [ "viewsCount", "repostsCount", "repostButton", "repostIcon", "likesCount", "likeButton", "likeIcon", "bookmarksCount", "bookmarkButton", "bookmarkIcon", ]; static values = { initialViews: { type: Number, default: 12345 }, // Initial views count initialReposts: { type: Number, default: 678 }, // Initial reposts count initialLikes: { type: Number, default: 2345 }, // Initial likes count initialBookmarks: { type: Number, default: 123 }, // Initial bookmarks count tickAnimationDuration: { type: Number, default: 500 }, // Duration of the tick animation }; connect() { this.initializeNumberFlow(this.viewsCountTarget, this.initialViewsValue); this.initializeNumberFlow(this.repostsCountTarget, this.initialRepostsValue); this.initializeNumberFlow(this.likesCountTarget, this.initialLikesValue); this.initializeNumberFlow(this.bookmarksCountTarget, this.initialBookmarksValue); // Store toggle states this.repostsActive = false; this.likesActive = false; this.bookmarksActive = false; // Simulate view count increasing over time this.viewsInterval = setInterval(() => { const currentViews = this.viewsCountTarget.flow.value; // Read current value const increment = Math.floor(Math.random() * 10) + 1; this.viewsCountTarget.flow.update(currentViews + increment); }, 3000); } disconnect() { if (this.viewsInterval) clearInterval(this.viewsInterval); } initializeNumberFlow(targetElement, initialValue) { targetElement.innerHTML = "<number-flow data-will-change></number-flow>"; const flowInstance = targetElement.querySelector("number-flow"); targetElement.flow = flowInstance; // Attach for easy access flowInstance.format = { notation: "compact", compactDisplay: "short", roundingMode: "trunc", }; flowInstance.plugins = [continuous]; flowInstance.spinTiming = { duration: this.tickAnimationDurationValue, easing: "ease-out" }; flowInstance.transformTiming = { duration: this.tickAnimationDurationValue, easing: "ease-out" }; flowInstance.update(initialValue); } toggleReposts() { this.repostsActive = !this.repostsActive; const currentValue = this.repostsCountTarget.flow.value; const newValue = this.repostsActive ? currentValue + 1 : currentValue - 1; this.repostsCountTarget.flow.update(newValue); this.repostButtonTarget.classList.toggle("text-emerald-500", this.repostsActive); // this.repostIconTarget.classList.toggle('fill-current', this.repostsActive); // If you want to fill the icon } toggleLikes() { this.likesActive = !this.likesActive; const currentValue = this.likesCountTarget.flow.value; const newValue = this.likesActive ? currentValue + 1 : currentValue - 1; this.likesCountTarget.flow.update(newValue); this.likeButtonTarget.classList.toggle("text-pink-500", this.likesActive); this.likeIconTarget.classList.toggle("fill-pink-500", this.likesActive); // Example of filling the icon } toggleBookmarks() { this.bookmarksActive = !this.bookmarksActive; const currentValue = this.bookmarksCountTarget.flow.value; const newValue = this.bookmarksActive ? currentValue + 1 : currentValue - 1; this.bookmarksCountTarget.flow.update(newValue); this.bookmarkButtonTarget.classList.toggle("text-blue-500", this.bookmarksActive); this.bookmarkIconTarget.classList.toggle("fill-blue-500", this.bookmarksActive); } }
Pricing plan tabs
Animated price changes when switching between different pricing tiers.
Choose Your Billing Period
Save more with longer commitments
Select Your Plan
per month
<div class="space-y-4"> <div class="p-0 sm:p-6"> <div data-controller="pricing-tabs" data-pricing-tabs-initial-price-value="29" data-pricing-tabs-active-tab-classes-value='["bg-white","text-neutral-900","hover:text-neutral-800","shadow-[rgba(255,255,255,0.8)_0_1.5px_0_0_inset,rgba(0,0,0,0.1)_0_1px_3px_0]","dark:text-neutral-50","dark:hover:text-white","dark:bg-neutral-900","dark:ring-1","dark:ring-neutral-950","dark:bg-neutral-800","dark:shadow-[rgba(255,255,255,0.1)_0_0_2px_0_inset,rgba(255,255,255,0.05)_0_1.5px_1px_0_inset,rgba(0,0,0,0.1)_0_1px_3px_0]"]' data-pricing-tabs-inactive-tab-classes-value='["hover:bg-neutral-50","ring-1","ring-neutral-100","hover:ring-white","dark:hover:bg-neutral-700","dark:ring-neutral-800","dark:hover:ring-neutral-600","text-neutral-500","dark:text-neutral-400"]'> <!-- Billing Period Tabs --> <div class="mb-8"> <div class="text-center mb-4"> <h3 class="text-lg font-semibold text-neutral-800 dark:text-neutral-200 mb-2">Choose Your Billing Period</h3> <p class="text-sm text-neutral-600 dark:text-neutral-400">Save more with longer commitments</p> </div> <ul class="relative inline-grid items-center justify-center w-full grid-cols-1 sm:grid-cols-3 p-1 bg-neutral-100 border border-neutral-200 rounded-xl select-none dark:bg-neutral-800 dark:border-neutral-700" role="tablist"> <li class="mr-1 rounded-md whitespace-nowrap text-sm font-medium transition"> <button type="button" role="tab" aria-selected="true" class="flex rounded-lg items-center justify-center py-2.5 px-4 w-full text-center transition-all duration-200 ease-in-out focus:outline-offset-2 focus:outline-neutral-500 dark:focus:outline-neutral-200" data-pricing-tabs-target="periodTab" data-action="click->pricing-tabs#switchPeriod keydown.left->pricing-tabs#previousPeriod keydown.right->pricing-tabs#nextPeriod" data-period="monthly" data-initial="true" tabindex="0"> Monthly </button> </li> <li class="mr-1 rounded-md whitespace-nowrap text-sm font-medium transition"> <button type="button" role="tab" aria-selected="false" class="flex rounded-lg items-center justify-center py-2.5 px-4 w-full text-center transition-all duration-200 ease-in-out focus:outline-offset-2 focus:outline-neutral-500 dark:focus:outline-neutral-200" data-pricing-tabs-target="periodTab" data-action="click->pricing-tabs#switchPeriod keydown.left->pricing-tabs#previousPeriod keydown.right->pricing-tabs#nextPeriod" data-period="quarterly" tabindex="-1"> Quarterly <span class="ml-1 px-1.5 py-0.5 text-xs bg-green-100 text-green-700 rounded dark:bg-green-900 dark:text-green-300">Save 15%</span> </button> </li> <li class="mr-1 rounded-md whitespace-nowrap text-sm font-medium transition"> <button type="button" role="tab" aria-selected="false" class="flex rounded-lg items-center justify-center py-2.5 px-4 w-full text-center transition-all duration-200 ease-in-out focus:outline-offset-2 focus:outline-neutral-500 dark:focus:outline-neutral-200" data-pricing-tabs-target="periodTab" data-action="click->pricing-tabs#switchPeriod keydown.left->pricing-tabs#previousPeriod keydown.right->pricing-tabs#nextPeriod" data-period="annual" tabindex="-1"> Annual <span class="ml-1 px-1.5 py-0.5 text-xs bg-green-100 text-green-700 rounded dark:bg-green-900 dark:text-green-300">Save 25%</span> </button> </li> </ul> </div> <!-- Plan Selection --> <div class="mb-6"> <div class="text-center mb-4"> <h3 class="text-lg font-semibold text-neutral-800 dark:text-neutral-200 mb-2">Select Your Plan</h3> </div> <ul class="relative inline-grid items-center justify-center w-full grid-cols-1 sm:grid-cols-3 p-1 bg-neutral-100 border border-neutral-200 rounded-xl select-none dark:bg-neutral-800 dark:border-neutral-700" role="tablist"> <li class="mr-1 rounded-md whitespace-nowrap text-sm font-medium transition"> <button type="button" role="tab" aria-selected="true" class="flex flex-col rounded-lg items-center justify-center py-3 px-4 w-full text-center transition-all duration-200 ease-in-out focus:outline-offset-2 focus:outline-neutral-500 dark:focus:outline-neutral-200" data-pricing-tabs-target="planTab" data-action="click->pricing-tabs#switchPlan keydown.left->pricing-tabs#previousPlan keydown.right->pricing-tabs#nextPlan" data-plan="basic" data-initial="true" tabindex="0"> <span class="font-medium">Basic</span> <span class="text-xs text-neutral-500 dark:text-neutral-400 mt-1">For individuals</span> </button> </li> <li class="mr-1 rounded-md whitespace-nowrap text-sm font-medium transition"> <button type="button" role="tab" aria-selected="false" class="flex flex-col rounded-lg items-center justify-center py-3 px-4 w-full text-center transition-all duration-200 ease-in-out focus:outline-offset-2 focus:outline-neutral-500 dark:focus:outline-neutral-200" data-pricing-tabs-target="planTab" data-action="click->pricing-tabs#switchPlan keydown.left->pricing-tabs#previousPlan keydown.right->pricing-tabs#nextPlan" data-plan="pro" tabindex="-1"> <span class="font-medium">Pro</span> <span class="text-xs text-neutral-500 dark:text-neutral-400 mt-1">For small teams</span> </button> </li> <li class="mr-1 rounded-md whitespace-nowrap text-sm font-medium transition"> <button type="button" role="tab" aria-selected="false" class="flex flex-col rounded-lg items-center justify-center py-3 px-4 w-full text-center transition-all duration-200 ease-in-out focus:outline-offset-2 focus:outline-neutral-500 dark:focus:outline-neutral-200" data-pricing-tabs-target="planTab" data-action="click->pricing-tabs#switchPlan keydown.left->pricing-tabs#previousPlan keydown.right->pricing-tabs#nextPlan" data-plan="enterprise" tabindex="-1"> <span class="font-medium">Enterprise</span> <span class="text-xs text-neutral-500 dark:text-neutral-400 mt-1">For large organizations</span> </button> </li> </ul> </div> <!-- Price Display --> <div class="text-center"> <div class="mb-4"> <div data-pricing-tabs-target="priceDisplay" class="text-3xl sm:text-4xl md:text-5xl font-bold text-neutral-800 dark:text-neutral-50 mb-2"> <!-- NumberFlow will be injected here --> </div> <p data-pricing-tabs-target="billingText" class="text-sm sm:text-base text-neutral-600 dark:text-neutral-400">per month</p> </div> <div class="bg-neutral-50 dark:bg-neutral-800 rounded-lg p-4 max-w-md mx-auto"> <div class="flex justify-between items-center text-sm"> <span class="text-neutral-600 dark:text-neutral-400">Selected Plan:</span> <span data-pricing-tabs-target="selectedPlan" class="font-medium text-neutral-800 dark:text-neutral-200">Basic Plan</span> </div> <div class="flex justify-between items-center text-sm mt-2"> <span class="text-neutral-600 dark:text-neutral-400">Billing Period:</span> <span data-pricing-tabs-target="selectedPeriod" class="font-medium text-neutral-800 dark:text-neutral-200">Monthly</span> </div> <div data-pricing-tabs-target="savingsInfo" class="mt-3 text-xs text-green-600 dark:text-green-400 hidden"> <!-- Savings information will be shown here --> </div> </div> </div> </div> </div> </div>
import { Controller } from "@hotwired/stimulus"; import "number-flow"; import { continuous } from "number-flow"; export default class extends Controller { static targets = [ "periodTab", "planTab", "priceDisplay", "billingText", "selectedPlan", "selectedPeriod", "savingsInfo", ]; static values = { initialPrice: { type: Number, default: 29 }, // Initial price activeTabClasses: { type: Array, default: ["bg-white", "text-slate-900", "shadow-md"] }, // Classes for the active tab inactiveTabClasses: { type: Array, default: ["text-slate-600", "hover:bg-slate-100", "hover:text-slate-800"] }, // Classes for the inactive tab tickAnimationDuration: { type: Number, default: 700 }, // Duration of the tick animation }; // SaaS pricing structure pricing = { basic: { monthly: 29, quarterly: 25, // ~15% discount annual: 22, // ~25% discount }, pro: { monthly: 79, quarterly: 67, // ~15% discount annual: 59, // ~25% discount }, enterprise: { monthly: 199, quarterly: 169, // ~15% discount annual: 149, // ~25% discount }, }; connect() { this.priceDisplayTarget.innerHTML = "<number-flow></number-flow>"; this.priceFlow = this.priceDisplayTarget.querySelector("number-flow"); this.priceFlow.format = { style: "currency", currency: "USD", minimumFractionDigits: 0 }; this.priceFlow.plugins = [continuous]; this.priceFlow.spinTiming = { duration: this.tickAnimationDurationValue, easing: "ease-in-out" }; this.priceFlow.transformTiming = { duration: this.tickAnimationDurationValue, easing: "ease-in-out" }; // Initialize with default selections this.currentPeriod = "monthly"; this.currentPlan = "basic"; this.currentPeriodIndex = 0; this.currentPlanIndex = 0; // Set initial active states this.activatePeriodTab(this.periodTabTargets[0]); this.activatePlanTab(this.planTabTargets[0]); // Update display this.updatePriceDisplay(); } switchPeriod(event) { event.preventDefault(); const clickedTab = event.currentTarget; const newPeriod = clickedTab.dataset.period; this.currentPeriod = newPeriod; this.currentPeriodIndex = this.periodTabTargets.indexOf(clickedTab); this.activatePeriodTab(clickedTab); this.updatePriceDisplay(); } switchPlan(event) { event.preventDefault(); const clickedTab = event.currentTarget; const newPlan = clickedTab.dataset.plan; this.currentPlan = newPlan; this.currentPlanIndex = this.planTabTargets.indexOf(clickedTab); this.activatePlanTab(clickedTab); this.updatePriceDisplay(); } // Period navigation methods previousPeriod(event) { event.preventDefault(); const newIndex = this.currentPeriodIndex > 0 ? this.currentPeriodIndex - 1 : this.periodTabTargets.length - 1; this.switchToPeriodByIndex(newIndex); } nextPeriod(event) { event.preventDefault(); const newIndex = this.currentPeriodIndex < this.periodTabTargets.length - 1 ? this.currentPeriodIndex + 1 : 0; this.switchToPeriodByIndex(newIndex); } // Plan navigation methods previousPlan(event) { event.preventDefault(); const newIndex = this.currentPlanIndex > 0 ? this.currentPlanIndex - 1 : this.planTabTargets.length - 1; this.switchToPlanByIndex(newIndex); } nextPlan(event) { event.preventDefault(); const newIndex = this.currentPlanIndex < this.planTabTargets.length - 1 ? this.currentPlanIndex + 1 : 0; this.switchToPlanByIndex(newIndex); } switchToPeriodByIndex(index) { if (index >= 0 && index < this.periodTabTargets.length) { const targetTab = this.periodTabTargets[index]; const newPeriod = targetTab.dataset.period; this.currentPeriod = newPeriod; this.currentPeriodIndex = index; this.activatePeriodTab(targetTab); this.updatePriceDisplay(); targetTab.focus(); } } switchToPlanByIndex(index) { if (index >= 0 && index < this.planTabTargets.length) { const targetTab = this.planTabTargets[index]; const newPlan = targetTab.dataset.plan; this.currentPlan = newPlan; this.currentPlanIndex = index; this.activatePlanTab(targetTab); this.updatePriceDisplay(); targetTab.focus(); } } activatePeriodTab(activeTab) { this.periodTabTargets.forEach((tab) => { tab.classList.remove(...this.activeTabClassesValue); tab.classList.add(...this.inactiveTabClassesValue); tab.setAttribute("aria-selected", "false"); tab.setAttribute("tabindex", "-1"); }); activeTab.classList.add(...this.activeTabClassesValue); activeTab.classList.remove(...this.inactiveTabClassesValue); activeTab.setAttribute("aria-selected", "true"); activeTab.setAttribute("tabindex", "0"); } activatePlanTab(activeTab) { this.planTabTargets.forEach((tab) => { tab.classList.remove(...this.activeTabClassesValue); tab.classList.add(...this.inactiveTabClassesValue); tab.setAttribute("aria-selected", "false"); tab.setAttribute("tabindex", "-1"); }); activeTab.classList.add(...this.activeTabClassesValue); activeTab.classList.remove(...this.inactiveTabClassesValue); activeTab.setAttribute("aria-selected", "true"); activeTab.setAttribute("tabindex", "0"); } updatePriceDisplay() { const price = this.pricing[this.currentPlan][this.currentPeriod]; this.priceFlow.update(price); // Update billing text const billingTexts = { monthly: "per month", quarterly: "per month, billed quarterly", annual: "per month, billed annually", }; this.billingTextTarget.textContent = billingTexts[this.currentPeriod]; // Update selected plan and period display const planNames = { basic: "Basic Plan", pro: "Pro Plan", enterprise: "Enterprise Plan", }; const periodNames = { monthly: "Monthly", quarterly: "Quarterly", annual: "Annual", }; this.selectedPlanTarget.textContent = planNames[this.currentPlan]; this.selectedPeriodTarget.textContent = periodNames[this.currentPeriod]; // Show savings information this.updateSavingsInfo(); } updateSavingsInfo() { const monthlyPrice = this.pricing[this.currentPlan].monthly; const currentPrice = this.pricing[this.currentPlan][this.currentPeriod]; if (this.currentPeriod === "monthly") { this.savingsInfoTarget.classList.add("hidden"); } else { const savings = monthlyPrice - currentPrice; const savingsPercent = Math.round((savings / monthlyPrice) * 100); const totalSavings = this.currentPeriod === "quarterly" ? savings * 3 : savings * 12; this.savingsInfoTarget.innerHTML = ` 💰 You save $${savings}/month (${savingsPercent}% off) • $${totalSavings} total savings per ${ this.currentPeriod === "quarterly" ? "quarter" : "year" } `; this.savingsInfoTarget.classList.remove("hidden"); } } }
Input stepper with animation
Animated number input with increment/decrement controls and smooth transitions.
Quantity Selector
<div class="space-y-4"> <div class="p-6"> <div class="flex flex-col items-center"> <h3 class="text-lg font-semibold text-neutral-800 dark:text-neutral-200 mb-3">Quantity Selector</h3> <div data-controller="increment" data-increment-initial-value="10" data-increment-min-value="0" data-increment-max-value="20" data-increment-step-value="1" data-increment-shift-step-value="5" class="w-full max-w-xs mx-auto"> <div class="group flex items-stretch rounded-md text-xl lg:text-2xl font-semibold ring-3 ring-neutral-200 dark:ring-neutral-700 transition-shadow focus-within:ring-2 focus-within:ring-neutral-500 dark:focus-within:ring-neutral-500"> <button type="button" aria-hidden="true" tabindex="-1" data-increment-target="decrementButton" data-action="pointerdown->increment#focusInput click->increment#decrement" class="flex items-center pl-3 pr-2 text-neutral-500 hover:text-neutral-600 disabled:opacity-50 disabled:hover:text-neutral-500 disabled:cursor-not-allowed transition-colors dark:text-neutral-50 dark:hover:text-neutral-400"> <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"></line></svg> </button> <div class="relative grid items-center justify-items-center [grid-template-areas:'overlap'] *:[grid-area:overlap] flex-grow"> <input type="number" data-increment-target="input" data-action="input->increment#handleInput blur->increment#handleBlur keydown->increment#handleKeydown" style="font-kerning: none;" class="spin-hide w-full bg-transparent !caret-transparent py-2 text-center font-[inherit] text-transparent outline-hidden" /> <div data-increment-target="numberFlowDisplay" class="pointer-events-none text-neutral-800 dark:text-neutral-50 font-mono"> <!-- NumberFlow injected here --> </div> </div> <button type="button" aria-hidden="true" tabindex="-1" data-increment-target="incrementButton" data-action="pointerdown->increment#focusInput click->increment#increment" class="flex items-center pl-2 pr-3 text-neutral-500 hover:text-neutral-600 disabled:opacity-50 disabled:hover:text-neutral-500 disabled:cursor-not-allowed transition-colors dark:text-neutral-50 dark:hover:text-neutral-400"> <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg> </button> </div> </div> </div> </div> </div>
import { Controller } from "@hotwired/stimulus"; import "number-flow"; import { continuous } from "number-flow"; export default class extends Controller { static targets = ["input", "numberFlowDisplay", "decrementButton", "incrementButton"]; static values = { min: { type: Number, default: -Infinity }, // Minimum value max: { type: Number, default: Infinity }, // Maximum value step: { type: Number, default: 1 }, // Step size shiftStep: { type: Number, default: 10 }, // Shift step size initial: { type: Number, default: 0 }, animationDuration: { type: Number, default: 300 }, }; connect() { this.currentValue = this.initialValue; this.numberFlowDisplayTarget.innerHTML = "<number-flow></number-flow>"; this.flow = this.numberFlowDisplayTarget.querySelector("number-flow"); this.flow.format = { useGrouping: false }; // Match example this.flow.locales = "en-US"; this.flow.plugins = [continuous]; this.flow.animated = true; this.flow.willChange = true; // from example this.flow.spinTiming = { duration: this.animationDurationValue, easing: "ease-out" }; this.flow.transformTiming = { duration: this.animationDurationValue, easing: "ease-out" }; // Event listeners for caret visibility during animation this.flow.addEventListener("animationsstart", () => this.toggleCaret(false)); this.flow.addEventListener("animationsfinish", () => this.toggleCaret(true)); this.inputTarget.value = this.currentValue; this.flow.update(this.currentValue); // Initialize NumberFlow this.updateButtonStates(); } toggleCaret(show) { this.inputTarget.classList.toggle("caret-transparent", !show); this.inputTarget.classList.toggle("caret-slate-700", show); // Or your preferred caret color } handleInput(event) { this.flow.animated = false; // Disable animation during direct input let nextValue = this.currentValue; const inputValue = event.currentTarget.value; if (inputValue === "") { // If input is empty, we might revert to initial or do nothing, // for now, let's stick to the current value if it becomes empty during typing. // Or, as in example, could revert to a "defaultValue" if we stored one. // For simplicity, we will ensure input always reflects a valid number. nextValue = this.currentValue; // Keep it as is, or handle as per UX preference } else { const num = parseInt(inputValue, 10); // Using parseInt, as step is 1 if (!isNaN(num)) { nextValue = Math.min(Math.max(num, this.minValue), this.maxValue); } else { nextValue = this.currentValue; // Revert if not a number } } this.currentValue = nextValue; // Manually update the input.value in case the number stays the same e.g. 09 == 9 // or if clamped. event.currentTarget.value = this.currentValue; this.flow.update(this.currentValue); this.updateButtonStates(); // Re-enable animation shortly after input stops, or on blur. // Using a timeout here for simplicity after typing. clearTimeout(this.typingTimer); this.typingTimer = setTimeout(() => { this.flow.animated = true; }, 500); } handleKeydown(event) { if (event.key === "ArrowUp") { event.preventDefault(); this.flow.animated = true; // Enable animation for arrow key interactions this.increment(event); } else if (event.key === "ArrowDown") { event.preventDefault(); this.flow.animated = true; // Enable animation for arrow key interactions this.decrement(event); } } // Ensure animation is re-enabled when input loses focus handleBlur() { this.flow.animated = true; // If input is empty on blur, reset to current valid value if (this.inputTarget.value === "") { this.inputTarget.value = this.currentValue; } } decrement(event = null) { const stepSize = event && event.shiftKey ? this.shiftStepValue : this.stepValue; this.updateValue(this.currentValue - stepSize); } increment(event = null) { const stepSize = event && event.shiftKey ? this.shiftStepValue : this.stepValue; this.updateValue(this.currentValue + stepSize); } updateValue(newValue, animate = true) { this.flow.animated = animate; const clampedValue = Math.min(Math.max(newValue, this.minValue), this.maxValue); if (clampedValue !== this.currentValue) { this.currentValue = clampedValue; this.inputTarget.value = this.currentValue; this.flow.update(this.currentValue); this.updateButtonStates(); } else if (!animate) { // If value is same but we need to ensure flow is updated (e.g. after input) this.flow.update(this.currentValue); } } updateButtonStates() { this.decrementButtonTarget.disabled = this.currentValue <= this.minValue; this.incrementButtonTarget.disabled = this.currentValue >= this.maxValue; } // Allow focus on input when clicking buttons (like original example) focusInput(event) { if (event.pointerType === "mouse") { event.preventDefault(); // Prevent button from taking focus if it's a mouse click this.inputTarget.focus(); } } disconnect() { clearTimeout(this.typingTimer); // Remove event listeners if they were added directly to this.flow // For now, they are auto-cleaned by Stimulus if on controller element or targets } } // Add some basic CSS for the caret and input const style = document.createElement("style"); style.textContent = ` .caret-transparent { caret-color: transparent !important; } .spin-hide::-webkit-outer-spin-button, .spin-hide::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } .spin-hide { -moz-appearance: textfield; /* Firefox */ } `; document.head.appendChild(style);