{"title":"Stimulus Integration","description":"Add a Stimulus controller through Amber's build-time asset manifest","section":"guides/assets","version":"v2","path":"guides/assets/stimulus","canonical_url":"https://amberframework.org/docs/v2/guides/assets/stimulus","markdown_url":"https://amberframework.org/docs/v2/guides/assets/stimulus.md","inherited":false,"content_markdown":"# Stimulus integration\n\n> **Optional library:** Amber's asset manifest is release-gated. Stimulus is an\n> optional third-party dependency, not an Amber requirement; review and pin the\n> exact browser artifact your application chooses.\n\nStimulus can add focused behavior to server-rendered ECR without moving markup\nor page ownership into JavaScript. Asset Pipeline fingerprints the local modules\nand Amber's manifest-aware import-map helper connects stable module names to\ntheir generated URLs.\n\nComplete [Asset Pipeline](../) first. This page creates and edits:\n\n**Reference file map:**\n\n```text\napp/assets/javascript/application.js\napp/assets/javascript/controllers/dropdown_controller.js\nsrc/views/layouts/application.ecr\nsrc/views/home/index.ecr\n```\n\n## 1. Create the controller\n\n**File: `app/assets/javascript/controllers/dropdown_controller.js` — create\nthis complete file.**\n\n```javascript\nimport { Controller } from \"@hotwired/stimulus\"\n\nexport default class extends Controller {\n  static targets = [\"button\", \"panel\"]\n\n  connect() {\n    this.close()\n  }\n\n  toggle() {\n    const open = this.buttonTarget.getAttribute(\"aria-expanded\") !== \"true\"\n    this.buttonTarget.setAttribute(\"aria-expanded\", String(open))\n    this.panelTarget.hidden = !open\n  }\n\n  close() {\n    this.buttonTarget.setAttribute(\"aria-expanded\", \"false\")\n    this.panelTarget.hidden = true\n  }\n}\n```\n\n## 2. Start Stimulus and register the controller\n\n**File: `app/assets/javascript/application.js` — create this complete entry\npoint.**\n\n```javascript\nimport { Application } from \"@hotwired/stimulus\"\nimport DropdownController from \"dropdown-controller\"\n\nconst application = Application.start()\napplication.register(\"dropdown\", DropdownController)\n```\n\nRegistration is explicit. A filename ending in `_controller.js` does not make\nit register itself, and Asset Pipeline does not inspect application semantics.\n\n## 3. Map the modules in the layout\n\n**File: `src/views/layouts/application.ecr` — place this map in `<head>` before\nmodule scripts. Replace any existing import map rather than adding a second.**\n\n```ecr\n<%= javascript_importmap_tag(\n  {\n    \"application\" => \"javascript/application.js\",\n    \"dropdown-controller\" => \"javascript/controllers/dropdown_controller.js\",\n    \"@hotwired/stimulus\" => \"https://cdn.jsdelivr.net/npm/@hotwired/stimulus@3.2.2/+esm\"\n  },\n  preload: [\n    \"javascript/application.js\",\n    \"javascript/controllers/dropdown_controller.js\"\n  ]\n) %>\n```\n\n**File: `src/views/layouts/application.ecr` — start the application immediately\nbefore `</body>`.**\n\n```ecr\n<%= content %>\n<script type=\"module\">import \"application\";</script>\n</body>\n```\n\nThe two local values are strict logical paths resolved through the asset\nmanifest. The exact external HTTPS URL passes through. Pinning a version does\nnot remove CDN availability, privacy, integrity, or policy risk; to self-host,\nplace the reviewed browser-ready ESM artifact under\n`app/assets/javascript/vendor/` and map that logical path instead.\n\n## 4. Add the ECR markup\n\n**File: `src/views/home/index.ecr` — add this section inside the page content.**\n\n```ecr\n<section data-controller=\"dropdown\">\n  <button\n    type=\"button\"\n    data-dropdown-target=\"button\"\n    data-action=\"click->dropdown#toggle\"\n    aria-controls=\"framework-details\"\n  >\n    Framework details\n  </button>\n\n  <div id=\"framework-details\" data-dropdown-target=\"panel\">\n    Amber renders the document; Stimulus adds this interaction.\n  </div>\n</section>\n```\n\nThe identifier passed to `application.register`, `data-controller`, each\n`data-action`, and every target prefix must be `dropdown`.\n\n## Pass server values through HTML\n\nUse Stimulus values or ordinary `data-*` attributes for server-rendered\nconfiguration. Do not generate executable JavaScript from user-controlled ECR\nvalues.\n\n**File: `app/assets/javascript/controllers/countdown_controller.js` — create\nthe controller.**\n\n```javascript\nimport { Controller } from \"@hotwired/stimulus\"\n\nexport default class extends Controller {\n  static targets = [\"display\"]\n  static values = { seconds: { type: Number, default: 60 } }\n\n  connect() {\n    this.remaining = this.secondsValue\n    this.displayTarget.textContent = String(this.remaining)\n    this.timer = window.setInterval(() => this.tick(), 1000)\n  }\n\n  tick() {\n    this.remaining -= 1\n    this.displayTarget.textContent = String(this.remaining)\n    if (this.remaining <= 0) window.clearInterval(this.timer)\n  }\n\n  disconnect() {\n    window.clearInterval(this.timer)\n  }\n}\n```\n\nThen make three matching edits:\n\n1. map `\"countdown-controller\"` to\n   `\"javascript/controllers/countdown_controller.js\"` in the existing\n   `javascript_importmap_tag` call;\n2. import it and call `application.register(\"countdown\", CountdownController)`\n   in `app/assets/javascript/application.js`; and\n3. add the following markup to its owning ECR view.\n\n**File: for example `src/views/events/show.ecr` — add this element where the\ntimer belongs.**\n\n```ecr\n<p data-controller=\"countdown\" data-countdown-seconds-value=\"30\">\n  Time remaining:\n  <span data-countdown-target=\"display\" aria-live=\"polite\">30</span>\n</p>\n```\n\nEscape user-controlled attribute values. The view owns the value; the\ncontroller owns reusable behavior.\n\n## Build and verify\n\n**Run from: the application root after every module change.**\n\n```bash\namber assets build\namber assets check\ncrystal spec\namber watch\n```\n\nVerify in order:\n\n1. the manifest contains the application and every controller logical path;\n2. the one import map contains fingerprinted local URLs and the intended pinned\n   Stimulus URL;\n3. every mapped response returns `200` with a JavaScript content type;\n4. the controller connects and the keyboard and pointer interaction work;\n5. navigation away cleans up timers and listeners; and\n6. the browser console contains no import-map, CSP, or module errors.\n\nTrace failures through the actual ownership chain:\n`app/assets/javascript/` source → `amber assets build` →\n`public/assets/manifest.json` → `src/views/layouts/application.ecr` → the\n`data-controller` element."}