{"slug":"password-show-hide-toggle","meta":{"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"]},"body":"# Password Show/Hide Toggle (eye icon)\n\n\u003e ⚠️ **Cookbook example — not live code.** (KEEP THIS CALLOUT.) Every code block below\n\u003e is an **example snippet**, **not part of the llamapress.ai codebase**, and **not\n\u003e running on this server**. This is a reference recipe for a **Leo instance (an AI coding\n\u003e agent) to implement in its own app** — read it to understand the pattern, then recreate\n\u003e it there.\n\nPuts a small eye icon inside a password field. Click it to reveal the password in plain\ntext (handy for catching typos on sign-in and sign-up), click again to hide it back to\ndots. It's a tiny Stimulus controller that flips the input's `type` between `\"password\"`\nand `\"text\"` and swaps an open-eye icon for a slashed-eye icon.\n\n\u003e **When to use:** any password input where a typo means a failed login and a frustrated\n\u003e user — sign-in, sign-up, \"change password\" forms. **When not to:** high-security fields\n\u003e where showing the value on a shared/over-the-shoulder screen is a real risk (or just\n\u003e leave it default-hidden and let the user opt in).\n\n---\n\n## The 80/20 in one breath\n\n1. Wrap the password input in a `position: relative` container so the icon can sit inside it.\n2. Add `data-controller=\"password-toggle\"` on the wrapper and a\n   `data-password-toggle-target=\"input\"` on the `\u003cinput type=\"password\"\u003e`.\n3. Drop an absolutely-positioned button on the right edge holding **two** icons — an open\n   eye (shown when hidden) and a slashed eye (shown when visible).\n4. On click, the controller toggles the input `type` and flips which icon is visible.\n\nThat's the whole feature — no model, no controller, no round-trip. It's pure client-side.\n\n---\n\n## Layer 1 — The View\n\n```erb\n\u003c%# app/views/landing/index.html.erb  (or your sign-in / devise form) %\u003e\n\u003c%# The wrapper is position:relative so the eye button can be absolutely placed inside. %\u003e\n\u003cdiv class=\"relative\" data-controller=\"password-toggle\"\u003e\n  \u003c%= password_field_tag :password, nil,\n        class: \"w-full rounded-lg border border-gray-300 px-3 py-2 pr-10 \" \\\n               \"focus:border-indigo-500 focus:ring-indigo-500\",\n        data: { password_toggle_target: \"input\" },\n        autocomplete: \"current-password\" %\u003e\n\n  \u003c%# The pr-10 above reserves room so typed text never runs under the icon. %\u003e\n  \u003cbutton type=\"button\"\n          class=\"absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600\"\n          data-action=\"password-toggle#toggle\"\n          data-password-toggle-target=\"button\"\n          aria-label=\"Show password\"\u003e\n    \u003c%# Open eye — visible while the password is HIDDEN (click to reveal). %\u003e\n    \u003ci class=\"fa-solid fa-eye\" data-password-toggle-target=\"eyeOpen\"\u003e\u003c/i\u003e\n    \u003c%# Slashed eye — visible while the password is SHOWN (click to hide). Hidden by default. %\u003e\n    \u003ci class=\"fa-solid fa-eye-slash hidden\" data-password-toggle-target=\"eyeSlash\"\u003e\u003c/i\u003e\n  \u003c/button\u003e\n\u003c/div\u003e\n```\n\n## Layer 2 — Stimulus / JavaScript\n\n```javascript\n// app/javascript/controllers/password_toggle_controller.js\nimport { Controller } from \"@hotwired/stimulus\"\n\n// Toggles a password field between hidden (dots) and visible (plain text), and\n// swaps the eye icon to match. Purely client-side — no form submit involved.\nexport default class extends Controller {\n  static targets = [\"input\", \"button\", \"eyeOpen\", \"eyeSlash\"]\n\n  toggle() {\n    const showing = this.inputTarget.type === \"text\"\n\n    // Flip the field type.\n    this.inputTarget.type = showing ? \"password\" : \"text\"\n\n    // Swap which icon is visible (open eye = hidden state, slashed eye = shown state).\n    this.eyeOpenTarget.classList.toggle(\"hidden\", !showing ? true : false)\n    this.eyeSlashTarget.classList.toggle(\"hidden\", !showing ? false : true)\n\n    // Keep the accessible label in sync with what the click will now do.\n    this.buttonTarget.setAttribute(\"aria-label\", showing ? \"Show password\" : \"Hide password\")\n  }\n}\n```\n\n\u003e The two `classList.toggle` lines read a little awkwardly on purpose — after the flip,\n\u003e `showing` still holds the *previous* state. When it was showing (`true`), we're now\n\u003e hiding, so we want the open eye back (`eyeOpen` un-hidden) and the slash hidden. If the\n\u003e ternaries bother you, compute `const nowHidden = this.inputTarget.type === \"password\"`\n\u003e *after* the flip and toggle off that instead.\n\n---\n\n## Gotchas (the hard-won stuff)\n\n- **Font Awesome must actually be loaded.** These snippets use `fa-eye` / `fa-eye-slash`\n  from Font Awesome. If your app doesn't pull in FA, the icons render as empty boxes or\n  nothing at all. Either add the FA stylesheet/kit, or swap the two `\u003ci\u003e` tags for inline\n  SVGs (see below) — don't assume FA is present just because the class name looks right.\n- **We tried an inline closed-eye SVG and reverted it.** On the original sign-in page we\n  briefly replaced the slashed eye with a hand-drawn SVG, then switched back to\n  `fa-eye-slash` — it was cleaner and consistent with the rest of the app's icon library.\n  If FA *is* already in your app, prefer it; only reach for SVG when you have no icon set.\n- **Reserve space with padding, not margin.** Put `pr-10` (right padding) on the input so\n  long passwords don't slide under the icon. The icon sits *on top* of the field, so\n  without the padding the last characters get visually clipped.\n- **Keep it a `\u003cbutton type=\"button\"\u003e`.** Inside a `\u003cform\u003e`, a bare `\u003cbutton\u003e` defaults to\n  `type=\"submit\"` — clicking the eye would submit the form. Always set `type=\"button\"`.\n- **Accessibility:** give the button an `aria-label` and update it on toggle (\"Show\n  password\" ↔ \"Hide password\") so screen-reader users know what it does and its current\n  state.\n- **Don't persist the revealed state.** Leave the field hidden on load every time; showing\n  by default defeats the point and leaks the value on a shared screen.\n\n---\n\n## Files this pattern touches\n\n```\napp/javascript/controllers/password_toggle_controller.js   # the toggle logic\napp/views/landing/index.html.erb                           # the field + eye button markup\n```\n\n## How to adapt to your schema\n\n1. **Any form works** — this isn't tied to Devise or a particular field name. Reuse the\n   same wrapper + controller on sign-up, password-reset, or a settings \"new password\"\n   field. For two fields (password + confirm), give each its own wrapper/controller\n   instance.\n2. **No Font Awesome?** Replace the two `\u003ci class=\"fa-...\"\u003e` tags with inline SVGs (an eye\n   and an eye-with-slash) carrying the same `data-password-toggle-target` attributes — the\n   controller doesn't care what the icons are, only which one is `hidden`.\n3. **Styling** is all Tailwind here; translate the utility classes to your CSS if you're\n   not on Tailwind. The only structural requirements are: relative wrapper, absolutely\n   positioned button, right-side padding on the input.\n"}