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:

Terminal
npm install number-flow
Terminal
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

Selected Plan: Basic Plan
Billing Period: Monthly
<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);

Table of contents