Navbar User Chip with Role & Sign-Out
⚠️ Cookbook example — not live code. 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.
The standard "who am I" chip for the top-right corner of an app's navbar. It answers three questions at a glance: who is signed in (avatar photo, or initials in a filled circle when there's no photo), what they are (their role in the app, in small muted text under their name), and how to leave (a quiet sign-out arrow). It's one shared partial, so every layout in the app renders the identical chip.
When to use: any signed-in app layout — dashboards, admin areas, internal tools. This should be the default top-right of every authenticated screen. When not to: marketing/public pages (no session to show), or apps with a full account dropdown menu — this chip is deliberately menu-free; sign-out is one click.
The 80/20 in one breath
- Add two helper methods —
user_initialsanduser_role_label— toapp/helpers/application_helper.rb. - Create the shared partial
app/views/shared/_user_chip.html.erb(name + role stack, avatar-or-initials circle, sign-out arrow). - Render it in the navbar of your layout, inside
<% if user_signed_in? %>, in a right-aligned flex container (ml-auto flex items-center gap-3). - Verify: sign in, check the chip shows the right initials and a human-readable role, click the arrow, land on the signed-out page.
Layer 1 — The helpers
Keep the initials and role logic out of the view so the partial stays dumb and both values are testable.
# app/helpers/application_helper.rb
module ApplicationHelper
# "Jordan Rivera" -> "JR"; falls back to the email for users with no name:
# "jordan.rivera@acme.com" -> "JR". Always 1-2 characters, never blank.
def user_initials(user)
source = user.name.presence || user.email.to_s
source.split(/[\s@.]/).reject(&:blank?).first(2).map { |w| w[0] }.join.upcase
end
# Human-readable role, never a raw enum value like "super_admin".
# Adapt the first line to wherever YOUR app stores role (see "How to adapt").
def user_role_label(user)
raw = user.try(:role) || user.try(:roles)&.first&.try(:name)
raw.present? ? raw.to_s.humanize : "Member"
end
def user_display_name(user)
user.name.presence || user.email.split("@").first.titleize
end
end
Layer 2 — The partial
<%# app/views/shared/_user_chip.html.erb %>
<%# Renders: [ name / role ] [ avatar or initials ] [ sign-out arrow ] %>
<div class="flex items-center gap-2.5">
<%# Name with role beneath — hidden on phones so the chip collapses to the avatar %>
<div class="hidden text-right leading-tight sm:block">
<p class="text-[13px] font-medium text-slate-800"><%= user_display_name(current_user) %></p>
<p class="text-[11px] text-slate-400"><%= user_role_label(current_user) %></p>
</div>
<%# Avatar photo if one is attached, otherwise an initials circle %>
<% if current_user.respond_to?(:avatar) && current_user.avatar.respond_to?(:attached?) && current_user.avatar.attached? %>
<%= image_tag current_user.avatar, class: "h-9 w-9 rounded-full object-cover" %>
<% else %>
<div class="flex h-9 w-9 items-center justify-center rounded-full bg-slate-800 text-[13px] font-semibold text-white">
<%= user_initials(current_user) %>
</div>
<% end %>
<%# Sign-out arrow — button_to so it's a real DELETE form, no Turbo dependency %>
<%= button_to destroy_user_session_path, method: :delete,
class: "text-slate-300 transition hover:text-rose-500",
form_class: "flex", "aria-label": "Sign out" do %>
<i class="fa-solid fa-arrow-right-from-bracket"></i>
<% end %>
</div>
If Font Awesome is not loaded in your app, replace the <i> with this inline SVG (same
arrow-out-of-bracket icon, inherits the text color):
<%# Inline-SVG fallback for the sign-out arrow (drop inside the button_to block) %>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" class="h-4 w-4 fill-current" aria-hidden="true">
<path 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"/>
</svg>
Layer 3 — Rendering it in the layout
<%# app/views/layouts/application.html.erb — inside the navbar/header %>
<header class="border-b border-slate-200 bg-white">
<div class="mx-auto flex h-14 max-w-7xl items-center gap-6 px-4">
<%# ... logo + nav links ... %>
<div class="ml-auto flex items-center gap-3">
<% if user_signed_in? %>
<%= render "shared/user_chip" %>
<% end %>
</div>
</div>
</header>
Gotchas (the hard-won stuff)
- Use
button_to, notlink_towithdata: { turbo_method: :delete }. The link version silently degrades to a GET when Turbo fails to load (broken importmap, JS error earlier on the page), and Devise's sign-out route only accepts DELETE — so sign-out just 404s or no-ops.button_torenders a real<form method="post">with a_method=deletefield and works with zero JavaScript. button_towraps the button in a<form>— style the form too. Withoutform_class: "flex"the form is a block element and the arrow icon drops out of vertical alignment with the avatar. This is the most common "why is my icon 3px too low" bug with this pattern.- Never render the raw role value.
current_user.roleis often an enum like"super_admin"or"instance_operator"— always pass it throughhumanize(theuser_role_labelhelper does). If the app has no role concept at all, the helper's"Member"fallback keeps the chip from rendering an awkward blank line. - Guard against name-less users. Sign-up flows frequently collect only an email.
Deriving both the display name and the initials from the email (
split(/[\s@.]/)) means the chip never renders an empty circle. A user named"j@x.co"still gets"J". - Hide the text block on phones, keep the avatar.
hidden sm:blockon the name/role stack is deliberate: on a 375px screen the navbar has no room for two lines of text, but the avatar + sign-out arrow still fit. Don't hide the whole chip. - Font Awesome is NOT guaranteed on every app. If
fa-arrow-right-from-bracketrenders as an empty square, the FA stylesheet isn't loaded — use the inline-SVG fallback above instead of adding a CDN<link>just for one icon. - Wrap the render in
user_signed_in?, not a nil-check inside the partial. The partial assumescurrent_useris present; keeping the guard at the call site means signed-out layouts (marketing pages, Devise screens) never pay for it. - The avatar branch is optional and self-disabling. The
respond_to?chain means the same partial works whether or not the app has an Active Storageavatarattachment onUser— no avatar setup, no crash, initials render. If you do have avatars andimage_processinginstalled, prefer a variant (current_user.avatar.variant(resize_to_fill: [72, 72])) so you're not shipping a full-size upload into a 36px circle. - Colors here are plain Tailwind slate — swap in your theme. The proven source
implementation used custom theme tokens; this recipe uses
slate-800(filled circle),slate-400(role line),slate-300 → rose-500(sign-out idle → hover). Keep the relationships (avatar darkest, role muted, sign-out quietest until hovered) even if you change the hues — the sign-out arrow should be the least prominent element until the pointer is on it.
Files this pattern touches
app/helpers/application_helper.rb # user_initials, user_role_label, user_display_name
app/views/shared/_user_chip.html.erb # the chip partial (new file)
app/views/layouts/application.html.erb # render call in the navbar
How to adapt to your schema
- Point
user_role_labelat your role storage. Common variants: a string/enum column →user.role.humanize; arolify-style association →user.roles.first&.name&.humanize; an org-membership join →user.memberships.find_by(organization: current_organization)&.role&.humanize. Change only the helper — the partial never knows where roles live. - Not using Devise? Replace
destroy_user_session_pathwith your sign-out route anduser_signed_in?/current_userwith your session helpers. Keep the DELETE method and thebutton_toform. - Multiple layouts? Render the same
shared/user_chippartial in each — that's the point of extracting it. Don't copy the markup per layout. - Want a dropdown menu instead of a bare arrow? This chip is the menu-free baseline. If you outgrow it (profile link, settings, org switcher), wrap the whole chip in a Stimulus dropdown and move sign-out into the menu — but keep the name/role/avatar anatomy.