DEV Community

Rails Designer
Rails Designer

Posted on • Originally published at railsdesigner.com

Announcing Attractive.js, a new JavaScript-free JavaScript library

This article was originally published on Rails Designer


After last week's introduction of Perron, I am now announcing another little “OSS present”: a JavaScript-free JavaScript library. 🎁 Say what?

👉 If you want to check out the repo and star ⭐ it, that would make my day! 😊

Attractive.js lets you add interactivity to your site using only HTML attributes (hence the name attribute active). No JavaScript code required. Just add ⁠data-action and, optionally, data-target attributes to your elements, and… done! Something like this:

<button data-action="addClass#bg-black" data-target="#door">
  Paint it black
</button>

<p id="door">
  Paint me black
</p>
Enter fullscreen mode Exit fullscreen mode

Or if you want to toggle a CSS class, you write: data-action="toggleClass#bg-black". Or toggle multiple CSS classes: data-action="toggleClass#bg-black,text-white".

Other actions include addAttribute, form#submit and copy#my-api-key. Attractive, right? 😅

I designed Attractive.js to be a little sister of Stimulus, hence the similar data-* attributes API. It also draws inspiration from Alpine, but Attractive.js aims to be way more minimal than both libraries (I guess it also looks like htmx, but I only learned about its syntax when writing this article).

It will never be a replacement for either library (or any advanced JS UI library). For static websites, it's likely all the interactivity layer you'll need. In early-stage Rails applications, it could serve as a lighter alternative to Stimulus. Together with modern CSS, it provides just enough interactivity to quickly ship both static sites and Turbo-powered Rails apps.

Attractive.js' actions

Attractive.js has currently a humble set of “actions”. These actions are chosen to give you most bang for your buck. So you can move quicker and launch faster by writing absolutely no JavaScript! These action groups are currently supported.

  • attributes
  • classes
  • clipboard
  • data attributes
  • dialog
  • form
  • intersection
  • reload
  • scrollTo

Each action is by default applied to the current element (i.e. the element that has the data-action attribute applied). You choose another target (or targets), by setting the data-target.

Actions are named logically and can optionally have a value, as the classes actions showed. Or with the (data) attributes actions it could be written like this:

data-action="toggleAttribute#disabled=disabled"
Enter fullscreen mode Exit fullscreen mode

This would add a disabled=disabled attribute and value to the defined target. Or how about this one that will copy my-api-key to the clipboard?

data-action="copy#my-api-key"
Enter fullscreen mode Exit fullscreen mode

Attractive, you say? I think so too! 😎

Events

Just like Stimulus' event handling, Attractive.js uses the same defaults. click for button and a, submit for form, change for select, etc.

You can also override these or add event listeners to elements that normally would not have them, like div, section or h*. Like this <section data-action="mouseover->addAttribute#open data-target="details">.

Works with Stimulus

Attractive.js works perfect together Stimulus. So once you need more advanced JavaScript handling you can introduce Stimulus while letting Attractive.js handle the basic interactivity.

Code example for inspiration

Here are some examples (including some live examples using CodePen) to give an idea what can be done with Attractive.js.

Submit form on change

You often have written a Stimulus controller to do this. Now you can add simply data attributes.

<%= form_with model: @checklist, id: "form" do |form| %>
  <%= form.check_box :verify_naming, data: { action: "form#submit", target: "form" } %>
  <%= form.label :verify_naming, "Double check that I used the 'Attractive' pun enough times" %>

  <%= form.check_box :update_readme, data: { action: "form#submit", target: "form" } %>
  <%= form.label :update_readme, "Update README to mention we're JavaScript-free*" %>

  <%= form.check_box :add_asterisk, data: { action: "form#submit", target: "form" } %>
  <%= form.label :add_asterisk, "Add footnote: *technically still JavaScript" %>

  <%= form.check_box :social_copy, data: { action: "form#submit", target: "form" } %>
  <%= form.label :social_copy, "Write the most attractive article ever" %>
<% end %>
Enter fullscreen mode Exit fullscreen mode

Or how about a typical country, followed by a region select?

<%= turbo_frame_tag "location" do %>
  <%= form_with model: @location, id: "form" do |form| %>
    <%= form.label :country, "Country" %>
      <%= form.select :country, [["Select a country…", ""], "France", "Germany", "Japan", "Spain"], {}, data: { action: "reload", target: "#location" } %>

    <%= form.label :region, "Region" %>
    <%= form.select :region, [["Select a country first", ""]], { disabled: @location.country.blank? } %>
  <% end %>
<% end %>
Enter fullscreen mode Exit fullscreen mode

Upon selecting the country, the turbo-frame will reload, thus removing the disabled attribute on the region select.

Tabs content

Who doesnt have tabs somewhere in their app or site? Check out this CodePen how you can tackle this. Using addDataAttribute#visible=tab-n as the action.

Toggle password visibility

Check this CodePen to view how to toggle the visibility of a password field. Nice UX for sign up and registration screens. This uses cycleAttribute#type=password,text as the action.

Copy code to your clipboard

This example, using data-action="copy" and data-target="#pre", shows how to add a copy to clipboard button for code (view it on Codepen). It shows a different icon after copied and then reverts back to original icon.

This example was taken from the Perron docs.

Slideshow (with images)

How about a slidehow? A component you see often on (marketing) sites. Check out this CodePen on how to do it (it uses data-action="addDataAttribute#slideshowNumber=n").

Select all checkboxes

Need to (de)select all checkboxes? This one uses a nested target along with toggleAttribute#checked=checked, e.g. #form [type=checkbox]. See the CodePen.

Work with native dialog element

The native HTML dialog element gives a a11y-ready-dialog for free. You need no extra JS when using Attractive.js, just add dialog#{open,openModal}. Check out this CodePen for a live example.

(Transition upon show done using CSS' @starting-style property)

The story behind Attractive.js

I had the idea for Attractive.js for a long time, but it was never more than a little, fragile idea. This little idea became clearer after building some of my products and sites, and working with multiple clients and needingtrying to explain the verbosity of some Stimulus controllers for seemingly little interactivity. Take a toggle class Stimulus controller:

import { Controller } from "@hotwired/stimulus"

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

  toggle() {
    this.elementTarget.classList.toggle(this.classesValue)
  }
}
Enter fullscreen mode Exit fullscreen mode

And then wire up the HTML:

<div data-controller="classes">
  <button data-action="classes#toggle" data-classes-value="bg-black" data-classes-target="button">Paint it black</button>

  <p data-classes-target="element">
    Paint me black
  </p>
</div>
Enter fullscreen mode Exit fullscreen mode

Compare that to the Attractive.js example:

<button data-action="addClass#bg-black" data-target="#door">
  Paint it black
</button>

<p id="door">
  Paint me black
</p>
Enter fullscreen mode Exit fullscreen mode

You see there is a gap between common functionality for websites and (early) stage Rails apps powered by Turbo.

So in March of 2025 I sat down and wrote a small proof of concept (it is still in the original repo's commit history 🤫).

const Attractive = {
  initialize() {
    document.addEventListener('click', this.event.bind(this));
    document.addEventListener('change', this.event.bind(this));

    return this;
  },

  event(event) {
    const element = event.target.closest('[data-action]');
    if (!element) return;

    const actionValue = element.getAttribute('data-action');
    const targetSelector = element.getAttribute('data-target');

    if (actionValue.startsWith('toggleClass#')) {
      const className = actionValue.split('#')[1];

      this.toggleClass(element, className, targetSelector);
    }
  },

  toggleClass(element, className, targetSelector) {
    const targets = targetSelector
      ? document.querySelectorAll(targetSelector)
      : [element.parentElement];

    Array.from(targets).forEach(target => {
      target.classList.toggle(className);
    });
  }
};

if (typeof document !== 'undefined') {
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', () => Attractive.initialize());
  } else {
    Attractive.initialize();
  }
}

if (typeof window !== 'undefined') {
  window.Attractive = Attractive;
}

export default Attractive;
Enter fullscreen mode Exit fullscreen mode

Yes, pretty gnarly. But it showed what I had in mind (JS-free JS library) was possible. It took quite a few more iterations: adding a clean adapter-like system for various classes, using Intersection Observer and so on. Above code is in some form still in the current code base. 😊


It is early days, but I am already using Attractive.js on the docs site for Perron (see sidebar on smaller screens, copy to clipboard for code and ToC also on smaller screens) and Attractive.js.

Would love if you could give the Attractive.js repo that lovely star on GitHub and give it a try in your next project. ⭐

Top comments (0)