All Controllers
A single-page reference for all controllers in stimulus-snippets. Each section is also available as its own page.
Accordion
Expand and collapse a set of content panels, with full ARIA disclosure support and optional single-open mode.
When to use this vs. <details>/<summary>
Reach for <details>/<summary> first when:
- You have a single collapsible section (a “read more”, an inline note).
- You have a group of independent disclosures with no coordination needed.
- You want exclusive single-open behaviour with no JavaScript — the
nameattribute on<details>groups elements so only one can be open at a time, and it has broad browser support.
Reach for this controller when your panels represent distinct, named sections of content that deserve heading-level structure:
- Screen reader users navigate by heading (
h2,h3, …) to jump between sections. Wrapping each trigger in a heading element makes accordion sections discoverable in the document outline;<details>does not appear there. - You want the full ARIA Accordion pattern —
aria-expanded,aria-controls,aria-labelledby— wired up automatically. - You want arrow-key navigation between triggers (
ArrowDown/ArrowUp/Home/End).
Usage
Copy accordion_controller.js to app/javascript/controllers/ and register it:
// app/javascript/controllers/index.js
import AccordionController from "./accordion_controller";
application.register("accordion", AccordionController);
HTML
Basic accordion (any number of panels open)
<div data-controller="accordion">
<h3>
<button
type="button"
data-accordion-target="trigger"
data-action="click->accordion#toggle keydown->accordion#keydown"
aria-expanded="false"
>
Section One
</button>
</h3>
<div data-accordion-target="panel" hidden>
<p>Content for section one.</p>
</div>
<h3>
<button
type="button"
data-accordion-target="trigger"
data-action="click->accordion#toggle keydown->accordion#keydown"
aria-expanded="false"
>
Section Two
</button>
</h3>
<div data-accordion-target="panel" hidden>
<p>Content for section two.</p>
</div>
<h3>
<button
type="button"
data-accordion-target="trigger"
data-action="click->accordion#toggle keydown->accordion#keydown"
aria-expanded="false"
>
Section Three
</button>
</h3>
<div data-accordion-target="panel" hidden>
<p>Content for section three.</p>
</div>
</div>
Triggers and panels are paired by position — the first trigger controls the first panel, and so on.
Starting with a panel open
Omit hidden from the panel and set aria-expanded="true" on the trigger:
<h3>
<button
type="button"
data-accordion-target="trigger"
data-action="click->accordion#toggle keydown->accordion#keydown"
aria-expanded="true"
>
Section One
</button>
</h3>
<div data-accordion-target="panel">
<p>This panel starts open.</p>
</div>
Single-open (exclusive) mode
Add data-accordion-exclusive-value="true" to ensure at most one panel is open at a time:
<div data-controller="accordion" data-accordion-exclusive-value="true">
<!-- same trigger/panel pairs as above -->
</div>
If the page loads with multiple panels open and exclusive mode is on, the controller keeps the first open panel and closes the rest.
API
Targets
| Target | Required | Description |
|---|---|---|
trigger | Yes | A <button> that toggles its paired panel. Paired by index with each panel. |
panel | Yes | The content panel. Paired by index with each trigger. Hidden when collapsed. |
Values
| Value | Type | Default | Description |
|---|---|---|---|
exclusive | Boolean | false | When true, opening one panel closes all others. |
Actions
| Action | Description |
|---|---|
toggle | Opens or closes the panel paired with the clicked trigger. Wire to click on triggers. |
keydown | Arrow-key navigation between triggers. Wire to keydown on triggers. |
Accessibility
The controller sets up the ARIA Accordion pattern:
aria-expandedis set on each trigger on connect (derived from whether the paired panel is hidden) and kept in sync as panels open and close.aria-controlsis set on each trigger pointing to its panel’sid. If a panel has noid, one is generated automatically.aria-labelledbyis set on each panel pointing to its trigger’sid. If a trigger has noid, one is generated automatically.- Panels are shown and hidden via the
hiddenattribute.
Wrap each trigger in a heading element at the appropriate level for the page outline (h2, h3, etc.). This lets screen reader users navigate to accordion sections by heading, which <details>/<summary> does not support.
Authoring note: include aria-expanded and hidden in your HTML so the correct state is present before Stimulus connects.
Keyboard behaviour (while a trigger has focus):
| Key | Action |
|---|---|
Enter/Space | Toggle the focused panel (native <button> behaviour — fires click) |
ArrowDown | Move focus to the next trigger (wraps to first) |
ArrowUp | Move focus to the previous trigger (wraps to last) |
Home | Move focus to the first trigger |
End | Move focus to the last trigger |
Character Count
Show a live character count for a textarea or input, with optional remaining-characters feedback tied to a max length.
Usage
Copy character_count_controller.js to app/javascript/controllers/ and register it:
// app/javascript/controllers/index.js
import CharacterCountController from "./character_count_controller";
application.register("character-count", CharacterCountController);
HTML
<!-- Remaining characters (most common) -->
<div data-controller="character-count" data-character-count-max-value="280">
<label for="bio">Bio</label>
<textarea
id="bio"
name="bio"
maxlength="280"
data-character-count-target="input"
data-action="input->character-count#update"
></textarea>
<p>
<span data-character-count-target="remaining">280</span> characters
remaining
</p>
</div>
<!-- Current count only (no max) -->
<div data-controller="character-count">
<label for="notes">Notes</label>
<textarea
id="notes"
name="notes"
data-character-count-target="input"
data-action="input->character-count#update"
></textarea>
<p><span data-character-count-target="count">0</span> characters</p>
</div>
<!-- Both count and remaining -->
<div data-controller="character-count" data-character-count-max-value="140">
<label for="tweet">Tweet</label>
<textarea
id="tweet"
name="tweet"
maxlength="140"
data-character-count-target="input"
data-action="input->character-count#update"
></textarea>
<p>
<span data-character-count-target="count">0</span> /
<span data-character-count-target="remaining">140</span>
</p>
</div>
API
Targets
| Target | Required | Description |
|---|---|---|
input | Yes | The <input> or <textarea> being counted. |
count | No | Updated with the current character count. |
remaining | No | Updated with max - count. Requires max value set. |
Values
| Value | Type | Default | Description |
|---|---|---|---|
max | Number | — | Maximum character limit. Required for remaining target. |
Actions
| Action | Description |
|---|---|
update | Recalculates count and remaining values |
Accessibility
- The controller adds
aria-live="polite"tocountandremainingtargets on connect (if not already set), so screen readers announce updates as the user types. - Set the initial value in the HTML (e.g.
280) to avoid a flash of incorrect content before Stimulus connects. - Adding
maxlengthto the input enforces the limit at the browser level; themaxvalue here is only for the display counter.
Checkbox Required
Require at least a minimum number of checkboxes in a group to be checked before the form can be submitted.
Usage
Copy checkbox_required_controller.js to app/javascript/controllers/ and register it:
// app/javascript/controllers/index.js
import CheckboxRequiredController from "./checkbox_required_controller";
application.register("checkbox-required", CheckboxRequiredController);
HTML
<!-- Require at least one (default) -->
<form>
<fieldset data-controller="checkbox-required">
<legend>Interests (select at least one)</legend>
<label>
<input
type="checkbox"
name="interests[]"
value="music"
data-checkbox-required-target="checkbox"
data-action="change->checkbox-required#validate"
/>
Music
</label>
<label>
<input
type="checkbox"
name="interests[]"
value="sport"
data-checkbox-required-target="checkbox"
data-action="change->checkbox-required#validate"
/>
Sport
</label>
<label>
<input
type="checkbox"
name="interests[]"
value="film"
data-checkbox-required-target="checkbox"
data-action="change->checkbox-required#validate"
/>
Film
</label>
<p data-checkbox-required-target="error" hidden>
Please select at least one option.
</p>
</fieldset>
<button type="submit">Submit</button>
</form>
<!-- Require at least two -->
<fieldset
data-controller="checkbox-required"
data-checkbox-required-min-value="2"
>
<!-- ... checkboxes ... -->
<p data-checkbox-required-target="error" hidden>
Please select at least two options.
</p>
</fieldset>
The controller automatically intercepts the nearest parent <form>’s submit event — no additional wiring on the form element is needed.
API
Targets
| Target | Required | Description |
|---|---|---|
checkbox | Yes | Each <input type="checkbox"> that counts toward the minimum. |
error | No | Shown when the group is invalid; hidden when valid. Set hidden on this element in HTML. |
Values
| Value | Type | Default | Description |
|---|---|---|---|
min | Number | 1 | Minimum number of checkboxes that must be checked. |
Actions
| Action | Description |
|---|---|
validate | Counts checked boxes; shows the error target and blocks form submission if fewer than min are checked. Wire to change on each checkbox for live feedback. |
Accessibility
- The controller sets
data-valid="true"ordata-valid="false"on the wrapping element after eachvalidatecall. Use CSS to style the error message or checkboxes accordingly. - Place the error message after the checkboxes in the DOM so screen readers encounter it in reading order.
- A native
<fieldset>+<legend>communicates the group to assistive technology without extra ARIA.
Clipboard
Copy text from a source element to the clipboard on button click, with optional brief feedback.
Usage
Copy clipboard_controller.js to app/javascript/controllers/ and register it:
// app/javascript/controllers/index.js
import ClipboardController from "./clipboard_controller";
application.register("clipboard", ClipboardController);
HTML
<!-- Copy an input's value -->
<div data-controller="clipboard">
<input
type="text"
value="Hello world"
data-clipboard-target="source"
readonly
/>
<button type="button" data-action="click->clipboard#copy">Copy</button>
<span data-clipboard-target="feedback" hidden aria-live="polite"
>Copied!</span
>
</div>
<!-- Copy text content from any element -->
<div data-controller="clipboard">
<code data-clipboard-target="source">gem "stimulus-rails"</code>
<button type="button" data-action="click->clipboard#copy">Copy</button>
</div>
<!-- Custom feedback duration (500ms) -->
<div data-controller="clipboard" data-clipboard-success-duration-value="500">
<input type="text" value="API key" data-clipboard-target="source" readonly />
<button type="button" data-action="click->clipboard#copy">Copy</button>
<span data-clipboard-target="feedback" hidden aria-live="polite"
>Copied!</span
>
</div>
API
Targets
| Target | Required | Description |
|---|---|---|
source | Yes | Element to copy from. Uses .value if present (input/textarea), otherwise .textContent. |
feedback | No | Element shown briefly after a successful copy. |
Values
| Value | Type | Default | Description |
|---|---|---|---|
successDuration | Number | 2000 | Milliseconds to show the feedback target. |
Actions
| Action | Description |
|---|---|
copy | Copies source text to clipboard |
Accessibility
- Add
aria-live="polite"to the feedback element (already shown in the example) so screen readers announce the confirmation. - Uses the
navigator.clipboardAPI, which requires a secure context (HTTPS or localhost) and may prompt for permission in some browsers.
Dismiss
Click a button to remove or hide the controller element — alerts, flash notices, banners.
Usage
Copy dismiss_controller.js to app/javascript/controllers/ and register it:
// app/javascript/controllers/index.js
import DismissController from "./dismiss_controller";
application.register("dismiss", DismissController);
HTML
<!-- Removes element from the DOM -->
<div data-controller="dismiss" role="alert">
<p>Your changes have been saved.</p>
<button
type="button"
data-action="click->dismiss#dismiss"
aria-label="Dismiss"
>
×
</button>
</div>
<!-- Hides element with the hidden attribute (stays in DOM) -->
<div data-controller="dismiss" role="alert">
<p>Your changes have been saved.</p>
<button type="button" data-action="click->dismiss#hide" aria-label="Dismiss">
×
</button>
</div>
API
Actions
| Action | Description |
|---|---|
dismiss | Removes this.element from the DOM via .remove() |
hide | Sets this.element.hidden = true (preserves in the DOM) |
No targets. No values.
Accessibility
- Place
role="alert"on the container so screen readers announce it on page load. - Use
aria-label="Dismiss"on the button if it has no visible text label. dismissremoves the element entirely — the screen reader announcement is gone.hidesetshidden, which removes it from the accessibility tree too, but the element can be programmatically un-hidden later.
Password Reveal
Toggle a password input between password (hidden) and text (visible) types, updating show/hide labels in sync.
Usage
Copy password_reveal_controller.js to app/javascript/controllers/ and register it:
// app/javascript/controllers/index.js
import PasswordRevealController from "./password_reveal_controller";
application.register("password-reveal", PasswordRevealController);
HTML
<div data-controller="password-reveal">
<label for="password">Password</label>
<input
id="password"
type="password"
name="password"
autocomplete="current-password"
data-password-reveal-target="input"
/>
<button
type="button"
data-action="click->password-reveal#toggle"
aria-label="Toggle password visibility"
>
<span data-password-reveal-target="showLabel">Show</span>
<span data-password-reveal-target="hideLabel" hidden>Hide</span>
</button>
</div>
<!-- Without labels — button text changes via your own JS or CSS -->
<div data-controller="password-reveal">
<label for="password">Password</label>
<input
id="password"
type="password"
name="password"
autocomplete="current-password"
data-password-reveal-target="input"
/>
<button type="button" data-action="click->password-reveal#toggle">
Show / Hide
</button>
</div>
API
Targets
| Target | Required | Description |
|---|---|---|
input | Yes | The password <input> whose type is toggled. |
showLabel | No | Shown when password is hidden; hidden when visible. |
hideLabel | No | Shown when password is visible; hidden when hidden. |
Actions
| Action | Description |
|---|---|
toggle | Switches input type and flips show/hide label visibility |
Accessibility
- Use
type="button"to prevent accidental form submission. aria-label="Toggle password visibility"on the button describes the action to screen readers.- Pair with
autocomplete="current-password"(ornew-password) so password managers work correctly regardless of the current input type.
Password Rules
Show real-time feedback on whether a password satisfies a set of configurable rules. Each rule is a plain HTML element; the controller sets data-valid="true" or data-valid="false" on it as the user types. Your CSS handles the visual treatment.
Usage
Copy password_rules_controller.js to app/javascript/controllers/ and register it:
// app/javascript/controllers/index.js
import PasswordRulesController from "./password_rules_controller";
application.register("password-rules", PasswordRulesController);
HTML
<div data-controller="password-rules">
<label for="password">New password</label>
<input
id="password"
type="password"
name="password"
autocomplete="new-password"
data-password-rules-target="input"
data-action="input->password-rules#check"
/>
<ul aria-label="Password requirements" aria-live="polite">
<li data-password-rules-target="rule" data-min="8">
At least 8 characters
</li>
<li data-password-rules-target="rule" data-pattern="[A-Z]">
One uppercase letter
</li>
<li data-password-rules-target="rule" data-pattern="[a-z]">
One lowercase letter
</li>
<li data-password-rules-target="rule" data-pattern="[0-9]">One number</li>
<li data-password-rules-target="rule" data-pattern="[^A-Za-z0-9]">
One special character
</li>
</ul>
</div>
Styling with CSS
The controller writes data-valid="true" or data-valid="false" to each rule element. Style them however you like:
[data-valid="true"] {
color: green;
}
[data-valid="false"] {
color: red;
}
Or with icons via CSS content:
[data-password-rules-target="rule"]::before {
content: "○ ";
}
[data-password-rules-target="rule"][data-valid="true"]::before {
content: "✓ ";
color: green;
}
[data-password-rules-target="rule"][data-valid="false"]::before {
content: "✗ ";
color: red;
}
API
Targets
| Target | Required | Description |
|---|---|---|
input | Yes | The password <input> to validate against. |
rule | Yes (1+) | Each rule element. Configure with data-min or data-pattern. |
Rule configuration attributes
| Attribute | Description |
|---|---|
data-min | Minimum character count (integer). e.g. data-min="8". |
data-pattern | Regular expression string tested against the full value. e.g. data-pattern="[A-Z]". |
Actions
| Action | Description |
|---|---|
check | Evaluates all rules and updates their data-valid attributes |
Accessibility
- Place
aria-live="polite"on the rules container (e.g. the<ul>) so changes are announced to screen readers as the user types. - The rule text itself (e.g. “At least 8 characters”) is the accessible label — keep it descriptive enough to stand alone.
- Consider pairing with
password-revealso users can read what they’re typing when troubleshooting failed rules.
Security note
Client-side validation is UX-only. Always enforce password rules server-side as well.
Slug
Auto-populate a URL slug field from a title or name field as the user types.
Usage
Copy slug_controller.js to app/javascript/controllers/ and register it:
// app/javascript/controllers/index.js
import SlugController from "./slug_controller";
application.register("slug", SlugController);
HTML
<div data-controller="slug">
<div>
<label for="post_title">Title</label>
<input
id="post_title"
type="text"
name="post[title]"
data-slug-target="source"
data-action="input->slug#generate"
/>
</div>
<div>
<label for="post_slug">Slug</label>
<input
id="post_slug"
type="text"
name="post[slug]"
data-slug-target="output"
data-action="input->slug#lock"
/>
</div>
</div>
Once the user manually edits the output field, auto-generation stops. If the output already has a value when the controller connects (e.g. on an edit form), it starts locked. The locked state is stored in a data-slug-locked-value attribute on the controller element, so it survives controller reconnects (e.g. Turbo morphing).
Note: Only Latin-based characters are supported. Non-Latin scripts (CJK, Arabic, Hebrew, emoji, etc.) are stripped entirely and produce an empty slug. If your titles may contain non-Latin text, apply a server-side transliteration step before or after the slug is submitted.
API
Targets
| Target | Required | Description |
|---|---|---|
source | Yes | The field whose value is transformed into a slug. |
output | Yes | The field that receives the generated slug. |
Values
| Value | Type | Default | Description |
|---|---|---|---|
locked | Boolean | false | When true, auto-generation is disabled. Stored as data-slug-locked-value on the controller element; survives controller reconnects. Set by the controller on connect (if the output is pre-filled) or when the user edits the output field. |
Actions
| Action | Description |
|---|---|
generate | Transforms the source field value to a slug and writes it to the output field. No-op if the output has been manually edited. Wire to input on the source field. |
lock | Stops further auto-generation. Wire to input on the output field. |
Accessibility
The slug field is a standard text input — no additional ARIA attributes are required. Ensure both fields have visible <label> elements.
Tabs
Show one panel at a time from a set of tab buttons, with full ARIA tablist support and arrow-key navigation.
Usage
Copy tabs_controller.js to app/javascript/controllers/ and register it:
// app/javascript/controllers/index.js
import TabsController from "./tabs_controller";
application.register("tabs", TabsController);
HTML
<div data-controller="tabs">
<div role="tablist" aria-label="Example tabs">
<button
type="button"
data-tabs-target="tab"
data-action="click->tabs#select keydown->tabs#keydown"
>
Tab One
</button>
<button
type="button"
data-tabs-target="tab"
data-action="click->tabs#select keydown->tabs#keydown"
>
Tab Two
</button>
<button
type="button"
data-tabs-target="tab"
data-action="click->tabs#select keydown->tabs#keydown"
>
Tab Three
</button>
</div>
<div data-tabs-target="panel">
<p>Content for tab one.</p>
</div>
<div data-tabs-target="panel">
<p>Content for tab two.</p>
</div>
<div data-tabs-target="panel">
<p>Content for tab three.</p>
</div>
</div>
Tabs and panels are paired by position — the first tab controls the first panel, and so on.
To start on a tab other than the first, set data-tabs-index-value:
<div data-controller="tabs" data-tabs-index-value="1">...</div>
API
Targets
| Target | Required | Description |
|---|---|---|
tab | Yes | A tab button. Paired by index with the corresponding panel. |
panel | Yes | A tab panel. Paired by index with the corresponding tab. Hidden when not active. |
Values
| Value | Type | Default | Description |
|---|---|---|---|
index | Number | 0 | Index of the currently selected tab. Set in HTML to choose a starting tab. |
Actions
| Action | Description |
|---|---|
select | Activates the clicked tab and shows its panel. Wire to click on tab targets. |
keydown | Arrow-key navigation (Left / Right / Home / End). Wire to keydown on tab targets. |
Accessibility
The controller sets up the full ARIA tabs pattern:
role="tab"is added to each tab target;role="tabpanel"to each panel target.aria-selected,tabindex,aria-controls, andaria-labelledbyare managed automatically.- Inactive panels are hidden via the
hiddenattribute. - Panels receive
tabindex="0"so keyboard users can Tab into the active panel. - If a tab or panel has no
id, one is generated automatically to supportaria-controls/aria-labelledbylinking.
Add role="tablist" and an aria-label (or aria-labelledby) to the wrapper element that contains the tab buttons.
Keyboard behaviour (while a tab button has focus):
| Key | Action |
|---|---|
ArrowRight | Move to and activate the next tab |
ArrowLeft | Move to and activate the previous tab |
Home | Move to and activate the first tab |
End | Move to and activate the last tab |
Enter/Space | Activate the focused tab (native <button> behaviour — fires click, which triggers select) |
Tab | Move focus into the active panel |