{"slug":"navbar-user-chip-with-role","meta":{"title":"Navbar User Chip with Role \u0026 Sign-Out","slug":"navbar-user-chip-with-role","category":"Auth","summary":"Top-right user identity chip — avatar photo or initials, the user's name with their role beneath it, and a sign-out arrow — packaged as one shared partial.","tags":["navbar","devise","identity","sign-out","tailwind","ux-default"],"status":"stable","visibility":"public","source_project":"leo-nefe.leo.llamapress.ai","layers":["view"]},"body":"# Navbar User Chip with Role \u0026 Sign-Out\n\n\u003e ⚠️ **Cookbook example — not live code.** Every code block below is an **example\n\u003e snippet**, **not part of the llamapress.ai codebase**, and **not running on this\n\u003e server**. This is a reference recipe for a **Leo instance (an AI coding agent) to\n\u003e implement in its own app** — read it to understand the pattern, then recreate it there.\n\nThe standard \"who am I\" chip for the top-right corner of an app's navbar. It answers\nthree questions at a glance: **who is signed in** (avatar photo, or initials in a filled\ncircle when there's no photo), **what they are** (their role in the app, in small muted\ntext under their name), and **how to leave** (a quiet sign-out arrow). It's one shared\npartial, so every layout in the app renders the identical chip.\n\n\u003c!-- LIVE DEMO — agents: IGNORE this block entirely. It exists only so the guide page on\n     llamapress.ai shows the pattern working. Implement from the layer sections below. --\u003e\n\u003cdiv class=\"not-prose my-6 rounded-xl border border-slate-200 bg-slate-50 p-5\"\u003e\n  \u003cp class=\"mb-3 text-xs font-semibold uppercase tracking-wide text-slate-400\"\u003eLive demo — try it\u003c/p\u003e\n  \u003cdiv class=\"flex items-center justify-end gap-3 rounded-lg border border-slate-200 bg-white px-4 py-3\"\u003e\n    \u003cdiv class=\"text-right leading-tight\"\u003e\n      \u003cp class=\"text-[13px] font-medium text-slate-800\"\u003eJordan Rivera\u003c/p\u003e\n      \u003cp class=\"text-[11px] text-slate-400\"\u003eAdministrator\u003c/p\u003e\n    \u003c/div\u003e\n    \u003cdiv class=\"flex h-9 w-9 items-center justify-center rounded-full bg-slate-800 text-[13px] font-semibold text-white\"\u003eJR\u003c/div\u003e\n    \u003cbutton type=\"button\" onclick=\"this.closest('div.rounded-lg').outerHTML='\u003cp class=\u0026quot;px-4 py-3 text-sm text-slate-500\u0026quot;\u003eSigned out (demo) — refresh to reset.\u003c/p\u003e'\" class=\"text-slate-300 transition hover:text-rose-500\" aria-label=\"Sign out\"\u003e\u003ci class=\"fa-solid fa-arrow-right-from-bracket\"\u003e\u003c/i\u003e\u003c/button\u003e\n  \u003c/div\u003e\n\u003c/div\u003e\n\n\u003e **When to use:** any signed-in app layout — dashboards, admin areas, internal tools.\n\u003e This should be the default top-right of every authenticated screen.\n\u003e **When not to:** marketing/public pages (no session to show), or apps with a full\n\u003e account dropdown menu — this chip is deliberately menu-free; sign-out is one click.\n\n---\n\n## The 80/20 in one breath\n\n1. Add two helper methods — `user_initials` and `user_role_label` — to\n   `app/helpers/application_helper.rb`.\n2. Create the shared partial `app/views/shared/_user_chip.html.erb` (name + role stack,\n   avatar-or-initials circle, sign-out arrow).\n3. Render it in the navbar of your layout, inside `\u003c% if user_signed_in? %\u003e`, in a\n   right-aligned flex container (`ml-auto flex items-center gap-3`).\n4. Verify: sign in, check the chip shows the right initials and a human-readable role,\n   click the arrow, land on the signed-out page.\n\n---\n\n## Layer 1 — The helpers\n\nKeep the initials and role logic out of the view so the partial stays dumb and both\nvalues are testable.\n\n```ruby\n# app/helpers/application_helper.rb\nmodule ApplicationHelper\n  # \"Jordan Rivera\" -\u003e \"JR\"; falls back to the email for users with no name:\n  # \"jordan.rivera@acme.com\" -\u003e \"JR\". Always 1-2 characters, never blank.\n  def user_initials(user)\n    source = user.name.presence || user.email.to_s\n    source.split(/[\\s@.]/).reject(\u0026:blank?).first(2).map { |w| w[0] }.join.upcase\n  end\n\n  # Human-readable role, never a raw enum value like \"super_admin\".\n  # Adapt the first line to wherever YOUR app stores role (see \"How to adapt\").\n  def user_role_label(user)\n    raw = user.try(:role) || user.try(:roles)\u0026.first\u0026.try(:name)\n    raw.present? ? raw.to_s.humanize : \"Member\"\n  end\n\n  def user_display_name(user)\n    user.name.presence || user.email.split(\"@\").first.titleize\n  end\nend\n```\n\n---\n\n## Layer 2 — The partial\n\n```erb\n\u003c%# app/views/shared/_user_chip.html.erb %\u003e\n\u003c%# Renders: [ name / role ] [ avatar or initials ] [ sign-out arrow ] %\u003e\n\u003cdiv class=\"flex items-center gap-2.5\"\u003e\n  \u003c%# Name with role beneath — hidden on phones so the chip collapses to the avatar %\u003e\n  \u003cdiv class=\"hidden text-right leading-tight sm:block\"\u003e\n    \u003cp class=\"text-[13px] font-medium text-slate-800\"\u003e\u003c%= user_display_name(current_user) %\u003e\u003c/p\u003e\n    \u003cp class=\"text-[11px] text-slate-400\"\u003e\u003c%= user_role_label(current_user) %\u003e\u003c/p\u003e\n  \u003c/div\u003e\n\n  \u003c%# Avatar photo if one is attached, otherwise an initials circle %\u003e\n  \u003c% if current_user.respond_to?(:avatar) \u0026\u0026 current_user.avatar.respond_to?(:attached?) \u0026\u0026 current_user.avatar.attached? %\u003e\n    \u003c%= image_tag current_user.avatar, class: \"h-9 w-9 rounded-full object-cover\" %\u003e\n  \u003c% else %\u003e\n    \u003cdiv class=\"flex h-9 w-9 items-center justify-center rounded-full bg-slate-800 text-[13px] font-semibold text-white\"\u003e\n      \u003c%= user_initials(current_user) %\u003e\n    \u003c/div\u003e\n  \u003c% end %\u003e\n\n  \u003c%# Sign-out arrow — button_to so it's a real DELETE form, no Turbo dependency %\u003e\n  \u003c%= button_to destroy_user_session_path, method: :delete,\n        class: \"text-slate-300 transition hover:text-rose-500\",\n        form_class: \"flex\", \"aria-label\": \"Sign out\" do %\u003e\n    \u003ci class=\"fa-solid fa-arrow-right-from-bracket\"\u003e\u003c/i\u003e\n  \u003c% end %\u003e\n\u003c/div\u003e\n```\n\nIf Font Awesome is not loaded in your app, replace the `\u003ci\u003e` with this inline SVG (same\narrow-out-of-bracket icon, inherits the text color):\n\n```erb\n\u003c%# Inline-SVG fallback for the sign-out arrow (drop inside the button_to block) %\u003e\n\u003csvg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 512 512\" class=\"h-4 w-4 fill-current\" aria-hidden=\"true\"\u003e\n  \u003cpath d=\"M377.9 105.9L500.7 228.7c7.2 7.2 11.3 17.1 11.3 27.3s-4.1 20.1-11.3 27.3L377.9 406.1c-6.4 6.4-15 9.9-24 9.9c-18.7 0-33.9-15.2-33.9-33.9l0-62.1-128 0c-17.7 0-32-14.3-32-32l0-64c0-17.7 14.3-32 32-32l128 0 0-62.1c0-18.7 15.2-33.9 33.9-33.9c9 0 17.6 3.6 24 9.9zM160 96L96 96c-17.7 0-32 14.3-32 32l0 256c0 17.7 14.3 32 32 32l64 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-64 0c-53 0-96-43-96-96L0 128C0 75 43 32 96 32l64 0c17.7 0 32 14.3 32 32s-14.3 32-32 32z\"/\u003e\n\u003c/svg\u003e\n```\n\n---\n\n## Layer 3 — Rendering it in the layout\n\n```erb\n\u003c%# app/views/layouts/application.html.erb — inside the navbar/header %\u003e\n\u003cheader class=\"border-b border-slate-200 bg-white\"\u003e\n  \u003cdiv class=\"mx-auto flex h-14 max-w-7xl items-center gap-6 px-4\"\u003e\n    \u003c%# ... logo + nav links ... %\u003e\n\n    \u003cdiv class=\"ml-auto flex items-center gap-3\"\u003e\n      \u003c% if user_signed_in? %\u003e\n        \u003c%= render \"shared/user_chip\" %\u003e\n      \u003c% end %\u003e\n    \u003c/div\u003e\n  \u003c/div\u003e\n\u003c/header\u003e\n```\n\n---\n\n## Gotchas (the hard-won stuff)\n\n- **Use `button_to`, not `link_to` with `data: { turbo_method: :delete }`.** The link\n  version silently degrades to a GET when Turbo fails to load (broken importmap, JS\n  error earlier on the page), and Devise's sign-out route only accepts DELETE — so\n  sign-out just 404s or no-ops. `button_to` renders a real `\u003cform method=\"post\"\u003e` with a\n  `_method=delete` field and works with zero JavaScript.\n- **`button_to` wraps the button in a `\u003cform\u003e` — style the form too.** Without\n  `form_class: \"flex\"` the form is a block element and the arrow icon drops out of\n  vertical alignment with the avatar. This is the most common \"why is my icon 3px too\n  low\" bug with this pattern.\n- **Never render the raw role value.** `current_user.role` is often an enum like\n  `\"super_admin\"` or `\"instance_operator\"` — always pass it through `humanize` (the\n  `user_role_label` helper does). If the app has no role concept at all, the helper's\n  `\"Member\"` fallback keeps the chip from rendering an awkward blank line.\n- **Guard against name-less users.** Sign-up flows frequently collect only an email.\n  Deriving both the display name and the initials from the email (`split(/[\\s@.]/)`)\n  means the chip never renders an empty circle. A user named `\"j@x.co\"` still gets `\"J\"`.\n- **Hide the text block on phones, keep the avatar.** `hidden sm:block` on the name/role\n  stack is deliberate: on a 375px screen the navbar has no room for two lines of text,\n  but the avatar + sign-out arrow still fit. Don't hide the whole chip.\n- **Font Awesome is NOT guaranteed on every app.** If `fa-arrow-right-from-bracket`\n  renders as an empty square, the FA stylesheet isn't loaded — use the inline-SVG\n  fallback above instead of adding a CDN `\u003clink\u003e` just for one icon.\n- **Wrap the render in `user_signed_in?`, not a nil-check inside the partial.** The\n  partial assumes `current_user` is present; keeping the guard at the call site means\n  signed-out layouts (marketing pages, Devise screens) never pay for it.\n- **The avatar branch is optional and self-disabling.** The `respond_to?` chain means\n  the same partial works whether or not the app has an Active Storage `avatar`\n  attachment on `User` — no avatar setup, no crash, initials render. If you *do* have\n  avatars and `image_processing` installed, prefer a variant\n  (`current_user.avatar.variant(resize_to_fill: [72, 72])`) so you're not shipping a\n  full-size upload into a 36px circle.\n- **Colors here are plain Tailwind slate — swap in your theme.** The proven source\n  implementation used custom theme tokens; this recipe uses `slate-800` (filled circle),\n  `slate-400` (role line), `slate-300 → rose-500` (sign-out idle → hover). Keep the\n  *relationships* (avatar darkest, role muted, sign-out quietest until hovered) even if\n  you change the hues — the sign-out arrow should be the least prominent element until\n  the pointer is on it.\n\n---\n\n## Files this pattern touches\n\n```\napp/helpers/application_helper.rb          # user_initials, user_role_label, user_display_name\napp/views/shared/_user_chip.html.erb       # the chip partial (new file)\napp/views/layouts/application.html.erb     # render call in the navbar\n```\n\n## How to adapt to your schema\n\n1. **Point `user_role_label` at your role storage.** Common variants:\n   a string/enum column → `user.role.humanize`; a `rolify`-style association →\n   `user.roles.first\u0026.name\u0026.humanize`; an org-membership join →\n   `user.memberships.find_by(organization: current_organization)\u0026.role\u0026.humanize`.\n   Change only the helper — the partial never knows where roles live.\n2. **Not using Devise?** Replace `destroy_user_session_path` with your sign-out route\n   and `user_signed_in?` / `current_user` with your session helpers. Keep the DELETE\n   method and the `button_to` form.\n3. **Multiple layouts?** Render the same `shared/user_chip` partial in each — that's the\n   point of extracting it. Don't copy the markup per layout.\n4. **Want a dropdown menu instead of a bare arrow?** This chip is the menu-free\n   baseline. If you outgrow it (profile link, settings, org switcher), wrap the whole\n   chip in a Stimulus dropdown and move sign-out into the menu — but keep the\n   name/role/avatar anatomy.\n"}