Password Show/Hide Toggle (eye icon or checkbox) | LlamaPress Cookbook
LlamaPress
Cookbook
Forms Stable

Password Show/Hide Toggle (eye icon or checkbox)

Two ways to let users reveal a password before submitting — a clickable eye icon inside the field, or an explicit "Show password" checkbox below it — sharing one tiny Stimulus controller.

Proven on llamapress.ai sign-in view · stimulus_js
Live demo — try it Open full screen

Password Show/Hide Toggle (eye icon or checkbox)

⚠️ Cookbook example — not live code. (KEEP THIS CALLOUT.) Every code block below is an example snippet, not part of the llamapress.ai codebase, and not running on this server. This is a reference recipe for a Leo instance (an AI coding agent) to implement in its own app — read it to understand the pattern, then recreate it there.

Lets a user reveal a typed password in plain text (handy for catching typos on sign-in and sign-up), then hide it back to dots. Two interchangeable variants, one shared Stimulus controller that just flips the input's type between "password" and "text":

  • Variant A — eye icon: a small eye button inside the field. Compact, the modern convention, but needs an icon set and is easy to miss.
  • Variant B — "Show password" checkbox: an explicit labeled checkbox below the field. More discoverable, a bigger touch target, needs no icon library at all — often the better default for non-technical or older audiences.

When to use which: eye icon when space is tight and your users live in modern apps; checkbox when discoverability matters more than polish (internal tools, audiences that won't recognize the eye glyph), or when Font Awesome isn't available. When not to use either: high-security fields where revealing the value on a shared/over-the-shoulder screen is a real risk.


The 80/20 in one breath

  1. Put data-controller="password-toggle" on a wrapper around the password input, and data-password-toggle-target="input" on the <input type="password">.
  2. Variant A: make the wrapper position: relative and drop an absolutely-positioned button on the right edge holding two icons — open eye (shown while hidden) and slashed eye (shown while visible). Click → password-toggle#toggle.
  3. Variant B: put a labeled checkbox under the field with data-password-toggle-target="checkbox" and data-action="password-toggle#toggleFromCheckbox".
  4. The controller flips the input type and (for Variant A) swaps which icon is visible.

That's the whole feature — no model, no server controller, no round-trip. Pure client-side.


Layer 1 — The View (Variant A: eye icon)

<%# app/views/landing/index.html.erb  (or your sign-in / devise form) %>
<%# The wrapper is position:relative so the eye button can be absolutely placed inside. %>
<div class="relative" data-controller="password-toggle">
  <%= password_field_tag :password, nil,
        class: "w-full rounded-lg border border-gray-300 px-3 py-2 pr-10 " \
               "focus:border-indigo-500 focus:ring-indigo-500",
        data: { password_toggle_target: "input" },
        autocomplete: "current-password" %>

  <%# The pr-10 above reserves room so typed text never runs under the icon. %>
  <button type="button"
          class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
          data-action="password-toggle#toggle"
          data-password-toggle-target="button"
          aria-label="Show password">
    <%# Open eye — visible while the password is HIDDEN (click to reveal). %>
    <i class="fa-solid fa-eye" data-password-toggle-target="eyeOpen"></i>
    <%# Slashed eye — visible while the password is SHOWN (click to hide). Hidden by default. %>
    <i class="fa-solid fa-eye-slash hidden" data-password-toggle-target="eyeSlash"></i>
  </button>
</div>

Layer 1b — The View (Variant B: "Show password" checkbox)

<%# app/views/landing/index.html.erb — same controller, checkbox instead of the eye. %>
<div data-controller="password-toggle">
  <%= password_field_tag :password, nil,
        class: "w-full rounded-lg border border-gray-300 px-3 py-2 " \
               "focus:border-indigo-500 focus:ring-indigo-500",
        data: { password_toggle_target: "input" },
        autocomplete: "current-password" %>

  <%# Wrapping the checkbox in the <label> makes the TEXT clickable too (big target). %>
  <label class="mt-2 inline-flex items-center gap-2 text-sm text-gray-600 select-none cursor-pointer">
    <%# Plain HTML input, deliberately NO name= — see Gotchas (don't submit it as a param). %>
    <input type="checkbox"
           class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
           data-password-toggle-target="checkbox"
           data-action="password-toggle#toggleFromCheckbox">
    Show password
  </label>
</div>

Layer 2 — Stimulus / JavaScript (shared by both variants)

// app/javascript/controllers/password_toggle_controller.js
import { Controller } from "@hotwired/stimulus"

// Toggles a password field between hidden (dots) and visible (plain text).
// Works with an eye-icon button (toggle) OR a "Show password" checkbox
// (toggleFromCheckbox) — icon/button targets are optional, so one controller
// serves both variants. Purely client-side, no form submit involved.
export default class extends Controller {
  static targets = ["input", "button", "eyeOpen", "eyeSlash", "checkbox"]

  // Variant A — eye button clicked: flip whatever state we're in.
  toggle() {
    this.setVisible(this.inputTarget.type === "password")
  }

  // Variant B — checkbox changed: mirror the checkbox state.
  toggleFromCheckbox() {
    this.setVisible(this.checkboxTarget.checked)
  }

  setVisible(visible) {
    this.inputTarget.type = visible ? "text" : "password"

    // Icon swap + aria label only exist in Variant A — guard with has*Target.
    if (this.hasEyeOpenTarget)  this.eyeOpenTarget.classList.toggle("hidden", visible)
    if (this.hasEyeSlashTarget) this.eyeSlashTarget.classList.toggle("hidden", !visible)
    if (this.hasButtonTarget) {
      this.buttonTarget.setAttribute("aria-label", visible ? "Hide password" : "Show password")
    }
  }
}

Gotchas (the hard-won stuff)

  • Font Awesome must actually be loaded (Variant A only). These snippets use fa-eye / fa-eye-slash. If your app doesn't pull in FA, the icons render as empty boxes or nothing at all. Either add the FA stylesheet/kit, swap the <i> tags for inline SVGs, or just use Variant B — the checkbox needs no icons at all (this is the easiest fix).
  • We tried an inline closed-eye SVG and reverted it. On the original sign-in page we briefly replaced the slashed eye with a hand-drawn SVG, then switched back to fa-eye-slash — it was cleaner and consistent with the rest of the app's icon library. If FA is already in your app, prefer it; only reach for SVG when you have no icon set.
  • Give the checkbox NO name attribute (Variant B). A named checkbox inside the <form> submits as a param (show_password=1) and can trip strong-params or confuse logs. A nameless input is never submitted — that's why the snippet uses a plain <input type="checkbox"> instead of check_box_tag (which forces a name).
  • Wrap the checkbox in its <label> so clicking the words "Show password" toggles it too — the text is the target users actually aim for.
  • Reserve space with padding, not margin (Variant A). Put pr-10 (right padding) on the input so long passwords don't slide under the icon. The icon sits on top of the field; without the padding the last characters get visually clipped.
  • Keep the eye a <button type="button">. Inside a <form>, a bare <button> defaults to type="submit" — clicking the eye would submit the form. Always set type="button".
  • Accessibility: Variant A's button gets an aria-label kept in sync on toggle ("Show password" ↔ "Hide password"). Variant B is accessible by construction — it's a real labeled checkbox.
  • Don't persist the revealed state. Leave the field hidden on load every time; showing by default defeats the point and leaks the value on a shared screen.

Files this pattern touches

app/javascript/controllers/password_toggle_controller.js   # the shared toggle logic
app/views/landing/index.html.erb                           # the field + eye button OR checkbox markup

How to adapt to your schema

  1. Any form works — this isn't tied to Devise or a particular field name. Reuse the same wrapper + controller on sign-up, password-reset, or a settings "new password" field. For two fields (password + confirm), give each its own wrapper/controller instance — or with the checkbox variant, one checkbox can rule both by making both inputs input targets and flipping this.inputTargets in a loop.
  2. Pick the variant by audience: consumer-polish → eye icon; internal tool / broad audience / no icon library → checkbox. Both use the identical controller, so you can swap later by only touching the view.
  3. No Font Awesome? Use Variant B, or replace the two <i class="fa-..."> tags with inline SVGs carrying the same data-password-toggle-target attributes — the controller doesn't care what the icons are, only which one is hidden.
  4. Styling is all Tailwind here; translate the utility classes to your CSS if you're not on Tailwind. The only structural requirements are: (A) relative wrapper, absolutely positioned button, right-side padding on the input; (B) a labeled checkbox anywhere inside the data-controller wrapper.

Related