rails-37-style-frontend-stimulus

star 0

Reusable controller catalog with copy-paste code

Chwistophe By Chwistophe schedule Updated 3/6/2026

name: rails-37-style-frontend-stimulus description: Reusable controller catalog with copy-paste code license: MIT

Stimulus Controllers

Small, focused, reusable JavaScript controllers.


Philosophy

52 Stimulus controllers split roughly 60/40 between reusable utilities and domain-specific logic. Controllers remain:

  • Single-purpose - One job per controller
  • Configured via values/classes - No hardcoded strings
  • Event-based communication - Controllers dispatch events, don't call each other

Reusable Controllers Catalog

These controllers are generic enough to copy into any Rails project.

Copy-to-Clipboard Controller (25 lines)

Simple async clipboard API wrapper with visual feedback:

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

export default class extends Controller {
  static values = { content: String }
  static classes = [ "success" ]

  async copy(event) {
    event.preventDefault()
    this.reset()

    try {
      await navigator.clipboard.writeText(this.contentValue)
      this.element.classList.add(this.successClass)
    } catch {}
  }

  reset() {
    this.element.classList.remove(this.successClass)
    this.#forceReflow()
  }

  #forceReflow() {
    this.element.offsetWidth
  }
}

Usage:

<button data-controller="copy-to-clipboard"
        data-copy-to-clipboard-content-value="https://example.com/share"
        data-copy-to-clipboard-success-class="copied"
        data-action="click->copy-to-clipboard#copy">
  Copy Link
</button>

Auto-Click Controller (7 lines)

Clicks an element when it connects. Perfect for auto-submitting forms:

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

export default class extends Controller {
  connect() {
    this.element.click()
  }
}

Usage: <button data-controller="auto-click" data-action="..."> - triggers on page load.

Element Removal Controller (7 lines)

Removes any element on action:

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

export default class extends Controller {
  remove() {
    this.element.remove()
  }
}

Usage:

<div data-controller="element-removal">
  <button data-action="element-removal#remove">Dismiss</button>
</div>

Toggle Class Controller (31 lines)

Toggle, add, or remove CSS classes:

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

export default class extends Controller {
  static classes = [ "toggle" ]
  static targets = [ "checkbox" ]

  toggle() {
    this.element.classList.toggle(this.toggleClass)
  }

  add() {
    this.element.classList.add(this.toggleClass)
  }

  remove() {
    this.element.classList.remove(this.toggleClass)
  }

  checkAll() {
    this.checkboxTargets.forEach(checkbox => checkbox.checked = true)
  }

  checkNone() {
    this.checkboxTargets.forEach(checkbox => checkbox.checked = false)
  }
}

Auto-Resize Controller (32 lines)

Auto-expands textareas as you type:

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

export default class extends Controller {
  static values = { minHeight: { type: Number, default: 0 } }

  connect() {
    this.resize()
  }

  resize() {
    this.element.style.height = "auto"
    const newHeight = Math.max(this.minHeightValue, this.element.scrollHeight)
    this.element.style.height = `${newHeight}px`
  }
}

Usage:

<textarea data-controller="autoresize"
          data-autoresize-min-height-value="100"
          data-action="input->autoresize#resize"></textarea>

Dialog Controller (45 lines)

Native <dialog> management:

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

export default class extends Controller {
  connect() {
    this.element.addEventListener("close", this.#onClose.bind(this))
  }

  disconnect() {
    this.element.removeEventListener("close", this.#onClose.bind(this))
  }

  open() {
    this.element.showModal()
  }

  close() {
    this.element.close()
  }

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

  #onClose() {
    this.dispatch("closed")
  }
}

Usage:

<dialog data-controller="dialog"
        data-action="click->dialog#closeOnOutsideClick keydown.esc->dialog#close">
  <h2>Modal Content</h2>
  <button data-action="dialog#close">Close</button>
</dialog>

<button data-action="dialog#open" data-dialog-target="trigger">
  Open Dialog
</button>

Auto-Submit Controller (28 lines)

Debounced form auto-submission:

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

export default class extends Controller {
  static values = { delay: { type: Number, default: 300 } }

  connect() {
    this.timeout = null
  }

  submit() {
    clearTimeout(this.timeout)
    this.timeout = setTimeout(() => {
      this.element.requestSubmit()
    }, this.delayValue)
  }

  submitNow() {
    clearTimeout(this.timeout)
    this.element.requestSubmit()
  }

  disconnect() {
    clearTimeout(this.timeout)
  }
}

Usage:

<form data-controller="auto-submit" data-auto-submit-delay-value="500">
  <input type="text" data-action="input->auto-submit#submit">
  <select data-action="change->auto-submit#submitNow">
</form>

Local Time Controller (40 lines)

Display times in user's local timezone:

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

export default class extends Controller {
  static values = {
    datetime: String,
    format: { type: String, default: "relative" }
  }

  connect() {
    this.render()
  }

  render() {
    const date = new Date(this.datetimeValue)

    if (this.formatValue === "relative") {
      this.element.textContent = this.#relativeTime(date)
    } else {
      this.element.textContent = date.toLocaleString()
    }
  }

  #relativeTime(date) {
    const now = new Date()
    const diff = now - date
    const minutes = Math.floor(diff / 60000)
    const hours = Math.floor(minutes / 60)
    const days = Math.floor(hours / 24)

    if (minutes < 1) return "just now"
    if (minutes < 60) return `${minutes}m ago`
    if (hours < 24) return `${hours}h ago`
    if (days < 7) return `${days}d ago`
    return date.toLocaleDateString()
  }
}

Beacon Controller (20 lines)

Track views/reads by pinging a URL:

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

export default class extends Controller {
  static values = { url: String }

  connect() {
    this.#sendBeacon()
  }

  #sendBeacon() {
    if (this.hasUrlValue) {
      navigator.sendBeacon(this.urlValue)
    }
  }
}

Usage:

<div data-controller="beacon"
     data-beacon-url-value="/cards/123/reading">
  <!-- Content that should be marked as "read" -->
</div>

Form Reset Controller (12 lines)

Reset form on successful submission:

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

export default class extends Controller {
  reset() {
    this.element.reset()
  }

  resetOnSuccess(event) {
    if (event.detail.success) {
      this.reset()
    }
  }
}

Usage:

<form data-controller="form-reset"
      data-action="turbo:submit-end->form-reset#resetOnSuccess">

Character Counter Controller (25 lines)

Show remaining characters:

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

export default class extends Controller {
  static targets = [ "input", "counter" ]
  static values = { max: Number }

  connect() {
    this.update()
  }

  update() {
    const remaining = this.maxValue - this.inputTarget.value.length
    this.counterTarget.textContent = remaining

    if (remaining < 0) {
      this.counterTarget.classList.add("over-limit")
    } else {
      this.counterTarget.classList.remove("over-limit")
    }
  }
}

Stimulus Best Practices

Use Values API Over getAttribute

// Good
static values = { url: String, delay: Number }
this.urlValue
this.delayValue

// Avoid
this.element.getAttribute("data-url")

Use camelCase in JavaScript

// Good
static values = { autoSubmit: Boolean }  // data-auto-submit-value

// Matches Rails conventions

Always Clean Up in disconnect()

disconnect() {
  clearTimeout(this.timeout)
  this.observer?.disconnect()
  this.element.removeEventListener("custom", this.handler)
}

Use :self Action Filter

// Only trigger on this element, not bubbled events
data-action="click:self->modal#close"

Extract Shared Helpers to Modules

// app/javascript/helpers/date_helpers.js
export function formatRelativeTime(date) { ... }

// app/javascript/controllers/local_time_controller.js
import { formatRelativeTime } from "../helpers/date_helpers"

Dispatch Events for Communication

// In controller
this.dispatch("selected", { detail: { id: this.idValue } })

// In HTML
data-action="dropdown:selected->form#updateField"

File Organization

app/javascript/
├── controllers/
│   ├── application.js
│   ├── auto_click_controller.js
│   ├── auto_submit_controller.js
│   ├── autoresize_controller.js
│   ├── beacon_controller.js
│   ├── character_counter_controller.js
│   ├── copy_to_clipboard_controller.js
│   ├── dialog_controller.js
│   ├── element_removal_controller.js
│   ├── form_reset_controller.js
│   ├── local_time_controller.js
│   └── toggle_class_controller.js
└── helpers/
    ├── date_helpers.js
    └── dom_helpers.js
Install via CLI
npx skills add https://github.com/Chwistophe/agent-skills-unofficial-37-signals-rails-way-fizzy --skill rails-37-style-frontend-stimulus
Repository Details
star Stars 0
call_split Forks 0
navigation Branch main
article Path SKILL.md
More from Creator