0.0
The project is in a healthy, maintained state
Name held by Stimeo Labs; Core is the open-source component set of Stimeo UI, which is what stimeo-ui ships. Prefer depending on stimeo-ui directly.
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.