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-uiinconfig/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/dompin 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
stimeoinspector 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/viewsfetches 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
- Read the styling notes — how controllers hide things, and where state-dependent words belong.
- Browse the live demos in the component catalog.
- Copy the shared styles the demos build on.
- Check your markup with the Inspector (
stimeo check).