Declarative, controller-scoped element lookups for Stimulus — like targets,
but backed by any CSS selector. Built on the controller-blessing pattern
created by Marco Roth, with code adapted from his
article Supercharge your Stimulus controllers with custom APIs.
Important
This library is currently used internally at OpenProject and is under active development. There is no public roadmap yet — expect APIs to change without notice.
bun add @openproject/stimulus-elements @hotwired/stimulusRegister the blessing once, where you start your Stimulus application:
import { installElements } from "@openproject/stimulus-elements"
installElements()installElements() must run before you register any controllers / call
Application.start() — blessings are snapshotted per controller at
registration time, so installing afterward yields controllers without the
accessors.
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static elements = {
backdrop: "#backdrop",
item: ".item",
menuItem: ".menu > li",
}
connect() {
this.backdropElement?.classList.remove("hidden") // Element | null
this.itemElements.forEach((el) => (el.dataset.ready = "true")) // Element[]
if (this.hasMenuItemElement) { /* ... */ } // boolean
}
}For each entry foo: "<selector>" (key camelized) you get:
| Accessor | Type | Behaviour |
|---|---|---|
this.fooElement |
Element | null |
first match, scoped to the controller element |
this.fooElements |
Element[] |
all matches, as a real array |
this.hasFooElement |
boolean |
whether a match exists |
Lookups are scoped to the controller's own element, read live on every
access, and never throw — an invalid selector warns once and yields null / [].
Any declared element's selector can be overridden per instance from the controller element, without changing the controller:
<div data-controller="test"
data-test-backdrop-element=".backdrop"
data-test-menu-item-element=".item:not([data-disabled])"></div>The attribute is data-[identifier]-[name]-element, where [name] is the element
name in kebab-case (so menuItem becomes menu-item). When present and non-empty it
wins over the static elements selector; an empty or whitespace-only value falls back
to the static selector. Overrides are read live, like all lookups.
Keep element names to simple camelCase words — an embedded acronym like htmlURL dasherizes to html-u-r-l, which is hard to predict in the attribute.
import { Controller } from "@hotwired/stimulus"
import type { WithElements } from "@openproject/stimulus-elements"
interface MyController extends WithElements<{ backdrop: string }> {}
class MyController extends Controller {
static elements = { backdrop: "#backdrop" }
}WithElements camelizes keys exactly like the runtime, so you can pass your
static elements keys verbatim — menu_item and menuItem both yield
menuItemElement / menuItemElements / hasMenuItemElement.
Releases are driven by changesets.
When your PR changes published behaviour, run bunx changeset and commit the
generated file alongside your change. On merge to main, a "Release Tracking"
PR collects pending changesets; merging that PR bumps the version, updates
CHANGELOG.md, and publishes to npm (via OIDC trusted publishing — no tokens).
Adapted from Marco Roth's article Supercharge your Stimulus controllers with custom APIs, which introduced the blessing pattern this library builds on.