{"slug":"password-show-hide-toggle","meta":{"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)."}]},"body":"# Password Show/Hide Toggle (eye icon or checkbox)\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\nLets a user reveal a typed password in plain text (handy for catching typos on sign-in\nand sign-up), then hide it back to dots. Two interchangeable variants, one shared\nStimulus controller that just flips the input's `type` between `\"password\"` and `\"text\"`:\n\n- **Variant A — eye icon:** a small eye button *inside* the field. Compact, the modern\n  convention, but needs an icon set and is easy to miss.\n- **Variant B — \"Show password\" checkbox:** an explicit labeled checkbox *below* the\n  field. More discoverable, a bigger touch target, needs **no icon library at all** —\n  often the better default for non-technical or older audiences.\n\n\u003e **When to use which:** eye icon when space is tight and your users live in modern apps;\n\u003e checkbox when discoverability matters more than polish (internal tools, audiences that\n\u003e won't recognize the eye glyph), or when Font Awesome isn't available. **When not to use\n\u003e either:** high-security fields where revealing the value on a shared/over-the-shoulder\n\u003e screen is a real risk.\n\n\u003c!-- ▼ LIVE DEMO block — this renders interactively on the llamapress.ai/cookbook page\n     (raw HTML passes through the markdown renderer). Agents: IGNORE this block; recreate\n     the pattern from the code layers below. Demo uses inline handlers because an\n     innerHTML-injected \u003cscript\u003e never executes. --\u003e\n\u003cdiv class=\"not-prose my-6 rounded-2xl border border-gray-200 overflow-hidden shadow-sm\"\u003e\n  \u003cdiv class=\"bg-gray-50 border-b border-gray-200 px-4 py-2 flex items-center gap-2\"\u003e\n    \u003cspan class=\"w-2 h-2 rounded-full bg-emerald-400\"\u003e\u003c/span\u003e\n    \u003cspan class=\"text-[10px] font-bold uppercase tracking-widest text-gray-500\"\u003eLive demo — try it\u003c/span\u003e\n  \u003c/div\u003e\n  \u003cdiv class=\"p-5 grid grid-cols-1 sm:grid-cols-2 gap-6 bg-white\"\u003e\n    \u003cdiv\u003e\n      \u003cp class=\"text-xs font-semibold text-gray-500 mb-2\"\u003eVariant A — eye icon\u003c/p\u003e\n      \u003cdiv class=\"relative\"\u003e\n        \u003cinput 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\"\u003e\n        \u003cbutton 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');\"\u003e\n          \u003ci class=\"fa-solid fa-eye demo-eye\"\u003e\u003c/i\u003e\n          \u003ci class=\"fa-solid fa-eye-slash demo-eye-slash hidden\"\u003e\u003c/i\u003e\n        \u003c/button\u003e\n      \u003c/div\u003e\n    \u003c/div\u003e\n    \u003cdiv\u003e\n      \u003cp class=\"text-xs font-semibold text-gray-500 mb-2\"\u003eVariant B — \"Show password\" checkbox\u003c/p\u003e\n      \u003cinput 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\"\u003e\n      \u003clabel class=\"mt-2 inline-flex items-center gap-2 text-sm text-gray-600 select-none cursor-pointer\"\u003e\n        \u003cinput type=\"checkbox\" class=\"rounded border-gray-300\" onchange=\"this.closest('div').querySelector('input[type=password],input[type=text]').type=this.checked?'text':'password'\"\u003e\n        Show password\n      \u003c/label\u003e\n    \u003c/div\u003e\n  \u003c/div\u003e\n\u003c/div\u003e\n\n---\n\n## The 80/20 in one breath\n\n1. Put `data-controller=\"password-toggle\"` on a wrapper around the password input, and\n   `data-password-toggle-target=\"input\"` on the `\u003cinput type=\"password\"\u003e`.\n2. **Variant A:** make the wrapper `position: relative` and drop an absolutely-positioned\n   button on the right edge holding **two** icons — open eye (shown while hidden) and\n   slashed eye (shown while visible). Click → `password-toggle#toggle`.\n3. **Variant B:** put a labeled checkbox under the field with\n   `data-password-toggle-target=\"checkbox\"` and\n   `data-action=\"password-toggle#toggleFromCheckbox\"`.\n4. The controller flips the input `type` and (for Variant A) swaps which icon is visible.\n\nThat's the whole feature — no model, no server controller, no round-trip. Pure client-side.\n\n---\n\n## Layer 1 — The View (Variant A: eye icon)\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 1b — The View (Variant B: \"Show password\" checkbox)\n\n```erb\n\u003c%# app/views/landing/index.html.erb — same controller, checkbox instead of the eye. %\u003e\n\u003cdiv 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 \" \\\n               \"focus:border-indigo-500 focus:ring-indigo-500\",\n        data: { password_toggle_target: \"input\" },\n        autocomplete: \"current-password\" %\u003e\n\n  \u003c%# Wrapping the checkbox in the \u003clabel\u003e makes the TEXT clickable too (big target). %\u003e\n  \u003clabel class=\"mt-2 inline-flex items-center gap-2 text-sm text-gray-600 select-none cursor-pointer\"\u003e\n    \u003c%# Plain HTML input, deliberately NO name= — see Gotchas (don't submit it as a param). %\u003e\n    \u003cinput type=\"checkbox\"\n           class=\"rounded border-gray-300 text-indigo-600 focus:ring-indigo-500\"\n           data-password-toggle-target=\"checkbox\"\n           data-action=\"password-toggle#toggleFromCheckbox\"\u003e\n    Show password\n  \u003c/label\u003e\n\u003c/div\u003e\n```\n\n## Layer 2 — Stimulus / JavaScript (shared by both variants)\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).\n// Works with an eye-icon button (toggle) OR a \"Show password\" checkbox\n// (toggleFromCheckbox) — icon/button targets are optional, so one controller\n// serves both variants. Purely client-side, no form submit involved.\nexport default class extends Controller {\n  static targets = [\"input\", \"button\", \"eyeOpen\", \"eyeSlash\", \"checkbox\"]\n\n  // Variant A — eye button clicked: flip whatever state we're in.\n  toggle() {\n    this.setVisible(this.inputTarget.type === \"password\")\n  }\n\n  // Variant B — checkbox changed: mirror the checkbox state.\n  toggleFromCheckbox() {\n    this.setVisible(this.checkboxTarget.checked)\n  }\n\n  setVisible(visible) {\n    this.inputTarget.type = visible ? \"text\" : \"password\"\n\n    // Icon swap + aria label only exist in Variant A — guard with has*Target.\n    if (this.hasEyeOpenTarget)  this.eyeOpenTarget.classList.toggle(\"hidden\", visible)\n    if (this.hasEyeSlashTarget) this.eyeSlashTarget.classList.toggle(\"hidden\", !visible)\n    if (this.hasButtonTarget) {\n      this.buttonTarget.setAttribute(\"aria-label\", visible ? \"Hide password\" : \"Show password\")\n    }\n  }\n}\n```\n\n---\n\n## Gotchas (the hard-won stuff)\n\n- **Font Awesome must actually be loaded (Variant A only).** These snippets use `fa-eye`\n  / `fa-eye-slash`. 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, swap the `\u003ci\u003e` tags for inline SVGs,\n  **or just use Variant B — the checkbox needs no icons at all** (this is the easiest fix).\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- **Give the checkbox NO `name` attribute (Variant B).** A named checkbox inside the\n  `\u003cform\u003e` submits as a param (`show_password=1`) and can trip strong-params or confuse\n  logs. A nameless input is never submitted — that's why the snippet uses a plain\n  `\u003cinput type=\"checkbox\"\u003e` instead of `check_box_tag` (which forces a name).\n- **Wrap the checkbox in its `\u003clabel\u003e`** so clicking the words \"Show password\" toggles it\n  too — the text is the target users actually aim for.\n- **Reserve space with padding, not margin (Variant A).** Put `pr-10` (right padding) on\n  the input so long passwords don't slide under the icon. The icon sits *on top* of the\n  field; without the padding the last characters get visually clipped.\n- **Keep the eye a `\u003cbutton type=\"button\"\u003e`.** Inside a `\u003cform\u003e`, a bare `\u003cbutton\u003e`\n  defaults to `type=\"submit\"` — clicking the eye would submit the form. Always set\n  `type=\"button\"`.\n- **Accessibility:** Variant A's button gets an `aria-label` kept in sync on toggle\n  (\"Show password\" ↔ \"Hide password\"). Variant B is accessible by construction — it's a\n  real labeled checkbox.\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 shared toggle logic\napp/views/landing/index.html.erb                           # the field + eye button OR checkbox 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 — or with the checkbox variant, one checkbox can rule both by making both\n   inputs `input` targets and flipping `this.inputTargets` in a loop.\n2. **Pick the variant by audience:** consumer-polish → eye icon; internal tool / broad\n   audience / no icon library → checkbox. Both use the identical controller, so you can\n   swap later by only touching the view.\n3. **No Font Awesome?** Use Variant B, or replace the two `\u003ci class=\"fa-...\"\u003e` tags with\n   inline SVGs carrying the same `data-password-toggle-target` attributes — the controller\n   doesn't care what the icons are, only which one is `hidden`.\n4. **Styling** is all Tailwind here; translate the utility classes to your CSS if you're\n   not on Tailwind. The only structural requirements are: (A) relative wrapper, absolutely\n   positioned button, right-side padding on the input; (B) a labeled checkbox anywhere\n   inside the `data-controller` wrapper.\n"}