---
title: Password Show/Hide Toggle (eye icon or checkbox)
slug: password-show-hide-toggle
category: Forms
summary: 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.
tags: [stimulus, forms, auth, password, checkbox, ux-default, font-awesome]
status: stable
visibility: public
source_project: llamapress.ai sign-in
layers: [view, stimulus_js]
related:
  - title: UX Principles Guide for Building Web Software
    url: /wiki/ux-principles-for-web-software
    summary: The customer-facing UX principles this pattern serves — catching typos is friction elimination (Principle 2).
---

# 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.

<!-- ▼ LIVE DEMO block — this renders interactively on the llamapress.ai/cookbook page
     (raw HTML passes through the markdown renderer). Agents: IGNORE this block; recreate
     the pattern from the code layers below. Demo uses inline handlers because an
     innerHTML-injected <script> never executes. -->
<div class="not-prose my-6 rounded-2xl border border-gray-200 overflow-hidden shadow-sm">
  <div class="bg-gray-50 border-b border-gray-200 px-4 py-2 flex items-center gap-2">
    <span class="w-2 h-2 rounded-full bg-emerald-400"></span>
    <span class="text-[10px] font-bold uppercase tracking-widest text-gray-500">Live demo — try it</span>
  </div>
  <div class="p-5 grid grid-cols-1 sm:grid-cols-2 gap-6 bg-white">
    <div>
      <p class="text-xs font-semibold text-gray-500 mb-2">Variant A — eye icon</p>
      <div class="relative">
        <input type="password" value="hunter2" autocomplete="off" class="w-full rounded-lg border border-gray-300 px-3 py-2 pr-10 text-sm focus:border-indigo-500 focus:ring-indigo-500">
        <button type="button" aria-label="Show password" class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600" onclick="var i=this.parentElement.querySelector('input');var show=i.type==='password';i.type=show?'text':'password';this.querySelector('.demo-eye').classList.toggle('hidden',show);this.querySelector('.demo-eye-slash').classList.toggle('hidden',!show);this.setAttribute('aria-label',show?'Hide password':'Show password');">
          <i class="fa-solid fa-eye demo-eye"></i>
          <i class="fa-solid fa-eye-slash demo-eye-slash hidden"></i>
        </button>
      </div>
    </div>
    <div>
      <p class="text-xs font-semibold text-gray-500 mb-2">Variant B — "Show password" checkbox</p>
      <input type="password" value="hunter2" autocomplete="off" class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-indigo-500 focus:ring-indigo-500">
      <label class="mt-2 inline-flex items-center gap-2 text-sm text-gray-600 select-none cursor-pointer">
        <input type="checkbox" class="rounded border-gray-300" onchange="this.closest('div').querySelector('input[type=password],input[type=text]').type=this.checked?'text':'password'">
        Show password
      </label>
    </div>
  </div>
</div>

---

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

```erb
<%# 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)

```erb
<%# 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)

```javascript
// 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.
