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>
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"
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"
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 %>
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 %>
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)
}
}
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>
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>
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;
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)