Stimulus for sprinkles of JavaScript interactivity

Sarah Mitchell Feb 2026
3 tabs
// app/javascript/controllers/dropdown_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["menu"]

  connect() {
    console.log("Dropdown connected")
  }

  toggle(event) {
    event.preventDefault()
    this.menuTarget.classList.toggle("hidden")
  }

  hide(event) {
    // Hide if clicking outside dropdown
    if (!this.element.contains(event.target)) {
      this.menuTarget.classList.add("hidden")
    }
  }

  disconnect() {
    // Cleanup when element is removed
  }
}

// app/javascript/controllers/modal_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["content"]

  open() {
    this.element.classList.remove("hidden")
    document.body.classList.add("overflow-hidden")
  }

  close() {
    this.element.classList.add("hidden")
    document.body.classList.remove("overflow-hidden")
  }

  closeOnEscape(event) {
    if (event.key === "Escape") {
      this.close()
    }
  }

  closeBackground(event) {
    if (event.target === this.element) {
      this.close()
    }
  }
}

// app/javascript/controllers/form_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["submit"]

  validate() {
    const form = this.element
    const isValid = form.checkValidity()

    this.submitTarget.disabled = !isValid
  }

  submit(event) {
    event.preventDefault()

    const formData = new FormData(this.element)

    fetch(this.element.action, {
      method: this.element.method,
      body: formData,
      headers: {
        'X-CSRF-Token': document.querySelector('[name="csrf-token"]').content
      }
    })
    .then(response => response.json())
    .then(data => {
      console.log('Success:', data)
    })
  }
}

// app/javascript/controllers/clipboard_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["source", "button"]
  static values = {
    successMessage: String,
    successDuration: { type: Number, default: 2000 }
  }

  copy() {
    navigator.clipboard.writeText(this.sourceTarget.value)
    this.showSuccess()
  }

  showSuccess() {
    const originalText = this.buttonTarget.innerText
    this.buttonTarget.innerText = this.successMessageValue || "Copied!"

    setTimeout(() => {
      this.buttonTarget.innerText = originalText
    }, this.successDurationValue)
  }
}
3 files · javascript, erb Explain with highlit

Stimulus adds JavaScript behavior to HTML without building SPAs. Controllers attach to DOM elements via data-controller. I use Stimulus for modals, dropdowns, form validation, autocomplete. Actions connect events to controller methods via data-action. Targets reference DOM elements by name via data-target. Values pass data from HTML to JavaScript. Stimulus follows progressive enhancement—HTML works first, JavaScript enhances. Controllers are reusable—same dropdown controller on all dropdowns. Stimulus integrates perfectly with Turbo for reactive UIs. Understanding Stimulus lifecycle—connect, disconnect—enables proper setup/teardown. Stimulus keeps JavaScript organized in small, focused controllers. It's the missing link between Rails and modern JavaScript.