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"
    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

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.
  • A readonly source input has no visible <label> in the example above; give it an aria-label (or a visually hidden <label>) so screen reader users know what the field contains.
  • Uses the navigator.clipboard API, 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

ValueTypeDefaultDescription
dateStyleStringmediumAn Intl.DateTimeFormat dateStyle (full, long, medium, short), or none to omit the date.
timeStyleStringshortAn Intl.DateTimeFormat timeStyle (full, long, medium, short), or none to omit the time.
localeStringA BCP 47 locale tag (e.g. en-GB). Defaults to the viewer’s browser locale when omitted.
timeZoneStringAn 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 the datetime attribute 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-live region, 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

TargetRequiredDescription
groupYesThe controlling <select> (e.g. Country).
dependentYesThe <select> whose options are filtered. Its options are tagged with data-dependent-select-group.

Data attributes

AttributeRequiredDescription
data-dependent-select-groupNoSet 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

ActionDescription
filterHides 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 hidden and disabled, 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 dependent select can also act as the group for 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 empty value is 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

TargetRequiredDescription
listYesContainer that receives one <li> per file being uploaded.
statusNoReceives a short text message on upload start/success/failure. Gets aria-live="polite" auto-applied if not already set.

Actions

ActionWire toDescription
adddirect-upload:initializeAdds a progress item for the file.
progressdirect-upload:progressUpdates the item’s <progress> value (event.detail.progress is 0–100).
errordirect-upload:errorMarks the item as errored and announces the failure message.
enddirect-upload:endMarks 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 an aria-label of the file name, so screen reader users hear which file a given progress bar belongs to.
  • The optional status target gets aria-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"
  >
    &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.

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

TargetRequiredDescription
inputYesThe <input type="file"> to preview.
listYesContainer that receives one generated <li> per selected file.
emptyNoShown when no files are selected; hidden otherwise.

Actions

ActionDescription
previewRe-renders the list from the input’s current files. Wire to the input’s change event.
clearResets 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 empty target’s visibility is driven by the actual file count, so it stays in sync with what’s selected — including after clear.
  • 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 multiple file input requires rebuilding its FileList via the DataTransfer API, which is real-browser-only behavior with no jsdom equivalent — out of scope for a controller this small. Use clear to 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>

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

TargetRequiredDescription
dialogYesThe <dialog> element shown for confirmation.
messageNoAn element whose text is set from messageValue when the dialog opens.

Values

ValueTypeDefaultDescription
messageString""Text written into the message target when the dialog opens.

Actions

ActionDescription
interceptStops the original submit or click, records the source element, and opens the dialog.
proceedCloses the dialog and re-issues the original action (form.requestSubmit(submitter) or element.click()).
cancelCloses the dialog and discards the pending action.

Accessibility

  • <dialog> showing via showModal() traps focus and is announced by screen readers automatically; the controller falls back to toggling the open attribute in environments without showModal() 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 the dialog target if you want pressing Esc to also clear the pending action (native <dialog> fires a cancel event on Esc when shown via showModal()).
  • This controller intercepts a single element’s submit/click event. For Turbo’s data-turbo-method links, which bypass the DOM’s native form-submission flow, prefer Turbo.setConfirmMethod() to swap in a dialog-based confirm globally instead.
  • If multiple triggers share one dialog target, intercepting a second trigger while the dialog is already open keeps it open (and replaces the pending action) rather than re-opening it — calling showModal() on an already-open dialog throws in real browsers.
  • For forms with more than one submit button sharing a name but differing value (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 correct commit value 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

TargetRequiredDescription
fieldYesThe original field (e.g. the password input).
matchYesThe field that must match field (e.g. the confirmation input).
errorNoShown when the values don’t match; hidden when they match. Set hidden in the HTML.

Values

ValueTypeDefaultDescription
case-sensitiveBooleantrueSet to false to compare values ignoring letter case.

Actions

ActionDescription
validateCompares 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" or aria-invalid="false" on the match target after each validate call.
  • The controller sets data-valid="true" or data-valid="false" on the wrapping element after each validate call. 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-describedby from the match field 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

TargetRequiredDescription
inputYesThe visible text input the user types into.
hiddenNoReceives the clean numeric string (no grouping, currency symbol, etc.) for submission.

Values

ValueTypeDefaultDescription
localeStringPassed to Intl.NumberFormat for the formatted (blurred) display.
styleStringdecimaldecimal or currency.
currencyStringUSDISO currency code. Used only when style is currency.
minimumFractionDigitsNumberPassed through to Intl.NumberFormat.
maximumFractionDigitsNumberPassed through to Intl.NumberFormat.

Actions

ActionDescription
formatCall on input. Groups digits with thousands separators live as the user types, and syncs the clean value to the hidden target.
focusCall on focus. Strips grouping and currency symbols back to a plain editable value so the cursor isn’t fighting formatting.
blurCall 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 locale value 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,56 in de-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 set type="number" — browsers disallow setSelectionRange on numeric inputs, which the live format action relies on for cursor preservation.
  • The live format action 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 hidden target and the name attribute can stay on the visible input.

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.


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

ValueTypeDefaultDescription
localeStringA BCP 47 locale tag (e.g. es). Defaults to the viewer’s browser locale when omitted.
styleStringlongAn Intl.RelativeTimeFormat style (long, short, narrow).
numericStringautoauto allows phrasing like "yesterday"/"now"; always forces numeric phrasing like "1 day ago".
intervalNumber60000Milliseconds 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 the datetime attribute 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

TargetRequiredDescription
allNoThe “select all” checkbox. Reflects checked/indeterminate state as rows are selected.
rowYesA per-row checkbox.
barNoA bulk-actions container. Its hidden attribute is toggled — shown once any row is selected.
countNoReceives the number of currently selected rows as its text content.

Actions

ActionDescription
toggleAllChecks or unchecks every row target to match the all checkbox.
toggleUpdates selection state for a single row. Shift-clicking a row selects (or deselects) every row between it and the last-clicked row.

Accessibility

  • The all and row checkboxes need their own accessible names (aria-label or a wrapping <label>) — the controller doesn’t add them, since the right label text is specific to your data (e.g. “Select Alice”).
  • count gets aria-live="polite" added automatically on connect, so screen reader users hear the running total as selections change.
  • The all checkbox’s native indeterminate state 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-selected attribute (no value styling is assumed) so you can hook up a visual indicator via CSS.
  • Range selection is triggered by shiftKey on a native checkbox click event — 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

TargetRequiredDescription
inputYesThe search <input>.
itemYesOne per filterable row. Hidden (hidden attribute) when it doesn’t match the query.
emptyNoShown when no items match; hidden otherwise.

Data attributes

AttributeRequiredDescription
data-search-filter-termNoOverrides what an item is matched against. Defaults to the item’s textContent.

Actions

ActionDescription
filterRe-evaluates every item against the current query.

Accessibility

  • The controller adds aria-live="polite" to the empty target 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 hidden attribute, 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

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.


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

TargetRequiredDescription
headerYesA <button> inside a sortable column’s <th>. Click toggles that column’s sort.
rowYesA <tbody> row participating in sorting. All row targets must share the same <tbody> parent.

Data attributes

AttributeRequiredDescription
data-table-sort-typeNoSet 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-valueNoSet 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

ActionDescription
sortSorts 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> gets aria-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

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

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 beforeunload confirmation dialog appears. Browsers ignore custom beforeunload text for security reasons, so messageValue has no effect here.
  • Turbo Drive visit (turbo:before-visit, e.g. clicking a Turbo-managed link) — a window.confirm() dialog appears using messageValue (or a generic default), and the visit is cancelled if the user declines.

Note: The dirty flag clears as soon as submit fires 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-visit only fires for navigations Turbo Drive manages. If your app uses Turbo Streams to swap content without a full visit, call the markClean action manually after a successful save (e.g. from a turbo:submit-end listener) if you want the dirty flag cleared without a real submit event.

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

ValueTypeDefaultDescription
messageString"You have unsaved changes. Leave this page?"Confirmation text shown on a Turbo Drive visit. Has no effect on the native beforeunload dialog.

Actions

ActionDescription
markDirtyMarks 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).
markCleanMarks 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.