The Problem with Modern JS
You want to add a simple "Copy to Clipboard" button next to an API key or a share link.
- In React/Vue: You have to build a component, manage state (
isCopied), and wire up props. - In jQuery: You write a soup of
$('.btn').click()selectors that break the moment you change a CSS class. - In Vanilla JS: You end up writing
document.querySelectortwenty times.
Enter Stimulus.
Stimulus (part of the Hotwire stack) doesn't want to render your HTML. It just wants to sprinkle behavior onto it. It connects your HTML to JavaScript objects using simple data attributes.
Let’s build a reusable Clipboard controller.
Step 1: The Setup
If you are in Rails 7 or 8, you likely already have Stimulus installed.
Run the generator to create a blank file:
rails g stimulus clipboard
# Creates app/javascript/controllers/clipboard_controller.js
If you aren't using Rails, just create a class that extends Controller from @hotwired/stimulus.
Step 2: The HTML (The Interface)
Stimulus reverses the standard logic. Instead of your JS looking for the HTML, your HTML tells the JS what it is.
We need three things:
- The Controller Scope: (
data-controller) - The Source: The input field we want to copy (
data-clipboard-target) - The Trigger: The button that fires the action (
data-action)
<!-- The Wrapper: Connects to clipboard_controller.js -->
<div data-controller="clipboard">
<!-- The Source: We name this target "source" -->
<input type="text" value="X8-99-Z1-API-KEY" readonly data-clipboard-target="source">
<!-- The Trigger: On "click", run the "copy" method -->
<button data-action="click->clipboard#copy">
Copy
</button>
</div>
Step 3: The JavaScript (The Logic)
Now, open clipboard_controller.js. We will use the modern navigator.clipboard API.
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
// 1. Define the targets we referenced in HTML
static targets = [ "source" ]
copy() {
// 2. Access the target element using 'this.sourceTarget'
const textToCopy = this.sourceTarget.value
// 3. Use the Clipboard API
navigator.clipboard.writeText(textToCopy).then(() => {
alert("Copied to clipboard!")
}).catch(err => {
console.error("Failed to copy: ", err)
})
}
}
That’s it. You have a working feature. But let’s make it "Production Ready" (UX Polish).
Step 4: The UX Polish (Visual Feedback)
Alert boxes are ugly. Let's make the button change its text to "Copied!" for 2 seconds, then change back.
To do this, we need access to the button itself. In Stimulus, the element that triggered the action is passed as an event, or accessed via this.element (if it's the controller root).
Let's update the HTML to give the button a target so we can change its text easily.
<button data-action="click->clipboard#copy" data-clipboard-target="button">
Copy
</button>
Now update the controller:
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = [ "source", "button" ]
copy(event) {
// Prevent default anchor behavior if used on a link
event.preventDefault()
navigator.clipboard.writeText(this.sourceTarget.value).then(() => {
this.flashMessage()
})
}
flashMessage() {
// Save the original text (so we can revert it later)
const originalText = this.buttonTarget.innerText
// Change button text
this.buttonTarget.innerText = "Copied!"
this.buttonTarget.disabled = true // Prevent double clicks
// Reset after 2 seconds
setTimeout(() => {
this.buttonTarget.innerText = originalText
this.buttonTarget.disabled = false
}, 2000)
}
}
Why is this better?
- Reusability: You can drop
data-controller="clipboard"onto any div in your application, and it works. You don't need to write new JS for the "Profile Page" and different JS for the "Settings Page." - Readability: You can look at the HTML and know exactly what is happening.
click->clipboard#copyreads like a sentence. - No State Management: We aren't storing
isCopiedin a variable. We are simply manipulating the DOM directly, which is exactly what the browser is good at.
Summary
Stimulus is the "sweet spot" for 90% of web interactions. It doesn't try to take over your entire frontend; it just sprinkles behavior where you need it.
Homework: Try adding a CSS class (like .is-success which makes the button green) inside the flashMessage method using this.buttonTarget.classList.add(...)!
Are you using Stimulus or still wrestling with jQuery? Let me know in the comments!
Top comments (0)