Copy to Clipboard with Stimulus & Rails

Published 02 Nov 2024 · 16 min read
Learn how to build a configurable copy to clipboard feature with the Stimulus JavaScript library as part of a Rails application

In this post, we'll learn how to implement a Copy to Clipboard feature using Stimulus within a Rails application. Before diving into the specifics, let's take a moment to understand what Stimulus is.

Stimulus is a JavaScript library that facilitates adding small, targeted enhancements to web applications. It's part of the Hotwire stack, which is an innovative approach to building modern web apps without the complexity typically associated with heavy JavaScript frameworks for building single page apps. While Stimulus can be used as a standalone library, this post will show how it fits into a Rails application, where it enhances server-rendered HTML with just the right amount of interactivity, aka "JavaScript sprinkles".

Here's what we'll build - given some text and a button:

stimulus copy clipboard demo 1

When the Copy button is clicked, the text is copied to the clipboard, and the button text updates to indicate it has been copied:

stimulus copy clipboard demo 2

At this point, the text starting with "The sun gently peeked.." can be pasted anywhere. A few seconds later, the button text updates to its original value:

stimulus copy clipboard demo 1

The completed project is on Github.

The copy button is an example of a feature that doesn't need server interaction. There's no need to send a request to the server, maintain state in the database and update the url of the application. This is why JavaScript with Stimulus is a perfect solution for this. Let's get started.

This post assumes a basic understanding of Rails controllers and views, and JavaScript, including events and DOM manipulation.

Initial Setup

For this project, I'm using Rails 7.1 and generated a controller that makes some data available for a view as follows:

bin/rails g controller welcome index
# app/controllers/welcome_controller.rb
class WelcomeController < ApplicationController
  def index
    @very_important_content = <<-CONTENT
      The sun gently peeked through the dense foliage, casting dappled shadows on the forest floor.
      With a steady hand, the artist meticulously applied strokes of vibrant color to the canvas, bringing the landscape to life.
      As the waves crashed against the rugged coastline, a sense of tranquility washed over the solitary figure standing on the cliff's edge.
      Amidst the bustling city streets, the aroma of freshly brewed coffee wafted from the quaint cafe, inviting passersby to linger a little longer.
    CONTENT
  end
end

Here is the corresponding view that displays this content:

<%# app/views/welcome/index.html.erb %>
<div>
  <%= @very_important_content %>
</div>

Let's make it the default route:

# config/routes.rb
Rails.application.routes.draw do
  get "welcome/index"
  root "welcome#index"
end

Now we'd like to add a Copy button that copies the content text to the clipboard. To get started with the copy to clipboard feature, we first need a button that the user can click to start the interaction. Normally buttons would be contained in a form with an action that POSTs to the server. But this is going to be a client-side only interaction, therefore no form or action is needed:

<%# app/views/welcome/index.html.erb %>

<div>
  <%= @very_important_content %>
</div>

<%# === ADD BUTTON HERE === %>
<button>Copy</button>

Introduce Stimulus

The next step is to generate a Stimulus controller. This is a JavaScript file that will be responsible for:

  1. Handling the Copy button click event
  2. Using the Clipboard API to copy the text in the content div to the clipboard
  3. Updating the text of the Copy button to "Copied" for 2 seconds, and then setting it back to "Copy" after the 2 seconds have passed.

Rails provides a generator for this:

bin/rails generate stimulus clipboard
# create  app/javascript/controllers/clipboard_controller.js

This creates the following file:

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

// Connects to data-controller="clipboard"
export default class extends Controller {
  connect() {
  }
}

The connect() function is one of several lifecycle callbacks. It's called every time the associated element enters the DOM. To associate a DOM element with a Stimulus controller, data- attributes are used. This will make more sense with an example.

Let's update the welcome view to wrap both the content and button in a wrapper div, and assign the data-controller attribute to the wrapper div to connect it to our controller:

<%# app/views/welcome/index.html.erb %>

<%# === CONNECT CLIPBOARD STIMULUS CONTROLLER TO DOM === %>
<div data-controller="clipboard">
  <div>
    <%= @very_important_content %>
  </div>
  <button>Copy</button>
</div>

Now update the connect() function in the controller to log that it's been called:

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

// Connects to data-controller="clipboard"
export default class extends Controller {
  connect() {
    console.log("=== CLIPBOARD CONTROLLER CONNECTED === ")
  }
}

Navigating to http://localhost:3000/ with the browser dev tools open to the Console tab should show:

=== CLIPBOARD CONTROLLER CONNECTED ===

Recall welcome/index is the default route. So when navigating to /, the welcome controller index action renders the app/views/welcome/index.html.erb view, which causes the div with data-controller="clipboard" attribute to enter the DOM, which in turn causes the connect() function in the clipboard Stimulus controller to run.

Copy Button Action

Now that the controller is connected, it's time to make it do some actual work.

Stimulus uses actions for handling DOM events. An action connects a DOM element and an event listener fired on that element, to a function within the controller. In this case, we need to handle the "click" event on the Copy button.

Update the markup by adding a data-action attribute on the button element:

<%# app/views/welcome/index.html.erb %>
<div data-controller="clipboard">
  <div>
    <%= @very_important_content %>
  </div>

  <%# === CONNECT CLICK EVENT ON BUTTON TO COPY FUNCTION IN CONTROLLER === %>
  <button data-action="click->clipboard#copy">Copy</button>
</div>

The syntax for the data-action value is "event->controllerName#controllerFunction".

Then add a copy() function in the controller. For now, it just logs that it was called. The connect() function has been removed as we don't need to run any code when the element enters the DOM.

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

export default class extends Controller {
  // Executed when a DOM element with `data-action="click->clipboard#copy"`
  // is clicked on.
  copy() {
    console.log("=== CLIPBOARD COPY CALLED ===")
  }
}

Now if you refresh http://localhost:3000 and click the Copy button, the dev tools Console tab should show:

=== CLIPBOARD COPY CALLED ===

Since the click event is commonly used for button elements, Stimulus provides a shorthand syntax for the data-action attribute, allowing you to leave off the "click" event:

<button data-action="clipboard#copy">Copy</button>

The full list of shorthands can be found here.

Now that the click event handler is hooked up to the Stimulus clipboard controller, the next step is to update the copy() function to use the Clipboard API to copy the text in the content div. The structure will be as follows:

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

export default class extends Controller {
  copy() {
    // The text to be copied should come from the content div
    const text = "TBD..."

    navigator.clipboard.writeText(text)
      .then(() => {
        // success
      })
      .catch((error) => {
        // fail
      });
  }
}

The controller needs to know what text to copy. The actual text is contained in the content div. This requires getting a reference to the DOM element <div>The sun gently peeked through...</div>. This is explained in the next section.

Targets

Stimulus uses targets to get references to specific DOM elements that are within the scope of the controller. This is accomplished by adding a data-clipboard-target=XXX attribute to an element, where XXX will become part of the variable name in the controller. The clipboard part of this data attribute refers to the controller name.

For the copy to clipboard functionality, we need a reference to the content div so that we can extract its innerText to pass to the clipboard. We'll name this content by adding a data-clipboard-target="content" to the content div:

<%# app/views/welcome/index.html.erb %>

<div data-controller="clipboard">

  <%# === MAKE THIS ELEMENT AVAILABLE IN CONTROLLER === %>
  <div data-clipboard-target="content">
    <%= @very_important_content %>
  </div>

  <button data-action="clipboard#copy">Copy</button>
</div>

To get a reference to the content element in the Stimulus controller, add it to the static targets array. Then it can be used in any function as this.contentTarget. The code below logs out the innerText value of the content div:

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

export default class extends Controller {
  // ADD DOM ELEMENT TARGETS HERE
  static targets = ["content"]

  copy() {
    // Now we can reference `this.contentTarget` to get the
    // DOM element having data-clipboard-target="content"
    const text = this.contentTarget.innerText
    console.log(`=== TEXT TO BE COPIED: ${text} ===`)
  }
}

Now if you refresh the browser at http://localhost:3000 and click the Copy button, the console tab should display:

=== TEXT TO BE COPIED: The sun gently peeked through the dense foliage... ===

Clipboard API

Now that we have a click handler hooked up to the Copy button, and are able to get a reference to the text content to be copied, we can put this all together with the clipboard API. Specifically the writeText function is used to copy the given text to the system clipboard. It returns a promise that resolves after the clipboard has been updated with the text.

Update the copy() function in the controller as follows:

import { Controller } from "@hotwired/stimulus";

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

  copy() {
    const text = this.contentTarget.innerText

    // === USE THE CLIPBOARD API TO COPY `text`
    navigator.clipboard.writeText(text)
      .then(() => {
        console.log('Text copied to clipboard');
      })
      .catch((error) => {
        console.error('Failed to copy text to clipboard:', error);
      });
  }
}

To test if this is working, refresh the browser at http://localhost:3000 and click the Copy button. The dev tools Console tab should show:

Text copied to clipboard

Then, entering Cmd + V in a new text document, or with your cursor anywhere in an existing document, should paste in the content from the page, starting with The sun gently peeked through the dense....

User Feedback

Technically, the copy to clipboard feature is working, but there's no visible feedback to the user that the copy was successful. It would be nice to update the text of the Copy button to "Copied" for a few seconds, so the user knows it worked, and then change the text back to "Copy".

In JavaScript, if you have a reference to a button element, it's text can be modified by setting it's textContent property. So if we had a reference to the button element, we could change its text in the copy() function, after the clipboard writeText promise is resolved.

To get a reference to a DOM element, we need an additional target. Let's include one for the button element, naming it button. The button element already has a data- attribute for the copy action we implemented earlier, and it's perfectly fine to have multiple data- attributes on the same element:

<%# app/views/welcome/index.html.erb %>

<div data-controller="clipboard">
  ...

  <%# === GET A REFERENCE TO THE BUTTON ELEMENT BY NAMING IT `button` === %>
  <button
    data-action="clipboard#copy"
    data-clipboard-target="button">
      Copy
  </button>
</div>

Then update the static targets list in the clipboard controller to add button:

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  // === ADD BUTTON AS TARGET HERE ===
  static targets = ["content", "button"]

  copy() {
    // ...
  }
}

Once the button is added as a target, it can be referenced in controller functions as this.buttonTarget. Let's update the copy() function to update the button's text, then use a setTimeout to put the text back to what it was, after a 2 second delay:

import { Controller } from "@hotwired/stimulus"

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

  copy() {
    const text = this.contentTarget.innerText
    navigator.clipboard.writeText(text)
      .then(() => {
        // === UPDATE BUTTON TEXT TO COPIED ===
        this.buttonTarget.textContent = 'Copied';

        // === RESET THE BUTTON TEXT AFTER 2 SECONDS ===
        setTimeout(() => {
          this.buttonTarget.textContent = 'Copy';
        }, 2000);
      })
      .catch((error) => {
        console.error('Failed to copy text to clipboard:', error);
      });
  }
}

Values

At this point, the feature is functional and could be considered complete. But there's a few customizations that could make this more re-usable across your application. For example, there may be some places where the success text should show something else such as "Done". You may also want variability in the delay, for example 3 seconds or just 1 second rather than 2 seconds.

Currently these values are hard-coded in the controller. It would be nice if the app developer could provide these as inputs to the controller. In Stimulus, this is accomplished with values.

Start by declaring what inputs the Stimulus controller should accept with the static values declaration. This accepts an object where the keys are the input variable names, and the values are objects specifying the data types that the input will be converted to as it's read from the DOM into a JavaScript variable. You can also specify a default value in case none is provided from the DOM.

In the version of the controller below, two input values have been added to customize the behaviour:

  1. confirm, which is a String to indicate what confirmation text the button should show after successful copy.
  2. delayMs, which is a number in milliseconds before the original button text is displayed again
import { Controller } from "@hotwired/stimulus"

// Connects to data-controller="clipboard"
export default class extends Controller {
  // ...

  // === DECLARE INPUT VARIABLES HERE ===
  static values = {
    confirm: { type: String, default: 'Copied' },
    delayMs: { type: Number, default: 2000 }
  }

  copy() {
    // ...
  }
}

Then the view is updated to pass these values in to the controller. Suppose the button text should be updated to Done rather than Copied, and we'd like a 3000 millisecond delay instead of the default 2000:

<%# app/views/welcome/index.html.erb %>

<%# === SPECIFY ADDITIONAL INPUT VALUES ON THE DOM ELEMENT === %>
<%# === WHERE THE STIMULUS CONTROLLER IS DECLARED === %>
<div data-controller="clipboard"
  data-clipboard-confirm-value="Done"
  data-clipboard-delay-ms-value="3000">
    ...
</div>

Then the Stimulus controller can be updated to use these values instead of the hard-coded Copied and 2000:

import { Controller } from "@hotwired/stimulus"

// Connects to data-controller="clipboard"
export default class extends Controller {
  static targets = ["content", "button"]

  static values = {
    confirm: { type: String, default: 'Copied' },
    delayMs: { type: Number, default: 2000 }
  }

  copy() {
    const text = this.contentTarget.innerText
    navigator.clipboard.writeText(text)
      .then(() => {
        // === USE `confirmValue`
        this.buttonTarget.textContent = this.confirmValue;
        setTimeout(() => {
          this.buttonTarget.textContent = "Copy";
        // USE `delayMsValue`
        }, this.delayMsValue);
      })
      .catch((error) => {
        console.error('Failed to copy text to clipboard:', error);
      });
  }
}

Naming Convention: camelCase is used in JavaScript, whereas kebab-case is used in the DOM. So data-clipboard-delay-ms-value on a DOM element becomes this.delayMs in the controller.

Targets vs Values

Both targets and values end up as this.something in the controller, which can be a little confusing. The distinction is:

Targets are DOM elements that the controller can reference. Use this when you need to perform DOM manipulation. These are specified by adding data-{controllerName}-target="foo" on any DOM element that the controller needs access to.

Values are inputs that can be provided to the controller from the DOM to vary its behaviour. Think of these as arguments provided to a function. These are specified by adding data-{controllerName}-{variableName}="{variableValue}" on the element where the controller is declared.

Here is the final version of the controller with comments explaining the mapping from targets and values to the DOM:

import { Controller } from "@hotwired/stimulus"

// Connects to data-controller="clipboard"
export default class extends Controller {
  // Targets are DOM elements this controller can reference
  //---------------------------------------------------
  // DOM                                JavaScript
  //---------------------------------------------------
  // data-clipboard-target="content"    this.contentTarget
  // data-clipboard-target="button"     this.buttonTarget
  static targets = ["content", "button"]

  // Values are inputs provided from the DOM into the controller
  // DOM                                    JavaScript
  //---------------------------------------------------
  // data-clipboard-confirm-value="foo"     this.confirmValue
  // data-clipboard-delay-ms-value="num ms" this.delayMsValue
  static values = {
    confirm: { type: String, default: 'Copied' },
    delayMs: { type: Number, default: 2000 }
  }

  copy() {
    const text = this.contentTarget.innerText
    navigator.clipboard.writeText(text)
      .then(() => {
        this.buttonTarget.textContent = this.confirmValue;
        setTimeout(() => {
          this.buttonTarget.textContent = "Copy";
        }, this.delayMsValue);
      })
      .catch((error) => {
        console.error('Failed to copy text to clipboard:', error);
      });
  }
}

Internationalization

After publishing the original version of this post, a Reddit user raised a great question: How would you make this work with internationalization? That question brought up an issue I hadn’t considered - there are many hard-coded English words throughout the solution, including in the Stimulus controller:

this.buttonTarget.textContent = "Copy";

While internationalization (i18n) in Rails is a broad topic, here's how this specific problem can be solved:

Step 1: Configure i18n

Add the locales that are supported and specify a default. This can go either in config/application.rb as shown below or in config/initializers/locale.rb:

module StimulusDemo
  class Application < Rails::Application
    # Support English and Spanish
    config.i18n.available_locales = %i[en es]

    # Default to English
    config.i18n.default_locale = :en

    # ...
  end
end

Step 2: Controller Locale Switching

In the main application controller, add a before_action to set the locale from params or the default if not specified:

class ApplicationController < ActionController::Base
  before_action :set_locale

  private

  def set_locale
    I18n.locale = params[:locale] || I18n.default_locale
  end

  # ensures locale is included in all generated URLs
  def default_url_options
    { locale: I18n.locale }
  end
end

Step 3: Modify Routes to Include Locale

Wrap routes that need to work with translations with an optional path scope. This allows routes that don't specify a locale to be considered valid and use the default locale:

# config/routes.rb
Rails.application.routes.draw do
  scope "(:locale)", locale: /en|es/ do
    root "welcome#index"
    get "welcome/index"
  end
  # ...
end

Step 4: Create Translation Files

Take all the currently hard-coded English content out of controllers, and views and move it to the translation files:

# config/locales/en.yml
en:
  clipboard:
    copy: "Copy"
    done: "Done"
  welcome:
    index:
      very_important_content: >
        The sun gently peeked through the dense foliage, casting dappled shadows on the forest floor.
        With a steady hand, the artist meticulously applied strokes of vibrant color to the canvas, bringing the landscape to life.
        ...
# config/locales/es.yml
es:
  clipboard:
    copy: "Copiar"
    done: "Hecho"
  welcome:
    index:
      very_important_content: >
        El sol se asomó suavemente a través del denso follaje, proyectando sombras moteadas en el suelo del bosque.
        ...

Step 5: Add Language Switcher

Update the application layout with links to switch to English or Spanish:

<%# app/views/layouts/application.html.erb %>
<!DOCTYPE html>
<html lang="<%= I18n.locale %>">
  <head>...</head>

  <body>
    <%# === NEW: ADD LINKS FOR LANGUAGE SWITCHING === %>
    <header>
      <div >
        <%= link_to "English", url_for(locale: :en) %>
        <%= link_to "Español", url_for(locale: :es) %>
      </div>
    </header>

    <main>
      <%= yield %>
    <main>
  </body>
</html>

Step 6: Update Welcome Controller

Set translated content rather than hard-coded:

# app/controllers/welcome_controller.rb
class WelcomeController < ApplicationController
  def index
    # @very_important_content = "The sun gently peeked through..."
    @very_important_content = I18n.t("welcome.index.very_important_content")
  end
end

Step 7: Update Stimulus Controller

Replace the hard-coded English word "Copy" with a new input value copyText:

// app/javascript/controllers/clipboard_controller.js
export default class extends Controller {
  // === NEW: copyText ADDED HERE ===
  // === OPTIONALLY REMOVE DEFAULTS SO THESE MUST ALWAYS BE PROVIDED ===
  static values = {
    confirm: { type: String, default: 'Done' },
    delayMs: { type: Number, default: 2000 },
    copyText: { type: String, default: 'Copy' }
  }

  copy() {
    const text = this.contentTarget.innerText
    navigator.clipboard.writeText(text)
      .then(() => {
        this.buttonTarget.textContent = this.confirmValue;
        setTimeout(() => {
          // === NEW: USE copyTextValue INSTEAD OF HARD-CODED ENGLISH WORD ===
          // this.buttonTarget.textContent = "Copy";
          this.buttonTarget.textContent = this.copyTextValue;
        }, this.delayMsValue);
      })
      // ...
  }
}

Step 8: Update Welcome View

Finally, update the welcome index view to pass in the translation values rather than hard-coded English words into the Stimulus controller via the data attributes:

<%# app/views/welcome/index.html.erb %>
<div
  data-controller="clipboard"
  data-clipboard-confirm-value="<%= t('clipboard.done') %>"
  data-clipboard-delay-ms-value="3000"
  data-clipboard-copy-text-value="<%= t('clipboard.copy') %>">

  <div data-clipboard-target="content" id="content">
    <%= @very_important_content %>
  </div>

  <button
    data-action="clipboard#copy"
    data-clipboard-target="button">
      <%= t('clipboard.copy') %>
  </button>
</div>

Now it defaults to English (added some TailwindCSS styles):

stimulus demo language english

Clicking Spanish changes the url and content:

stimulus demo language spanish

Clicking the Copy button while on the /es url, shows that it's functioning in Spanish (and after a few seconds returns to "Copiar"):

stimulus demo language spanish done

The complete changeset to add multi-language support can be viewed here.

Debugging

Stimulus controllers can be debugged using the browser developer tools just like any other JavaScript. For example using Chrome, from the developer tools, click on the "Sources" tab:

stimulus demo debug sources tab

Enter Command + P to bring up the sources fuzzy search (or if using Windows, follow the instructions displayed in the Sources tab for the shortcut), then start typing in "clipboard". It should find a match like this:

stimulus demo debug source fuzzy search

After hitting Enter to action the file match, it will load the file in the Sources tab. Then you can add breakpoints as usual. For example, here I've added a breakpoint at the navigator.clipboard line and clicked on the Copy button:

stimulus demo debug breakpoint

Loading

We didn't have to do anything special like registering or loading the controller. It just "showed up" in the app. This is because this project is using import-maps, which is the default for JavaScript when starting a new Rails 7 project. The Rails integration automatically loads all the Stimulus controller files from app/javascript/controllers. If you're using a different JS bundler such as Webpack or esbuild, it will require a few more steps. See the Stimulus docs on installing for more details.

Maintainability

A few caveats about maintainability:

On a large app with many Stimulus controllers, the template files will end up full of data-controller, data-somecontroller-target and data-somecontroller-somevariable-value. This could get confusing especially if there are multiple controllers in the same area of the DOM.

It would be amazing to have IDE support such as hover/go-to definition from the template to the controller and vice versa. There is Stimulus LSP for VSCode, which provides some support. It has completion support in the template, validation, and using the go-to-definition shortcut on any data- attribute from the template goes to the controller, although always the top of the file, not the specific value or target line. Doesn't work the other way though, from controller to template(s). Free extension idea if anyone's looking for a side project 😀

Another aspect of maintainability is automated testing. At the time of this writing, I couldn't find anything in the Stimulus Reference about unit testing controllers, although there is some discussion here and here.

In the meantime, be sure to have system tests that cover any JavaScript interactivity from the Stimulus controllers. If using Capybara, this means configuring it with a driver capable of executing JavaScript and understanding its waiting behaviour.

Conclusion

This post covered how to implement a Copy to Clipboard feature in a Rails application using Stimulus. It began with an overview of Stimulus and its role in adding interactivity to web apps. Then it explained how to create a new Stimulus controller, use Stimulus actions to handle DOM events, targets to perform DOM manipulation, and values for customization of the controller's behaviour. Finally it covered some aspects of maintainability to be aware of when using Stimulus.

Here's some further reading on Stimulus from the official documentation site: