Install

Add Stimeo UI to your app

Stimeo UI ships behavior-only Stimulus controllers for Rails — ARIA state, keyboard, and focus driven by data-* attributes. It ships no CSS, so the look stays entirely yours.

Status: pre-release (0.x). The stimeo--* attribute API may still change before 1.0 — pin your version.

Rails with importmap (recommended)

bundle add stimeo-ui
bin/rails generate stimeo:install

The generator:

  • vendors the prebuilt JS into vendor/javascript/stimeo/,
  • pins stimeo-ui in config/importmap.rb,
  • registers the core controllers with your Stimulus application (app/javascript/controllers/stimeo.js),
  • writes the opt-in positioning and cable entry points into those two files commented out. Uncomment an entry point's lines to enable it; positioning also needs the @floating-ui/dom pin shown in its section, which the generator does not write (see the sections below).

Re-running it is safe: the vendored copy is refreshed in place, while an existing stimeo-ui pin and your controllers/stimeo.js are left untouched. A re-run therefore adds no opt-in lines to an existing setup; the sections below show them.

Inspector is npm-distributed. The stimeo inspector CLI is a Node tool, so the gem does not bundle it. You can still use it from a gem-installed app without adding npm to your project: npx stimeo check app/views fetches and runs it on demand. The only requirement is a Node runtime — GitHub-hosted Actions runners include one out of the box.

Then drive components from HTML alone:

<div data-controller="stimeo--dropdown">
  <button data-stimeo--dropdown-target="trigger"
          data-action="click->stimeo--dropdown#toggle">Menu</button>
  <div data-stimeo--dropdown-target="menu" hidden>…</div>
</div>

npm (jsbundling or any bundler)

npm install stimeo-ui @hotwired/stimulus
import { Application } from "@hotwired/stimulus";
import { registerStimeo } from "stimeo-ui";

const application = Application.start();
registerStimeo(application); // registers every controller of the core entry point

The opt-in stimeo-ui/positioning and stimeo-ui/cable entry points register theirs with registerPositioning / registerCable (see below).

Prefer cherry-picking? Import controllers individually from stimeo-ui/controllers/* and register them under your own identifiers.

Opt-in positioning

For floating overlays like popover, tooltip, and hover-card, the controller handles the behavior (open / close), while where the panel appears is normally up to your CSS. Only when you want dynamic placement — following the anchor and flipping to stay on-screen at viewport edges — add the opt-in positioning module (stimeo-ui/positioning), backed by @floating-ui/dom. You don't have to: the core stimeo-ui entry includes neither the module nor @floating-ui/dom, so your bundle stays zero-extra-dependency unless you opt in. With importmap, also pin the subpath and its engine (the generator writes the first line commented out):

pin "stimeo-ui/positioning", to: "stimeo/positioning/index.js"
pin "@floating-ui/dom", to: "floating-ui-dom.js"

For the declarative stimeo--anchored controller, also register the module's controllers (the generator writes these lines into controllers/stimeo.js commented out):

import { registerPositioning } from "stimeo-ui/positioning";
registerPositioning(application);

Opt-in cable

Live counters, presence lists, and typing indicators keep their state on an Action Cable channel instead of in the page. Their controllers (stimeo--live-counter, stimeo--presence, stimeo--typing-indicator) live in the opt-in cable module (stimeo-ui/cable), backed by @rails/actioncable. The core stimeo-ui entry includes neither, so you add them only if you use them. With importmap, pin the subpath and its engine (the generator writes both lines commented out):

pin "stimeo-ui/cable", to: "stimeo/cable/index.js"
pin "@rails/actioncable", to: "actioncable.esm.js"

Then register its controllers next to the core ones (the generator writes the registerCable lines into controllers/stimeo.js commented out). If your app already has an Action Cable consumer, hand it over first with setCableConsumer, so both share one websocket; without one, the module creates its own on first use.

import consumer from "channels/consumer"; // only if your app has one
import { registerCable, setCableConsumer } from "stimeo-ui/cable";

setCableConsumer(consumer);
registerCable(application);

Linting

Stimeo UI is headless, so you author the WAI-ARIA roles yourself — and some controllers read them back as selector contracts (the data-grid finds its rows via [role="row"]). Your markup therefore holds valid custom-widget ARIA like <ul role="menu">, <div role="radio">, and <td role="gridcell">.

Strict static a11y linters — Biome's recommended preset (≥ 2.5) and eslint-plugin-jsx-a11y — flag these valid APG patterns, because their heuristics assume native semantic elements. Relax the conflicting rules only for the paths where you author Stimeo UI markup (adjust includes to your layout).

In Biome, add these overrides to biome.json:

{
  "overrides": [
    {
      "includes": ["app/components/**"],
      "linter": {
        "rules": {
          "a11y": {
            "noNoninteractiveElementToInteractiveRole": "off",
            "noRedundantRoles": "off",
            "useSemanticElements": "off",
            "useFocusableInteractive": "off",
            "noNoninteractiveTabindex": "off"
          }
        }
      }
    }
  ]
}

If you use ESLint (eslint-plugin-jsx-a11y) instead, turn off the equivalent rules for the same paths (in your .eslintrc.json or equivalent).

Next steps