Tools: Implementing Copy-to-Clipboard in Rails 8: The Modern Way

Tools: Implementing Copy-to-Clipboard in Rails 8: The Modern Way

Source: Dev.to

1. Generate the Controller ## 2. The JavaScript Code ## 3. Usage Examples (ERB) ## Scenario A: Copying from an Input Field ## Scenario B: Copying Static Text ## Important Notes for Production Here is a robust, modern Stimulus controller for Rails 8 to handle copying text to the clipboard. It uses the modern navigator.clipboard API and includes user feedback (changing the button text to "Copied!" temporarily). Run the Rails generator command: Update app/javascript/controllers/clipboard_controller.js. This implementation handles copying from input fields, textareas, or standard HTML elements (like a div or span). This is useful for API keys or shareable URLs. This is useful for copying codes or IDs displayed in a div or span. Templates let you quickly answer FAQs or store snippets for re-use. Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment's permalink. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse CODE_BLOCK: bin/rails generate stimulus clipboard Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: bin/rails generate stimulus clipboard CODE_BLOCK: bin/rails generate stimulus clipboard COMMAND_BLOCK: import { Controller } from "@hotwired/stimulus" export default class extends Controller { static targets = ["source", "button"] static values = { successDuration: { type: Number, default: 2000 } } connect() { // Save the original button text so we can revert it later if (this.hasButtonTarget) { this.originalText = this.buttonTarget.innerText } } copy(event) { event.preventDefault() const text = this.textToCopy() // Copy to clipboard navigator.clipboard.writeText(text).then(() => { this.showSuccess() }).catch(() => { console.error("Failed to copy text") }) } // Helper to determine what text to copy textToCopy() { if (this.sourceTarget.nodeName === "INPUT" || this.sourceTarget.nodeName === "TEXTAREA") { return this.sourceTarget.value } else { return this.sourceTarget.textContent.trim() } } // Visual feedback showSuccess() { if (!this.hasButtonTarget) return this.buttonTarget.innerText = "Copied!" this.buttonTarget.disabled = true setTimeout(() => { this.buttonTarget.innerText = this.originalText this.buttonTarget.disabled = false }, this.successDurationValue) } } Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: import { Controller } from "@hotwired/stimulus" export default class extends Controller { static targets = ["source", "button"] static values = { successDuration: { type: Number, default: 2000 } } connect() { // Save the original button text so we can revert it later if (this.hasButtonTarget) { this.originalText = this.buttonTarget.innerText } } copy(event) { event.preventDefault() const text = this.textToCopy() // Copy to clipboard navigator.clipboard.writeText(text).then(() => { this.showSuccess() }).catch(() => { console.error("Failed to copy text") }) } // Helper to determine what text to copy textToCopy() { if (this.sourceTarget.nodeName === "INPUT" || this.sourceTarget.nodeName === "TEXTAREA") { return this.sourceTarget.value } else { return this.sourceTarget.textContent.trim() } } // Visual feedback showSuccess() { if (!this.hasButtonTarget) return this.buttonTarget.innerText = "Copied!" this.buttonTarget.disabled = true setTimeout(() => { this.buttonTarget.innerText = this.originalText this.buttonTarget.disabled = false }, this.successDurationValue) } } COMMAND_BLOCK: import { Controller } from "@hotwired/stimulus" export default class extends Controller { static targets = ["source", "button"] static values = { successDuration: { type: Number, default: 2000 } } connect() { // Save the original button text so we can revert it later if (this.hasButtonTarget) { this.originalText = this.buttonTarget.innerText } } copy(event) { event.preventDefault() const text = this.textToCopy() // Copy to clipboard navigator.clipboard.writeText(text).then(() => { this.showSuccess() }).catch(() => { console.error("Failed to copy text") }) } // Helper to determine what text to copy textToCopy() { if (this.sourceTarget.nodeName === "INPUT" || this.sourceTarget.nodeName === "TEXTAREA") { return this.sourceTarget.value } else { return this.sourceTarget.textContent.trim() } } // Visual feedback showSuccess() { if (!this.hasButtonTarget) return this.buttonTarget.innerText = "Copied!" this.buttonTarget.disabled = true setTimeout(() => { this.buttonTarget.innerText = this.originalText this.buttonTarget.disabled = false }, this.successDurationValue) } } CODE_BLOCK: <div data-controller="clipboard"> <!-- The Source --> <input type="text" value="https://myapp.com/invite/123" readonly class="border p-2 rounded" data-clipboard-target="source"> <!-- The Trigger --> <button type="button" class="bg-blue-500 text-white p-2 rounded" data-action="clipboard#copy" data-clipboard-target="button"> Copy Link </button> </div> Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: <div data-controller="clipboard"> <!-- The Source --> <input type="text" value="https://myapp.com/invite/123" readonly class="border p-2 rounded" data-clipboard-target="source"> <!-- The Trigger --> <button type="button" class="bg-blue-500 text-white p-2 rounded" data-action="clipboard#copy" data-clipboard-target="button"> Copy Link </button> </div> CODE_BLOCK: <div data-controller="clipboard"> <!-- The Source --> <input type="text" value="https://myapp.com/invite/123" readonly class="border p-2 rounded" data-clipboard-target="source"> <!-- The Trigger --> <button type="button" class="bg-blue-500 text-white p-2 rounded" data-action="clipboard#copy" data-clipboard-target="button"> Copy Link </button> </div> CODE_BLOCK: <div data-controller="clipboard" data-clipboard-success-duration-value="1000"> <p> Discount Code: <strong data-clipboard-target="source">SUMMER2025</strong> </p> <button type="button" data-action="clipboard#copy" data-clipboard-target="button"> Copy Code </button> </div> Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: <div data-controller="clipboard" data-clipboard-success-duration-value="1000"> <p> Discount Code: <strong data-clipboard-target="source">SUMMER2025</strong> </p> <button type="button" data-action="clipboard#copy" data-clipboard-target="button"> Copy Code </button> </div> CODE_BLOCK: <div data-controller="clipboard" data-clipboard-success-duration-value="1000"> <p> Discount Code: <strong data-clipboard-target="source">SUMMER2025</strong> </p> <button type="button" data-action="clipboard#copy" data-clipboard-target="button"> Copy Code </button> </div> - HTTPS Requirement: The navigator.clipboard API allows writing to the clipboard only in a Secure Context. This means it works on localhost, but on production, your site must be served over HTTPS. If you use HTTP, the copy function will fail. - Icons: If your button contains an icon (like an SVG) instead of text, this.buttonTarget.innerText = "Copied!" will remove the icon. Solution: Instead of changing innerText, toggle a CSS class (e.g., hidden) on two different span elements inside the button (one for the icon, one for the "Copied" text). - Solution: Instead of changing innerText, toggle a CSS class (e.g., hidden) on two different span elements inside the button (one for the icon, one for the "Copied" text). - Solution: Instead of changing innerText, toggle a CSS class (e.g., hidden) on two different span elements inside the button (one for the icon, one for the "Copied" text).