Project

stimeo

0.0
The project is in a healthy, maintained state
Name held by Stimeo Labs so `gem "stimeo"` resolves to the real library. Prefer depending on stimeo-ui directly; this gem only depends on it and loads it.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

>= 0.2.0
 Project Readme

Stimeo UI

Live demo →

CI npm gem License: MIT

Headless Stimulus UI framework for Ruby on Rails. Stimeo UI ships behavior — ARIA state, keyboard interaction, focus management, Turbo resilience — as data-*-driven Stimulus controllers. It does not ship CSS: the consuming app owns the look entirely.

  • Lean by design: the core needs only @hotwired/stimulus at runtime (kept external in the build). Two opt-in subpaths each add one optional peer dependency: stimeo-ui/positioning (@floating-ui/dom) and stimeo-ui/cable (@rails/actioncable); see the Peer dependencies note below.
  • Accessibility first: every controller follows the relevant WAI-ARIA APG pattern and the related WCAG 2.2 AA criteria.
  • Public controller identifiers use the stimeo-- namespace (e.g. stimeo--dropdown).

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

Install

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, and registers the core controllers with your Stimulus application (app/javascript/controllers/stimeo.js). It writes the opt-in stimeo-ui/positioning and stimeo-ui/cable entry points into those two files commented out; to enable one, uncomment its lines. The cable lines include the pin for its @rails/actioncable peer; positioning also needs a pin for @floating-ui/dom. 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.

Need only a few controllers? Import them individually from stimeo-ui/controllers/* and register them under your own identifiers. Each file loads on its own, yet the coordination between components — which layer Escape closes, a focus trap's Tab and background, the value two components lend the same attribute — is kept once per page, whichever files the controllers came from, stimeo-ui itself included. Copies of the same controller also share dirty-form confirmations, highlight deadlines and portal origins. Opt-in Cable copies share the consumer, subscriptions and peer departure decisions; generated ARIA ids stay distinct across copies too.

  • Peer dependencies: @hotwired/stimulus (always), @floating-ui/dom (only if you use the opt-in stimeo-ui/positioning module — tooltips, popovers, etc. work without it via the default flow layout), and @rails/actioncable (only if you use the opt-in stimeo-ui/cable module).
  • No CSS is shipped. Style the components yourself; controllers only toggle ARIA state and data-* hooks.

Linting

Stimeo UI is headless, so you author the WAI-ARIA roles, states, and properties — and some controllers use explicit roles as selector contracts (the data-grid finds its rows via [role="row"]). Your markup therefore contains valid custom-widget ARIA such as <ul role="menu">, <div role="radio">, and <table role="grid">…<td role="gridcell">.

Strict static a11y linters — Biome's recommended preset (≥ 2.5) and eslint-plugin-jsx-a11y — report these valid APG patterns as errors, because their heuristics assume native semantic elements (there is no native equivalent for a custom, fully-stylable radio). Relax the conflicting rules only for the paths where you author Stimeo UI markup — set includes to your own component directories (the value below is a placeholder; adjust it to your layout) and keep the rules on everywhere else. For Biome:

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

The eslint-plugin-jsx-a11y equivalents are no-noninteractive-element-to-interactive-role, no-redundant-roles, prefer-tag-over-role, interactive-supports-focus, and no-noninteractive-tabindex. This repository's tests check these components with axe-core and with a virtual screen reader for speech order; neither stands in for a real screen reader on your own pages.

Composing components

Parts dispatch stimeo--<identifier>:<event> with a detail, and Stimulus can bind one part's event straight to another part's action. Wiring two parts is a data-action, not a <script>.

<!-- Copy a link and say so, with nothing in between. -->
<div data-controller="stimeo--toast"
     data-action="stimeo--clipboard:copy->stimeo--toast#show">
  <div data-controller="stimeo--clipboard"
       data-stimeo--clipboard-text-value="https://example.com/share"
       data-stimeo--clipboard-copied-label-value="Copied"
       data-stimeo--clipboard-error-label-value="Copy failed">
    <button type="button" data-stimeo--clipboard-target="button"
            data-action="click->stimeo--clipboard#copy">Copy</button>
  </div>
  <ol data-stimeo--toast-target="list"></ol>
  <template data-stimeo--toast-target="template">
    <li data-stimeo--toast-target="item"><span data-toast-slot="message"></span></li>
  </template>
</div>
<!-- A wizard moves a read-only progress indicator. -->
<div data-controller="stimeo--stepper">
  <ol data-controller="stimeo--step-indicator"
      data-action="stimeo--stepper:change@window->stimeo--step-indicator#setIndex">
    <li data-stimeo--step-indicator-target="step">Cart</li>
    <li data-stimeo--step-indicator-target="step">Shipping</li>
  </ol>
  <!-- the stepper's own step targets and buttons -->
</div>
<!-- A value a widget stepped submits the form, through the events a browser
     would have fired for its own control. -->
<form data-controller="stimeo--auto-submit"
      data-action="change->stimeo--auto-submit#submit">
  <div data-controller="stimeo--number-input">
    <input type="number" name="quantity" value="1" aria-label="Quantity"
           data-stimeo--number-input-target="input"
           data-action="change->stimeo--number-input#onInput
                        keydown->stimeo--number-input#onKeydown" />
    <button type="button" aria-label="Increase" tabindex="-1"
            data-stimeo--number-input-target="increment"
            data-action="click->stimeo--number-input#increment">+</button>
  </div>
</form>

Two things to know:

  • Events bubble from the part that dispatched them. The receiver has to be an ancestor. Anywhere else — a sibling, or a receiver nested inside the part that dispatches — needs @window, because bubbling only ever goes up: stimeo--clipboard:copy@window->stimeo--toast#show.
  • Do not say the same thing twice. A toast's message slot is a role="status" live region. Wiring the same result to the shared announcer as well (announce-copied-text, …) reads it out twice. Pick one.

stimeo check verifies both halves of a wire: the controller and action on the receiving side, and the event name on the emitting side.

Styling notes

hidden under a utility CSS layer

Controllers show and hide their own declared regions with the hidden attribute — a listbox option filtered out, an "empty" row, the half of a label that does not belong to the current state. hidden only carries display: none from the user-agent stylesheet, so any rule that sets display wins over it.

Utility-first frameworks make that collision likely. Tailwind v4 declares @layer theme, base, components, utilities, and DaisyUI puts component classes such as .menu li, .tabs, and .steps in the last layer. A hidden element inside one of them stays visible, and a display: none you add in @layer components still loses.

Put the override outside every layer — unlayered rules beat layered ones, so no !important is needed:

/* not inside @layer */
.menu li[hidden],
.menu [role="option"][hidden] {
  display: none;
}

Most visible with combobox, listbox, multi-select, empty-state, filter, and command-palette, whose options and empty rows are the ones being hidden.

State-dependent text belongs in markup

It is tempting to put the words that change with a state into CSS:

/* don't */
.play::after                      { content: "Auto-advance: off"; }
.play[aria-pressed="true"]::after { content: "Auto-advance: on"; }

Text in content never reaches a translation catalogue, and the usual "find untranslated strings" scan reads HTML, so it does not turn up there either. content: attr(aria-valuetext) has the same problem from the other side: the attribute is composed at runtime in one language. Keep the words in markup and let the controller show the half that applies:

<button data-stimeo--read-more-target="trigger" aria-expanded="false">
  <span data-stimeo--read-more-target="collapsedLabel">Read more</span>
  <span data-stimeo--read-more-target="expandedLabel" hidden>Show less</span>
</button>

Controllers that read out a composed value take the wording as an attribute instead — color-picker accepts data-value-text, and the announcing controllers accept announce-*-text.

Inspector CLI & MCP server

Stimeo UI bundles a zero-dependency static checker for its own markup contract — spelling of controllers/targets/values, required structure, and the ARIA attributes you (the author) must supply:

npx stimeo-ui check app/views    # check your templates (exit 1 on errors)
npx stimeo-ui catalog            # list every controller's public API

Both commands accept --json for machine-readable output, so check drops straight into CI.

The same engine runs as a Model Context Protocol server, so AI coding agents (Claude Code, Cursor, …) can discover the catalog, fetch verified reference markup, and validate generated HTML/ERB before presenting it:

claude mcp add stimeo -- npx -y stimeo-ui mcp

or in .mcp.json (Claude Code) / .cursor/mcp.json (Cursor):

{
  "mcpServers": {
    "stimeo": {
      "command": "npx",
      "args": ["-y", "stimeo-ui", "mcp"]
    }
  }
}

It exposes four read-only tools — stimeo_check (validate a source string), stimeo_catalog, stimeo_controller (one controller's full contract, accessibility requirements included), and stimeo_example (verified example markup: the controller's demo under examples/, bundled at build time and guaranteed to pass the checker) — plus MCP resources (stimeo://manifest, stimeo://examples/<id>) for preloading context without a tool round-trip. The server reads only its bundled manifest and example index; there are no write-capable tools.

The same checks also run live in your editor: the Stimeo UI Inspector extension on the VS Code Marketplace and Open VSX (for Cursor / VSCodium / Windsurf) gives as-you-type diagnostics, quick fixes, completions, and contract hovers. No setup: the engine and a manifest snapshot are bundled, and when your workspace installs stimeo-ui, the nearest installed version wins — so diagnostics always match what you run.

Contributing

Bug reports and feature requests are very welcome — please open a GitHub issue. For code changes, open an issue first to discuss direction; see CONTRIBUTING.md.

License & Pro

Free and open source under the MIT License © Stimeo Labs. Every component in this repository is Core, and the MIT grant is irrevocable.

Stimeo UI Pro — advanced behavior components that are the most work to build yourself — is planned around 1.0 as a separately licensed commercial track, built alongside (never carved out of) Core. For release news and early access, join the waitlist at stimeo-labs.com/waitlist.