stimulus
npx machina-cli add skill smnandre/symfony-ux-skills/stimulus --openclawStimulus
Modest JavaScript framework that connects JS objects to HTML via data attributes. Stimulus does not render HTML -- it augments server-rendered HTML with behavior.
The mental model: HTML is the source of truth, JavaScript controllers attach to elements, and data attributes are the wiring. No build step required with AssetMapper.
Quick Reference
data-controller="name" attach controller to element
data-name-target="item" mark element as a target
data-action="event->name#method" bind event to controller method
data-name-key-value="..." pass typed data to controller
data-name-key-class="..." configure CSS class names
data-name-other-outlet=".selector" reference another controller instance
Controller Skeleton
// assets/controllers/example_controller.js
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static targets = ['input', 'output'];
static values = { url: String, delay: { type: Number, default: 300 } };
static classes = ['loading'];
static outlets = ['other'];
connect() {
// Called when controller connects to DOM
}
disconnect() {
// Called when controller disconnects -- clean up here
}
submit(event) {
// Action method
}
}
File naming convention: hello_controller.js maps to data-controller="hello". Subdirectories use -- as separator: components/modal_controller.js maps to data-controller="components--modal".
HTML Wiring Examples
Basic Controller
<div data-controller="hello">
<input data-hello-target="name" type="text">
<button data-action="click->hello#greet">Greet</button>
<span data-hello-target="output"></span>
</div>
Values from Server (Twig)
Pass server data to controllers via value attributes. Values are typed and automatically parsed.
<div data-controller="map"
data-map-latitude-value="{{ place.lat }}"
data-map-longitude-value="{{ place.lng }}"
data-map-zoom-value="12">
</div>
Available types: String, Number, Boolean, Array, Object. Values trigger {name}ValueChanged() callbacks when mutated.
Actions
The format is event->controller#method. Default events exist per element type (click for buttons, input for inputs, submit for forms) so the event can be omitted.
{# Explicit event #}
<button data-action="click->hello#greet">Greet</button>
{# Default event (click for button) #}
<button data-action="hello#greet">Greet</button>
{# Multiple actions on same element #}
<input type="text"
data-action="focus->field#highlight blur->field#normalize input->field#validate">
{# Prevent default #}
<form data-action="submit->form#validate:prevent">
{# Keyboard shortcuts #}
<div data-action="keydown.esc@window->modal#close">
<input data-action="keydown.enter->modal#submit keydown.ctrl+s->modal#save">
{# Global events (window/document) #}
<div data-action="resize@window->sidebar#adjust click@document->sidebar#closeOutside">
CSS Classes
Externalize CSS class names so controllers stay generic:
<button data-controller="button"
data-button-loading-class="opacity-50 cursor-wait"
data-button-active-class="bg-blue-600"
data-action="click->button#submit">
Submit
</button>
// In controller
this.element.classList.add(...this.loadingClasses);
Multiple Controllers
An element can have multiple controllers:
<div data-controller="dropdown tooltip"
data-action="mouseenter->tooltip#show mouseleave->tooltip#hide">
<button data-action="click->dropdown#toggle">Menu</button>
<ul data-dropdown-target="menu" hidden>...</ul>
</div>
Outlets (Cross-Controller Communication)
Reference other controller instances by CSS selector:
<div data-controller="player"
data-player-playlist-outlet="#playlist">
<button data-action="click->player#playNext">Next</button>
</div>
<ul id="playlist" data-controller="playlist">
<li data-playlist-target="track">Song 1</li>
<li data-playlist-target="track">Song 2</li>
</ul>
// In player controller
static outlets = ['playlist'];
playNext() {
const tracks = this.playlistOutlet.trackTargets;
// ...
}
Lazy Loading (Heavy Dependencies)
Load controller JS only when the element appears in the viewport. Use for controllers with heavy dependencies (chart libs, editors, maps).
/* stimulusFetch: 'lazy' */
import { Controller } from '@hotwired/stimulus';
import Chart from 'chart.js';
export default class extends Controller {
connect() {
// Chart.js is only loaded when this element enters the viewport
}
}
The /* stimulusFetch: 'lazy' */ comment must be the very first line of the file.
Symfony / Twig Integration
Raw data attributes are the recommended approach -- they work everywhere, are easy to read, and need no special helpers.
{# Raw attributes (preferred) #}
<div data-controller="search"
data-search-url-value="{{ path('api_search') }}">
Twig helpers exist for complex cases or when generating attributes programmatically:
{# Twig helper #}
<div {{ stimulus_controller('search', { url: path('api_search') }) }}>
{# Chaining multiple controllers #}
<div {{ stimulus_controller('a')|stimulus_controller('b') }}>
{# Target and action helpers #}
<input {{ stimulus_target('search', 'query') }}>
<button {{ stimulus_action('search', 'submit') }}>
Key Principles
HTML drives, JS responds. Controllers don't create markup -- they attach behavior to existing HTML. If you find yourself generating DOM in a controller, consider whether a TwigComponent or LiveComponent would be better.
One controller, one concern. A dropdown controller handles dropdowns. A tooltip controller handles tooltips. Compose multiple controllers on the same element rather than building mega-controllers.
Clean up in disconnect(). If connect() adds event listeners, timers, or third-party library instances, disconnect() must remove them. Turbo navigation will disconnect and reconnect controllers as pages change.
Values over data attributes. Use Stimulus values (typed, with change callbacks) rather than raw data-* attributes for data that the controller needs to read or watch.
References
- Full API (lifecycle, targets, values, actions, classes, outlets): references/api.md
- Patterns (debounce, fetch, modals, forms, etc.): references/patterns.md
- Gotchas (common mistakes, debugging, Turbo compatibility): references/gotchas.md
Source
git clone https://github.com/smnandre/symfony-ux-skills/blob/main/skills/stimulus/SKILL.mdView on GitHub Overview
Stimulus is a modest JavaScript framework that connects JS objects to HTML via data attributes and augments server-rendered HTML with behavior. It treats HTML as the source of truth and attaches controllers to elements, wiring actions, targets, and values without a full render. With Symfony UX, Stimulus integrates via StimulusBundle and AssetMapper for seamless usage.
How This Skill Works
JavaScript controllers attach to elements using data-controller and wire behavior through data-target, data-action, data-value, and data-outlet attributes. The HTML remains the source of truth, and Stimulus augments it at runtime without rendering new HTML; AssetMapper ensures a zero-build setup. The Controller skeleton shows how to define targets, values, classes, and outlets, plus connect and disconnect hooks for lifecycle management.
When to Use It
- Add JavaScript behavior to server-rendered HTML without a full SPA.
- Wrap a third-party JS library inside a Stimulus controller to keep your HTML clean.
- Create interactive UI patterns like toggles, dropdowns, modals, tabs, or clipboard functionality.
- Enhance components with per-element logic using data-controller and data-action bindings.
- Build client-only interactions that donβt require a server round-trip or complex routing.
Quick Start
- Step 1: Create a Stimulus controller file (e.g., assets/controllers/example_controller.js) and extend Controller with static targets/values as needed.
- Step 2: Attach the controller in HTML using data-controller="example" and wire targets/actions with data-*-target and data-action attributes.
- Step 3: Ensure AssetMapper loads the controller so you can start interactivity without a build step.
Best Practices
- Use clear, descriptive controller names and file mappings (e.g., hello_controller.js maps to data-controller="hello").
- Define static targets, values, classes, and outlets to organize interactions from the start.
- Keep action methods small and focused; prefer single-responsibility handlers and reuse when possible.
- Wire events with data-action and rely on default events where appropriate to reduce boilerplate.
- Clean up in disconnect to avoid memory leaks; use outlets for cross-controller references.
Example Use Cases
- Toggle a section's visibility with a simple show/hide controller.
- Open and close a dropdown, updating UI state and accessibility attributes.
- Open a modal and trap focus, then close it with a dedicated action.
- Switch between tabs by updating content panes and active states.
- Copy text to the clipboard using a dedicated action and visual feedback.