// 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)
}
}
<!-- Dropdown -->
<div data-controller="dropdown"
data-action="click@window->dropdown#hide">
<button data-action="dropdown#toggle">
Menu
</button>
<div data-dropdown-target="menu" class="hidden">
<a href="/profile">Profile</a>
<a href="/settings">Settings</a>
<a href="/logout">Logout</a>
</div>
</div>
<!-- Modal -->
<div data-controller="modal" class="hidden"
data-action="keyup@window->modal#closeOnEscape click@window->modal#closeBackground">
<div data-modal-target="content">
<h2>Modal Title</h2>
<p>Modal content here</p>
<button data-action="modal#close">Close</button>
</div>
</div>
<button data-action="modal#open">Open Modal</button>
<!-- Form validation -->
<%= form_with model: @post, data: {
controller: "form",
action: "input->form#validate submit->form#submit"
} do |f| %>
<%= f.text_field :title, required: true %>
<%= f.text_area :content, required: true %>
<%= f.submit "Create Post", data: { form_target: "submit" } %>
<% end %>
<!-- Clipboard copy -->
<div data-controller="clipboard"
data-clipboard-success-message-value="Copied!"
data-clipboard-success-duration-value="3000">
<input data-clipboard-target="source"
value="Text to copy"
readonly>
<button data-clipboard-target="button"
data-action="clipboard#copy">
Copy
</button>
</div>
<!-- Autocomplete -->
<div data-controller="autocomplete">
<input type="text"
data-autocomplete-target="input"
data-action="input->autocomplete#search">
<ul data-autocomplete-target="results" class="hidden"></ul>
</div>
<!-- Character counter -->
<div data-controller="counter">
<textarea data-counter-target="input"
data-action="input->counter#count"
maxlength="280"></textarea>
<span data-counter-target="display">0 / 280</span>
</div>
<!-- Toggle visibility -->
<div data-controller="toggle">
<button data-action="toggle#toggle">
Show Details
</button>
<div data-toggle-target="content" class="hidden">
<p>Hidden details content</p>
</div>
</div>
<!-- Multiple controllers on same element -->
<div data-controller="dropdown modal tooltip"
data-action="mouseenter->tooltip#show mouseleave->tooltip#hide">
<!-- Element has 3 controllers -->
</div>
<!-- Values (passing data to controller) -->
<div data-controller="slideshow"
data-slideshow-index-value="0"
data-slideshow-auto-play-value="true">
<!-- Controller can access this.indexValue and this.autoPlayValue -->
</div>
// Autocomplete controller
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["input", "results"]
static values = { url: String }
search() {
const query = this.inputTarget.value
if (query.length < 2) {
this.resultsTarget.classList.add("hidden")
return
}
fetch(`${this.urlValue}?q=${query}`)
.then(response => response.json())
.then(data => {
this.displayResults(data)
})
}
displayResults(results) {
this.resultsTarget.innerHTML = results
.map(result => `
<li data-action="click->autocomplete#select" data-value="${result.id}">
${result.name}
</li>
`)
.join('')
this.resultsTarget.classList.remove("hidden")
}
select(event) {
const value = event.currentTarget.dataset.value
this.inputTarget.value = event.currentTarget.textContent
this.resultsTarget.classList.add("hidden")
}
}
// Debouncing with Stimulus values
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = { delay: { type: Number, default: 300 } }
initialize() {
this.search = this.debounce(this.search.bind(this), this.delayValue)
}
search() {
// Debounced search logic
}
debounce(func, wait) {
let timeout
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout)
func(...args)
}
clearTimeout(timeout)
timeout = setTimeout(later, wait)
}
}
}
// Composing controllers
import { Controller } from "@hotwired/stimulus"
import { useClickOutside } from "stimulus-use"
export default class extends Controller {
connect() {
useClickOutside(this)
}
clickOutside(event) {
this.element.classList.add("hidden")
}
}
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.