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:
npm install number-flow
yarn add number-flow
pin "number-flow", to: "https://esm.sh/number-flow"
pin "number-flow/group", to: "https://esm.sh/number-flow/group"
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-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-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-3 p-1 bg-neutral-100 border border-neutral-200 rounded-lg 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-3 p-1 bg-neutral-100 border border-neutral-200 rounded-lg 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-6xl 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-lg 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 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-none" />
<div data-increment-target="numberFlowDisplay"
class="pointer-events-none text-neutral-800 dark:text-neutral-50">
<!-- 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);