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
- Put
data-controller="password-toggle"on a wrapper around the password input, anddata-password-toggle-target="input"on the<input type="password">. - Variant A: make the wrapper
position: relativeand 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. - Variant B: put a labeled checkbox under the field with
data-password-toggle-target="checkbox"anddata-action="password-toggle#toggleFromCheckbox". - The controller flips the input
typeand (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
nameattribute (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 ofcheck_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 totype="submit"— clicking the eye would submit the form. Always settype="button". - Accessibility: Variant A's button gets an
aria-labelkept 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
- 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
inputtargets and flippingthis.inputTargetsin a loop. - 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.
- No Font Awesome? Use Variant B, or replace the two
<i class="fa-...">tags with inline SVGs carrying the samedata-password-toggle-targetattributes — the controller doesn't care what the icons are, only which one ishidden. - 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-controllerwrapper.