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"
aria-label="Text to copy"
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"
aria-label="API key"
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. - A readonly
sourceinput has no visible<label>in the example above; give it anaria-label(or a visually hidden<label>) so screen reader users know what the field contains. - Uses the
navigator.clipboardAPI, which requires a secure context (HTTPS or localhost) and may prompt for permission in some browsers.
Datetime Local
Display a server-rendered UTC timestamp in the viewer’s local time zone.
Usage
Copy datetime_local_controller.js to app/javascript/controllers/ and register it:
// app/javascript/controllers/index.js
import DatetimeLocalController from "./datetime_local_controller";
application.register("datetime-local", DatetimeLocalController);
HTML
<time data-controller="datetime-local" datetime="2026-06-19T15:30:00Z">
2026-06-19T15:30:00Z
</time>
Render the datetime attribute on the server as an ISO 8601 UTC timestamp
(e.g. created_at.utc.iso8601) and put the same value (or any
human-readable fallback) as the element’s text content for browsers without
JavaScript. On connect, the controller parses datetime and replaces the
text content with a version formatted in the viewer’s local time zone —
the datetime attribute itself is left untouched, so the element stays
machine-readable.
<!-- Date only -->
<time
data-controller="datetime-local"
data-datetime-local-time-style-value="none"
datetime="2026-06-19T15:30:00Z"
>
2026-06-19
</time>
<!-- Time only, with a longer style -->
<time
data-controller="datetime-local"
data-datetime-local-date-style-value="none"
data-datetime-local-time-style-value="long"
datetime="2026-06-19T15:30:00Z"
>
15:30:00 UTC
</time>
API
Values
| Value | Type | Default | Description |
|---|---|---|---|
dateStyle | String | medium | An Intl.DateTimeFormat dateStyle (full, long, medium, short), or none to omit the date. |
timeStyle | String | short | An Intl.DateTimeFormat timeStyle (full, long, medium, short), or none to omit the time. |
locale | String | — | A BCP 47 locale tag (e.g. en-GB). Defaults to the viewer’s browser locale when omitted. |
timeZone | String | — | An IANA time zone (e.g. America/New_York). Defaults to the viewer’s system time zone when omitted. |
Setting both dateStyle and timeStyle to none leaves the original text
content untouched, since there would be nothing left to format.
Source value
The controller reads the timestamp to format from the element’s datetime
attribute. If that attribute is absent, it falls back to the element’s text
content. The source value must be parseable by the JavaScript Date
constructor — an ISO 8601 string (with a Z or UTC offset) is recommended
so the timestamp is unambiguous regardless of the viewer’s time zone.
If the source value can’t be parsed, the element is left as-is.
Accessibility
- Use a
<time>element with thedatetimeattribute so assistive technology and browsers can still interpret the machine-readable value even though the visible text has been localized. - The reformatted text is plain text, not live — there’s no
aria-liveregion, since the value is set once on connect rather than updating continuously like a countdown. - Keep the no-JavaScript fallback text content meaningful (e.g. the same ISO string or a server-rendered date), since the controller may not run for some visitors.
Dependent Select
Filter a <select>’s options client-side based on the value of another select (Country → State, Category → Subcategory).
Usage
Copy dependent_select_controller.js to app/javascript/controllers/ and register it:
// app/javascript/controllers/index.js
import DependentSelectController from "./dependent_select_controller";
application.register("dependent-select", DependentSelectController);
HTML
<div data-controller="dependent-select">
<label for="country">Country</label>
<select
id="country"
data-dependent-select-target="group"
data-action="change->dependent-select#filter"
>
<option value="">Choose a country</option>
<option value="US">United States</option>
<option value="CA">Canada</option>
</select>
<label for="state">State / Province</label>
<select id="state" data-dependent-select-target="dependent">
<option value="">Choose a state</option>
<option value="CA-state" data-dependent-select-group="US">
California
</option>
<option value="NY" data-dependent-select-group="US">New York</option>
<option value="ON" data-dependent-select-group="CA">Ontario</option>
<option value="QC" data-dependent-select-group="CA">Quebec</option>
</select>
</div>
On a server-rendered edit form, mark the previously-saved options selected on
both selects — connect() re-applies the filter so the page loads with the
correct state options already visible.
API
Targets
| Target | Required | Description |
|---|---|---|
group | Yes | The controlling <select> (e.g. Country). |
dependent | Yes | The <select> whose options are filtered. Its options are tagged with data-dependent-select-group. |
Data attributes
| Attribute | Required | Description |
|---|---|---|
data-dependent-select-group | No | Set on a dependent <option> to the group value it belongs to. Options without this attribute (e.g. a placeholder) are always shown. |
Edge case: data-dependent-select-group="" (an explicit empty string, as opposed to omitting the attribute) matches when the group select has no value selected — it’s treated as a real group code, not a placeholder. Omit the attribute entirely for an option you want shown regardless of the group value.
Actions
| Action | Description |
|---|---|
filter | Hides and disables every dependent option whose group doesn’t match the current group value. Clears the dependent selection if it no longer matches. |
Accessibility
- Non-matching options are both
hiddenanddisabled, so they’re removed from the accessible options list and can’t be reached by typeahead or arrow-key selection in any browser. - An option without
data-dependent-select-group(typically a placeholder like “Choose a state”) is always visible, regardless of the selected group. - This controller handles one level of dependency (Country → State). For a longer chain (Country → State → City), use a separate instance per link — each
dependentselect can also act as thegroupfor the next one. - Clearing the dependent select’s value when its previous selection no longer matches relies on the native
<select>falling back to its first option (or a blank value if one exists) — make sure a placeholder option with an emptyvalueis present if you don’t want a stale option silently becoming selected.
Direct Upload Progress
Show a progress bar for each file as it uploads via Rails ActiveStorage direct uploads.
Usage
Copy direct_upload_progress_controller.js to app/javascript/controllers/ and register it:
// app/javascript/controllers/index.js
import DirectUploadProgressController from "./direct_upload_progress_controller";
application.register("direct-upload-progress", DirectUploadProgressController);
activestorage’s direct upload JS already dispatches direct-upload:* custom events on the file input — this controller just listens for them and renders the UI. No server-side changes are needed beyond the usual data-direct-upload-url setup.
HTML
<div data-controller="direct-upload-progress">
<label for="attachments">Attachments</label>
<input
id="attachments"
type="file"
multiple
name="post[attachments][]"
data-direct-upload-url="/rails/active_storage/direct_uploads"
data-action="
direct-upload:initialize->direct-upload-progress#add
direct-upload:progress->direct-upload-progress#progress
direct-upload:error->direct-upload-progress#error
direct-upload:end->direct-upload-progress#end
"
/>
<ul data-direct-upload-progress-target="list"></ul>
<p data-direct-upload-progress-target="status"></p>
</div>
Each selected file gets its own <li> containing the file name and a native <progress> element, added when ActiveStorage dispatches direct-upload:initialize and updated as direct-upload:progress events arrive.
API
Targets
| Target | Required | Description |
|---|---|---|
list | Yes | Container that receives one <li> per file being uploaded. |
status | No | Receives a short text message on upload start/success/failure. Gets aria-live="polite" auto-applied if not already set. |
Actions
| Action | Wire to | Description |
|---|---|---|
add | direct-upload:initialize | Adds a progress item for the file. |
progress | direct-upload:progress | Updates the item’s <progress> value (event.detail.progress is 0–100). |
error | direct-upload:error | Marks the item as errored and announces the failure message. |
end | direct-upload:end | Marks a non-errored item as done and sets its progress to 100. |
All four events are dispatched by ActiveStorage’s own direct upload JS on the file input element — wire them all via data-action on the input, as shown above.
Item state
Each <li> gets data-direct-upload-progress-state, one of uploading, done, or error. Use it to style the row (e.g. a red border on error). The filename <span> inside it carries data-direct-upload-progress-role="name" so you can target it in CSS.
Accessibility
- Each file’s
<progress>element has anaria-labelof the file name, so screen reader users hear which file a given progress bar belongs to. - The optional
statustarget getsaria-live="polite"applied automatically (unless already set) and is updated only on upload start, success, and failure — not on every percentage tick, to avoid spamming screen reader users.
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.
File Preview
Show a thumbnail (images) or filename and size (everything else) for files selected in a file input, before the form is submitted.
Usage
Copy file_preview_controller.js to app/javascript/controllers/ and register it:
// app/javascript/controllers/index.js
import FilePreviewController from "./file_preview_controller";
application.register("file-preview", FilePreviewController);
HTML
<div data-controller="file-preview">
<label for="attachments">Attachments</label>
<input
id="attachments"
type="file"
multiple
data-file-preview-target="input"
data-action="change->file-preview#preview"
/>
<button type="button" data-action="file-preview#clear">Clear</button>
<ul data-file-preview-target="list"></ul>
<p data-file-preview-target="empty">No files chosen.</p>
</div>
/* Style by the data attribute the controller sets — no CSS is assumed */
[data-file-preview-type="image"] img {
width: 3rem;
height: 3rem;
object-fit: cover;
}
API
Targets
| Target | Required | Description |
|---|---|---|
input | Yes | The <input type="file"> to preview. |
list | Yes | Container that receives one generated <li> per selected file. |
empty | No | Shown when no files are selected; hidden otherwise. |
Actions
| Action | Description |
|---|---|
preview | Re-renders the list from the input’s current files. Wire to the input’s change event. |
clear | Resets the input (clearing the selection) and re-renders the now-empty list. |
Generated markup
Each <li> carries data-file-preview-type="image" or "file" so you can target it in CSS. Image items include an <img> thumbnail (decorative — alt="", since the filename is shown separately); every item includes <span data-file-preview-role="name"> and <span data-file-preview-role="size">, separated by a literal ” · ” text node so the two don’t run together in default (unstyled) rendering.
Accessibility
- Thumbnails are decorative (
alt=""); the filename is already exposed as text, so a screen reader doesn’t need a redundant image description. - The
emptytarget’s visibility is driven by the actual file count, so it stays in sync with what’s selected — including afterclear. - This controller only previews the current selection; it does not replace the native
<input type="file">, so keyboard and screen reader support for picking files is unaffected.
Notes
- There’s no per-file remove button. Removing one file from a
multiplefile input requires rebuilding itsFileListvia theDataTransferAPI, which is real-browser-only behavior with no jsdom equivalent — out of scope for a controller this small. Useclearto reset the whole selection instead. - Object URLs created for image thumbnails are revoked on the next render and on disconnect, so previewing many large images doesn’t leak memory.
Form Confirm
Show a <dialog>-based confirmation prompt before a form submits or a link/button proceeds, replacing Rails 7’s removed data-confirm UJS behaviour.
Usage
Copy form_confirm_controller.js to app/javascript/controllers/ and register it:
// app/javascript/controllers/index.js
import FormConfirmController from "./form_confirm_controller";
application.register("form-confirm", FormConfirmController);
HTML
Confirming a destructive form submission
<div
data-controller="form-confirm"
data-form-confirm-message-value="Delete this post? This cannot be undone."
>
<form
action="/posts/1"
method="post"
data-action="submit->form-confirm#intercept"
>
<input type="hidden" name="_method" value="delete" />
<button type="submit">Delete</button>
</form>
<dialog data-form-confirm-target="dialog">
<p data-form-confirm-target="message"></p>
<button type="button" data-action="click->form-confirm#proceed">
Delete
</button>
<button type="button" data-action="click->form-confirm#cancel">
Cancel
</button>
</dialog>
</div>
Confirming a link or button click
Any clickable element works the same way — wire click instead of submit:
<div data-controller="form-confirm">
<a href="/cart/clear" data-action="click->form-confirm#intercept">
Clear cart
</a>
<dialog data-form-confirm-target="dialog">
<p>Clear your cart? Items will not be saved.</p>
<button type="button" data-action="click->form-confirm#proceed">
Clear
</button>
<button type="button" data-action="click->form-confirm#cancel">
Cancel
</button>
</dialog>
</div>
API
Targets
| Target | Required | Description |
|---|---|---|
dialog | Yes | The <dialog> element shown for confirmation. |
message | No | An element whose text is set from messageValue when the dialog opens. |
Values
| Value | Type | Default | Description |
|---|---|---|---|
message | String | "" | Text written into the message target when the dialog opens. |
Actions
| Action | Description |
|---|---|
intercept | Stops the original submit or click, records the source element, and opens the dialog. |
proceed | Closes the dialog and re-issues the original action (form.requestSubmit(submitter) or element.click()). |
cancel | Closes the dialog and discards the pending action. |
Accessibility
<dialog>showing viashowModal()traps focus and is announced by screen readers automatically; the controller falls back to toggling theopenattribute in environments withoutshowModal()support.- Give the dialog’s confirm and cancel buttons clear, distinct labels (e.g. “Delete” / “Cancel”) rather than generic “OK” / “Cancel” — screen reader users hear them out of context from the triggering element.
- Wire
data-action="cancel->form-confirm#cancel"on thedialogtarget if you want pressing Esc to also clear the pending action (native<dialog>fires acancelevent on Esc when shown viashowModal()). - This controller intercepts a single element’s
submit/clickevent. For Turbo’sdata-turbo-methodlinks, which bypass the DOM’s native form-submission flow, preferTurbo.setConfirmMethod()to swap in a dialog-based confirm globally instead. - If multiple triggers share one
dialogtarget, intercepting a second trigger while the dialog is already open keeps it open (and replaces the pending action) rather than re-opening it — callingshowModal()on an already-open dialog throws in real browsers. - For forms with more than one submit button sharing a
namebut differingvalue(e.g.<%= form.button "Save", name: :commit %>/<%= form.button "Save and add another", name: :commit %>), the controller remembers which button was clicked (event.submitter) and replays the resubmit through that same button, so the correctcommitvalue still reaches the server.
Match Validator
Require two fields to have the same value before the form can be submitted — password confirmation, email confirmation, and similar “type it again” patterns.
Usage
Copy match_validator_controller.js to app/javascript/controllers/ and register it:
// app/javascript/controllers/index.js
import MatchValidatorController from "./match_validator_controller";
application.register("match-validator", MatchValidatorController);
HTML
<form>
<div data-controller="match-validator">
<label for="password">Password</label>
<input
id="password"
type="password"
name="user[password]"
autocomplete="new-password"
data-match-validator-target="field"
data-action="input->match-validator#validate"
/>
<label for="password_confirmation">Confirm password</label>
<input
id="password_confirmation"
type="password"
name="user[password_confirmation]"
autocomplete="new-password"
data-match-validator-target="match"
data-action="input->match-validator#validate"
/>
<p data-match-validator-target="error" hidden>Passwords don't match.</p>
</div>
<button type="submit">Sign up</button>
</form>
<!-- Case-insensitive match (e.g. email confirmation) -->
<div
data-controller="match-validator"
data-match-validator-case-sensitive-value="false"
>
<input
type="email"
data-match-validator-target="field"
data-action="input->match-validator#validate"
/>
<input
type="email"
data-match-validator-target="match"
data-action="input->match-validator#validate"
/>
<p data-match-validator-target="error" hidden>Emails don't match.</p>
</div>
The controller automatically intercepts the nearest parent <form>’s submit event — no additional wiring on the form element is needed. Wire input->match-validator#validate on both fields for live feedback as the user types either one.
API
Targets
| Target | Required | Description |
|---|---|---|
field | Yes | The original field (e.g. the password input). |
match | Yes | The field that must match field (e.g. the confirmation input). |
error | No | Shown when the values don’t match; hidden when they match. Set hidden in the HTML. |
Values
| Value | Type | Default | Description |
|---|---|---|---|
case-sensitive | Boolean | true | Set to false to compare values ignoring letter case. |
Actions
| Action | Description |
|---|---|
validate | Compares field and match; shows the error target and blocks form submission if they differ. Wire to input on either field for live feedback. |
Accessibility
- The controller sets
aria-invalid="true"oraria-invalid="false"on thematchtarget after eachvalidatecall. - The controller sets
data-valid="true"ordata-valid="false"on the wrapping element after eachvalidatecall. Use CSS to style the fields or error message accordingly. - Place the error message after both fields in the DOM so screen readers encounter it in reading order, and consider an
aria-describedbyfrom thematchfield to the error target so it’s announced when the field receives focus.
Number Format
Live thousands-separator and currency formatting on a text input, with a clean unformatted numeric value submitted via a hidden field.
Usage
Copy number_format_controller.js to app/javascript/controllers/ and register it:
// app/javascript/controllers/index.js
import NumberFormatController from "./number_format_controller";
application.register("number-format", NumberFormatController);
HTML
<!-- Plain thousands separators -->
<div data-controller="number-format" data-number-format-locale-value="en-US">
<label for="quantity">Quantity</label>
<input
id="quantity"
name="order[quantity]"
inputmode="decimal"
data-number-format-target="input"
data-action="input->number-format#format blur->number-format#blur focus->number-format#focus"
value="1500"
/>
</div>
<!-- Currency display, clean value submitted via a hidden field -->
<div data-controller="number-format" data-number-format-locale-value="en-US">
<label for="price">Price</label>
<input
id="price"
type="text"
inputmode="decimal"
data-number-format-style-value="currency"
data-number-format-currency-value="USD"
data-number-format-target="input"
data-action="input->number-format#format blur->number-format#blur focus->number-format#focus"
value="1234.5"
/>
<input
type="hidden"
name="product[price]"
data-number-format-target="hidden"
/>
</div>
When a hidden target is present, give the name attribute to the hidden input instead of the visible one — the visible input is for display only, and the hidden input always holds the clean numeric string actually submitted.
API
Targets
| Target | Required | Description |
|---|---|---|
input | Yes | The visible text input the user types into. |
hidden | No | Receives the clean numeric string (no grouping, currency symbol, etc.) for submission. |
Values
| Value | Type | Default | Description |
|---|---|---|---|
locale | String | — | Passed to Intl.NumberFormat for the formatted (blurred) display. |
style | String | decimal | decimal or currency. |
currency | String | USD | ISO currency code. Used only when style is currency. |
minimumFractionDigits | Number | — | Passed through to Intl.NumberFormat. |
maximumFractionDigits | Number | — | Passed through to Intl.NumberFormat. |
Actions
| Action | Description |
|---|---|
format | Call on input. Groups digits with thousands separators live as the user types, and syncs the clean value to the hidden target. |
focus | Call on focus. Strips grouping and currency symbols back to a plain editable value so the cursor isn’t fighting formatting. |
blur | Call on blur. Applies the full Intl.NumberFormat display (currency symbol, locale grouping) and syncs the hidden target. |
connect() runs the same formatting as blur against the input’s initial value, so a server-rendered raw value (e.g. value="1234.5") displays formatted immediately on page load.
Accessibility
- The
localevalue affects only the formatted display (currency symbol placement, locale-specific grouping). Typed and submitted values always use a plain period as the decimal separator — round-tripping a locale’s own separators (e.g.1.234,56inde-DE) back into a raw number is ambiguous, so this controller deliberately doesn’t attempt it. If you need full locale-aware parsing, do it server-side. - Add
inputmode="decimal"on the visible input so mobile browsers show a numeric keyboard. Don’t settype="number"— browsers disallowsetSelectionRangeon numeric inputs, which the liveformataction relies on for cursor preservation. - The live
formataction preserves cursor position by digit count, not character offset, so inserting or removing a grouping separator doesn’t push the cursor to an unexpected spot. - If you don’t need a separate submitted value (e.g. a quantity field where commas are harmless to the backend), omit the
hiddentarget and thenameattribute can stay on the visibleinput.
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.
Relative Time
Display a server-rendered timestamp as a live-updating “5 minutes ago” style relative time.
Usage
Copy relative_time_controller.js to app/javascript/controllers/ and register it:
// app/javascript/controllers/index.js
import RelativeTimeController from "./relative_time_controller";
application.register("relative-time", RelativeTimeController);
HTML
<time data-controller="relative-time" datetime="2026-06-25T11:55:00Z">
2026-06-25T11:55:00Z
</time>
Render the datetime attribute on the server as an ISO 8601 UTC timestamp
(e.g. created_at.utc.iso8601) and put the same value (or any
human-readable fallback) as the element’s text content for browsers without
JavaScript. On connect, the controller replaces the text content with a
relative description ("5 minutes ago", "in 2 days", "yesterday") and
re-renders it on an interval so it stays current without a page reload —
the datetime attribute itself is left untouched, so the element stays
machine-readable.
<!-- Force "X days ago" phrasing instead of "yesterday" -->
<time
data-controller="relative-time"
data-relative-time-numeric-value="always"
datetime="2026-06-24T12:00:00Z"
>
2026-06-24T12:00:00Z
</time>
<!-- Re-render every 10 seconds for a live activity feed -->
<time
data-controller="relative-time"
data-relative-time-interval-value="10000"
datetime="2026-06-25T11:55:00Z"
>
2026-06-25T11:55:00Z
</time>
API
Values
| Value | Type | Default | Description |
|---|---|---|---|
locale | String | — | A BCP 47 locale tag (e.g. es). Defaults to the viewer’s browser locale when omitted. |
style | String | long | An Intl.RelativeTimeFormat style (long, short, narrow). |
numeric | String | auto | auto allows phrasing like "yesterday"/"now"; always forces numeric phrasing like "1 day ago". |
interval | Number | 60000 | Milliseconds between re-renders. Lower this for timestamps where second-level precision matters (e.g. a live feed); the 1-minute default suits typical created_at/updated_at display. |
Source value
The controller reads the timestamp to format from the element’s datetime
attribute. If that attribute is absent, it falls back to the element’s text
content. The source value must be parseable by the JavaScript Date
constructor — an ISO 8601 string (with a Z or UTC offset) is recommended
so the relative calculation is unambiguous regardless of the viewer’s time
zone.
If the source value can’t be parsed, the element is left as-is.
Accessibility
aria-live="polite"is applied to the element on connect, so assistive technology announces updated values without interrupting the user.- Use a
<time>element with thedatetimeattribute so assistive technology and browsers can still interpret the machine-readable value even though the visible text is relative and changes over time. - Keep the no-JavaScript fallback text content meaningful (e.g. the same ISO string or a server-rendered date), since the controller may not run for some visitors.
Row Select
Select table rows with checkboxes — select-all, shift-click range select, and a bulk-actions bar that appears once anything is selected.
Usage
Copy row_select_controller.js to app/javascript/controllers/ and register it:
// app/javascript/controllers/index.js
import RowSelectController from "./row_select_controller";
application.register("row-select", RowSelectController);
HTML
<div data-controller="row-select">
<div data-row-select-target="bar" hidden>
<span data-row-select-target="count"></span> selected
<button type="button">Delete selected</button>
</div>
<table>
<thead>
<tr>
<th>
<input
type="checkbox"
data-row-select-target="all"
data-action="change->row-select#toggleAll"
aria-label="Select all rows"
/>
</th>
<th scope="col">Name</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<input
type="checkbox"
data-row-select-target="row"
data-action="click->row-select#toggle"
aria-label="Select Alice"
/>
</td>
<td>Alice</td>
</tr>
<tr>
<td>
<input
type="checkbox"
data-row-select-target="row"
data-action="click->row-select#toggle"
aria-label="Select Bob"
/>
</td>
<td>Bob</td>
</tr>
</tbody>
</table>
</div>
The data-controller attribute must be on an ancestor of every target — the
bulk-actions bar doesn’t need to be inside the <table>, but it does need to
share a common container with it.
API
Targets
| Target | Required | Description |
|---|---|---|
all | No | The “select all” checkbox. Reflects checked/indeterminate state as rows are selected. |
row | Yes | A per-row checkbox. |
bar | No | A bulk-actions container. Its hidden attribute is toggled — shown once any row is selected. |
count | No | Receives the number of currently selected rows as its text content. |
Actions
| Action | Description |
|---|---|
toggleAll | Checks or unchecks every row target to match the all checkbox. |
toggle | Updates selection state for a single row. Shift-clicking a row selects (or deselects) every row between it and the last-clicked row. |
Accessibility
- The
allandrowcheckboxes need their own accessible names (aria-labelor a wrapping<label>) — the controller doesn’t add them, since the right label text is specific to your data (e.g. “Select Alice”). countgetsaria-live="polite"added automatically on connect, so screen reader users hear the running total as selections change.- The
allcheckbox’s nativeindeterminatestate is set whenever some but not all rows are selected — this is a real DOM property, not an ARIA attribute, and is announced by screen readers without extra markup. - Selected rows get a
data-row-select-selectedattribute (no value styling is assumed) so you can hook up a visual indicator via CSS. - Range selection is triggered by
shiftKeyon a native checkboxclickevent — no extra keyboard handling is needed since checkboxes already support Tab/Space natively.
Search Filter
Filter a list of items client-side as the user types, with an optional “no results” message.
Usage
Copy search_filter_controller.js to app/javascript/controllers/ and register it:
// app/javascript/controllers/index.js
import SearchFilterController from "./search_filter_controller";
application.register("search-filter", SearchFilterController);
HTML
<div data-controller="search-filter">
<label for="fruit-search">Filter fruits</label>
<input
id="fruit-search"
type="search"
data-search-filter-target="input"
data-action="input->search-filter#filter"
/>
<ul>
<li data-search-filter-target="item">Apple</li>
<li data-search-filter-target="item">Banana</li>
<li data-search-filter-target="item">Cherry</li>
</ul>
<p data-search-filter-target="empty" hidden>No fruits match your search.</p>
</div>
<!-- Matching against something other than the visible text -->
<li
data-search-filter-target="item"
data-search-filter-term="invoice 1042 acme corp"
>
#1042 — Acme Corp
</li>
API
Targets
| Target | Required | Description |
|---|---|---|
input | Yes | The search <input>. |
item | Yes | One per filterable row. Hidden (hidden attribute) when it doesn’t match the query. |
empty | No | Shown when no items match; hidden otherwise. |
Data attributes
| Attribute | Required | Description |
|---|---|---|
data-search-filter-term | No | Overrides what an item is matched against. Defaults to the item’s textContent. |
Actions
| Action | Description |
|---|---|
filter | Re-evaluates every item against the current query. |
Accessibility
- The controller adds
aria-live="polite"to theemptytarget on connect (if not already set), so screen readers announce when a search returns no results. - Matching is case-insensitive and ignores leading/trailing whitespace; it does not match across word boundaries (substring match only) or fuzzy-match typos.
- Use
<input type="search">for the built-in clear button and consistent mobile keyboard behavior. - Hidden items are removed from the accessibility tree and tab order via the native
hiddenattribute, not just visually hidden with CSS.
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.
Table Sort
Click a column header to sort a table’s rows by that column, with automatic string/number/date type detection.
Usage
Copy table_sort_controller.js to app/javascript/controllers/ and register it:
// app/javascript/controllers/index.js
import TableSortController from "./table_sort_controller";
application.register("table-sort", TableSortController);
HTML
<table data-controller="table-sort">
<thead>
<tr>
<th scope="col">
<button
type="button"
data-table-sort-target="header"
data-action="click->table-sort#sort"
>
Name
</button>
</th>
<th scope="col">
<button
type="button"
data-table-sort-target="header"
data-action="click->table-sort#sort"
>
Age
</button>
</th>
</tr>
</thead>
<tbody>
<tr data-table-sort-target="row">
<td>Charlie</td>
<td>40</td>
</tr>
<tr data-table-sort-target="row">
<td>Alice</td>
<td>30</td>
</tr>
<tr data-table-sort-target="row">
<td>Bob</td>
<td>25</td>
</tr>
</tbody>
</table>
Each sortable column needs a <button> inside its <th> (for native keyboard
support) wired to the header target, and every sortable <tbody> row needs
the row target. The column to sort by is determined by the clicked header’s
position in its <tr> — non-sortable columns (e.g. an actions column with no
header target) are fine to mix in.
API
Targets
| Target | Required | Description |
|---|---|---|
header | Yes | A <button> inside a sortable column’s <th>. Click toggles that column’s sort. |
row | Yes | A <tbody> row participating in sorting. All row targets must share the same <tbody> parent. |
Data attributes
| Attribute | Required | Description |
|---|---|---|
data-table-sort-type | No | Set on a header button to force "string", "number", or "date" comparison instead of auto-detecting from the first row’s cell text. Any other value is ignored and falls back to auto-detection. |
data-table-sort-value | No | Set on a <td> to provide the comparison value instead of the cell’s displayed text — useful when the display text isn’t directly sortable (e.g. a formatted date, or a month name backed by a numeric key). |
Type auto-detection checks the first row’s cell for that column in this order:
number → date → string. Override with data-table-sort-type for ambiguous
columns (e.g. a 4-digit ID that should never be read as a year).
Actions
| Action | Description |
|---|---|
sort | Sorts row targets by the clicked header’s column. Clicking the same header again reverses direction; clicking a different header starts ascending. |
Accessibility
- Each sortable
<th>getsaria-sort("none","ascending", or"descending"), kept in sync as columns are sorted — screen readers announce the current sort state on the column header. - Wrapping the clickable label in a real
<button>means Tab/Enter/Space work without any extra keyboard handling in the controller. scope="col"on each<th>(shown in the example above) is standard table markup, not specific to this controller, but is required for the header/cell association screen readers rely on.- This controller assumes a single
<tbody>; for multiple tbodies, use a separate instance per<tbody>with its own<thead>.
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 |
Unsaved Changes
Warns the user before they navigate away from a form with unsaved edits.
Usage
Copy unsaved_changes_controller.js to app/javascript/controllers/ and register it:
// app/javascript/controllers/index.js
import UnsavedChangesController from "./unsaved_changes_controller";
application.register("unsaved-changes", UnsavedChangesController);
HTML
<form
data-controller="unsaved-changes"
data-unsaved-changes-message-value="Discard your edits?"
>
<label for="post_title">Title</label>
<input id="post_title" type="text" name="post[title]" />
<button type="submit">Save</button>
</form>
Put data-controller="unsaved-changes" directly on the <form>. The controller listens for input/change anywhere inside it to mark the form dirty, and for the form’s own submit event to mark it clean again — no per-field data-action wiring required.
When the form is dirty, leaving the page is guarded two ways:
- Full page unload (closing the tab, typing a new URL, a non-Turbo link) — the browser’s native
beforeunloadconfirmation dialog appears. Browsers ignore custombeforeunloadtext for security reasons, somessageValuehas no effect here. - Turbo Drive visit (
turbo:before-visit, e.g. clicking a Turbo-managed link) — awindow.confirm()dialog appears usingmessageValue(or a generic default), and the visit is cancelled if the user declines.
Note: The dirty flag clears as soon as
submitfires and isn’t cancelled, not when the server confirms success. If a submission fails validation and re-renders the form, the page is no longer guarded even though the edits weren’t actually saved. This matches the controller’s minimal scope — pair it with your framework’s own validation-error handling if that gap matters for a given form.
Note:
turbo:before-visitonly fires for navigations Turbo Drive manages. If your app uses Turbo Streams to swap content without a full visit, call themarkCleanaction manually after a successful save (e.g. from aturbo:submit-endlistener) if you want the dirty flag cleared without a realsubmitevent.
Note: Combining this controller with form-confirm on the same
<form>works as expected — if form-confirm’s confirmation dialog cancels the submit (the user clicks Cancel), this controller does not mark the form clean, so the unsaved-changes guard stays active.
Note: If a page has multiple
unsaved-changes-controlled forms, declining one Turbo Drive visit confirmation prevents a second dirty form from also prompting for the same already-cancelled visit. Each separate navigation attempt is still guarded independently per dirty form.
API
Values
| Value | Type | Default | Description |
|---|---|---|---|
message | String | "You have unsaved changes. Leave this page?" | Confirmation text shown on a Turbo Drive visit. Has no effect on the native beforeunload dialog. |
Actions
| Action | Description |
|---|---|
markDirty | Marks the form as having unsaved changes. Called automatically on input/change; exposed for manual use (e.g. a rich-text editor that fires a custom change event instead). |
markClean | Marks the form as saved/unguarded. Called automatically on submit; exposed for manual use (e.g. a “discard changes” button, or after a successful Turbo Stream save). |
Accessibility
This controller has no visual UI of its own — it relies on the browser’s native beforeunload dialog and window.confirm(), both of which are handled accessibly by the browser/OS. No additional ARIA is required; ensure the form’s own fields have visible <label> elements as usual.