---
title: Password Show/Hide Toggle (eye icon)
slug: password-show-hide-toggle
category: Forms
summary: A clickable eye icon inside a password field that toggles the value between hidden dots and plain text, so users can catch typos before submitting.
tags: [stimulus, forms, auth, password, ux-default, font-awesome]
status: stable
visibility: public
source_project: llamapress.ai sign-in
layers: [view, stimulus_js]
---

# Password Show/Hide Toggle (eye icon)

> ⚠️ **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.

Puts a small eye icon inside a password field. Click it to reveal the password in plain
text (handy for catching typos on sign-in and sign-up), click again to hide it back to
dots. It's a tiny Stimulus controller that flips the input's `type` between `"password"`
and `"text"` and swaps an open-eye icon for a slashed-eye icon.

> **When to use:** any password input where a typo means a failed login and a frustrated
> user — sign-in, sign-up, "change password" forms. **When not to:** high-security fields
> where showing the value on a shared/over-the-shoulder screen is a real risk (or just
> leave it default-hidden and let the user opt in).

---

## The 80/20 in one breath

1. Wrap the password input in a `position: relative` container so the icon can sit inside it.
2. Add `data-controller="password-toggle"` on the wrapper and a
   `data-password-toggle-target="input"` on the `<input type="password">`.
3. Drop an absolutely-positioned button on the right edge holding **two** icons — an open
   eye (shown when hidden) and a slashed eye (shown when visible).
4. On click, the controller toggles the input `type` and flips which icon is visible.

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

---

## Layer 1 — The View

```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 2 — Stimulus / JavaScript

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

// Toggles a password field between hidden (dots) and visible (plain text), and
// swaps the eye icon to match. Purely client-side — no form submit involved.
export default class extends Controller {
  static targets = ["input", "button", "eyeOpen", "eyeSlash"]

  toggle() {
    const showing = this.inputTarget.type === "text"

    // Flip the field type.
    this.inputTarget.type = showing ? "password" : "text"

    // Swap which icon is visible (open eye = hidden state, slashed eye = shown state).
    this.eyeOpenTarget.classList.toggle("hidden", !showing ? true : false)
    this.eyeSlashTarget.classList.toggle("hidden", !showing ? false : true)

    // Keep the accessible label in sync with what the click will now do.
    this.buttonTarget.setAttribute("aria-label", showing ? "Show password" : "Hide password")
  }
}
```

> The two `classList.toggle` lines read a little awkwardly on purpose — after the flip,
> `showing` still holds the *previous* state. When it was showing (`true`), we're now
> hiding, so we want the open eye back (`eyeOpen` un-hidden) and the slash hidden. If the
> ternaries bother you, compute `const nowHidden = this.inputTarget.type === "password"`
> *after* the flip and toggle off that instead.

---

## Gotchas (the hard-won stuff)

- **Font Awesome must actually be loaded.** These snippets use `fa-eye` / `fa-eye-slash`
  from Font Awesome. If your app doesn't pull in FA, the icons render as empty boxes or
  nothing at all. Either add the FA stylesheet/kit, or swap the two `<i>` tags for inline
  SVGs (see below) — don't assume FA is present just because the class name looks right.
- **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.
- **Reserve space with padding, not margin.** 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, so
  without the padding the last characters get visually clipped.
- **Keep it 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:** give the button an `aria-label` and update it on toggle ("Show
  password" ↔ "Hide password") so screen-reader users know what it does and its current
  state.
- **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 toggle logic
app/views/landing/index.html.erb                           # the field + eye button 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.
2. **No Font Awesome?** Replace the two `<i class="fa-...">` tags with inline SVGs (an eye
   and an eye-with-slash) carrying the same `data-password-toggle-target` attributes — the
   controller doesn't care what the icons are, only which one is `hidden`.
3. **Styling** is all Tailwind here; translate the utility classes to your CSS if you're
   not on Tailwind. The only structural requirements are: relative wrapper, absolutely
   positioned button, right-side padding on the input.
