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 name attribute 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 patternaria-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

TargetRequiredDescription
triggerYesA <button> that toggles its paired panel. Paired by index with each panel.
panelYesThe content panel. Paired by index with each trigger. Hidden when collapsed.

Values

ValueTypeDefaultDescription
exclusiveBooleanfalseWhen true, opening one panel closes all others.

Actions

ActionDescription
toggleOpens or closes the panel paired with the clicked trigger. Wire to click on triggers.
keydownArrow-key navigation between triggers. Wire to keydown on triggers.

Accessibility

The controller sets up the ARIA Accordion pattern:

  • aria-expanded is set on each trigger on connect (derived from whether the paired panel is hidden) and kept in sync as panels open and close.
  • aria-controls is set on each trigger pointing to its panel’s id. If a panel has no id, one is generated automatically.
  • aria-labelledby is set on each panel pointing to its trigger’s id. If a trigger has no id, one is generated automatically.
  • Panels are shown and hidden via the hidden attribute.

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):

KeyAction
Enter/SpaceToggle the focused panel (native <button> behaviour — fires click)
ArrowDownMove focus to the next trigger (wraps to first)
ArrowUpMove focus to the previous trigger (wraps to last)
HomeMove focus to the first trigger
EndMove 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

TargetRequiredDescription
inputYesThe <input> or <textarea> being counted.
countNoUpdated with the current character count.
remainingNoUpdated with max - count. Requires max value set.

Values

ValueTypeDefaultDescription
maxNumberMaximum character limit. Required for remaining target.

Actions

ActionDescription
updateRecalculates count and remaining values

Accessibility

  • The controller adds aria-live="polite" to count and remaining targets 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 maxlength to the input enforces the limit at the browser level; the max value 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

TargetRequiredDescription
checkboxYesEach <input type="checkbox"> that counts toward the minimum.
errorNoShown when the group is invalid; hidden when valid. Set hidden on this element in HTML.

Values

ValueTypeDefaultDescription
minNumber1Minimum number of checkboxes that must be checked.

Actions

ActionDescription
validateCounts 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" or data-valid="false" on the wrapping element after each validate call. 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

TargetRequiredDescription
sourceYesElement to copy from. Uses .value if present (input/textarea), otherwise .textContent.
feedbackNoElement shown briefly after a successful copy.

Values

ValueTypeDefaultDescription
successDurationNumber2000Milliseconds to show the feedback target.

Actions

ActionDescription
copyCopies 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.clipboard API, 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"
  >
    &times;
  </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">
    &times;
  </button>
</div>

API

Actions

ActionDescription
dismissRemoves this.element from the DOM via .remove()
hideSets 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.
  • dismiss removes the element entirely — the screen reader announcement is gone. hide sets hidden, 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

TargetRequiredDescription
inputYesThe password <input> whose type is toggled.
showLabelNoShown when password is hidden; hidden when visible.
hideLabelNoShown when password is visible; hidden when hidden.

Actions

ActionDescription
toggleSwitches 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" (or new-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

TargetRequiredDescription
inputYesThe password <input> to validate against.
ruleYes (1+)Each rule element. Configure with data-min or data-pattern.

Rule configuration attributes

AttributeDescription
data-minMinimum character count (integer). e.g. data-min="8".
data-patternRegular expression string tested against the full value. e.g. data-pattern="[A-Z]".

Actions

ActionDescription
checkEvaluates 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-reveal so 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

TargetRequiredDescription
sourceYesThe field whose value is transformed into a slug.
outputYesThe field that receives the generated slug.

Values

ValueTypeDefaultDescription
lockedBooleanfalseWhen 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

ActionDescription
generateTransforms 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.
lockStops 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

TargetRequiredDescription
tabYesA tab button. Paired by index with the corresponding panel.
panelYesA tab panel. Paired by index with the corresponding tab. Hidden when not active.

Values

ValueTypeDefaultDescription
indexNumber0Index of the currently selected tab. Set in HTML to choose a starting tab.

Actions

ActionDescription
selectActivates the clicked tab and shows its panel. Wire to click on tab targets.
keydownArrow-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, and aria-labelledby are managed automatically.
  • Inactive panels are hidden via the hidden attribute.
  • 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 support aria-controls / aria-labelledby linking.

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):

KeyAction
ArrowRightMove to and activate the next tab
ArrowLeftMove to and activate the previous tab
HomeMove to and activate the first tab
EndMove to and activate the last tab
Enter/SpaceActivate the focused tab (native <button> behaviour — fires click, which triggers select)
TabMove focus into the active panel