Interactive Behaviour
Interactive Behaviour
Interactive behaviour is handled in two layers: CSS for style responses to state, Stimulus controllers for anything requiring JS. Controllers live in app/javascript/gll_component_library/controllers/.
Controllers are UX sprinkles — DOM interactions, class toggles, measurements. Not application logic.
CSS First
Reach for CSS before JS. Hover states, focus styles, transitions, and class-based show/hide need no controller:
.navigation__item:hover { background-color: var(--nav-item-bg-hover); }&:has(+ .open) { transform: rotate(180deg); }.submenu { max-height: 0; overflow: hidden; &.open { max-height: 2000px; }}Use a controller only when the effect requires measuring the DOM, reading runtime values, or responding to events CSS can't handle.
State Classes
Controllers toggle classes on elements; CSS responds to them:
| Class | Applied to | Effect |
|---|---|---|
.open |
Submenus, nav items, panels | Expanded/visible |
.rotate |
Arrow/chevron icons | Rotated state |
.loaded |
Material Symbols icons | Icon font has loaded |
.disabled |
Buttons | Non-interactive |
this.targetTarget.classList.toggle('open')this.arrowbuttonTarget.classList.toggle('rotate')Viewport Breakpoint
Use 1024 in JS — matches @include mobiletablet:
if (window.innerWidth < 1024) { ... }Passing Measurements to CSS
Pass JS measurements to CSS via a custom property on the controller element:
setWidth() { const width = this.logoTarget.offsetWidth this.element.style.setProperty('--width', `${width}px`)}Reading Configuration
Pass configuration via data-* attributes on the controller element; read in connect():
connect() { this.itemsPerView = this.element.getAttribute('data-items-per-view') this.pagination = this.element.getAttribute('data-pagination') === 'true'}Document-Level Listeners
Add in connect(), remove in disconnect():
connect() { this.handleClick = this.handleClick.bind(this) document.addEventListener('click', this.handleClick, true)}disconnect() { document.removeEventListener('click', this.handleClick, true)}Controller Conventions
Filename snake_case → controller name kebab-case: open_toggle_controller.js → data-controller="open-toggle".
Standard Structure
import { Controller } from "@hotwired/stimulus"// Connects to data-controller="my-thing"export default class extends Controller { static targets = ["target"] // only if targets are used connect() { } // one-time DOM setup disconnect() { } // required if document/window listeners added myAction() { } // called via data-action="event->my-thing#myAction"}Targets
static targets = ["logo"]connect() { if (this.hasLogoTarget) { ... } // guard before accessing}Access: this.nameTarget (first match), this.nameTargets (all), this.hasNameTarget (boolean).
Style
const/let— novar- Arrow functions:
() => this.method() - No semicolons
- No TypeScript