Navbar User Chip with Role & Sign-Out | LlamaPress Cookbook
LlamaPress
Cookbook
Auth Stable

Navbar User Chip with Role & Sign-Out

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.

Proven on leo-nefe.leo.llamapress.ai view

Live demo — try it

Jordan Rivera

Administrator

JR

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

  1. Add two helper methods — user_initials and user_role_label — to app/helpers/application_helper.rb.
  2. Create the shared partial app/views/shared/_user_chip.html.erb (name + role stack, avatar-or-initials circle, sign-out arrow).
  3. 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).
  4. 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, not link_to with data: { 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_to renders a real <form method="post"> with a _method=delete field and works with zero JavaScript.
  • button_to wraps the button in a <form> — style the form too. Without form_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.role is often an enum like "super_admin" or "instance_operator" — always pass it through humanize (the user_role_label helper 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:block on 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-bracket renders 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 assumes current_user is 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 Storage avatar attachment on User — no avatar setup, no crash, initials render. If you do have avatars and image_processing installed, 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

  1. Point user_role_label at your role storage. Common variants: a string/enum column → user.role.humanize; a rolify-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.
  2. Not using Devise? Replace destroy_user_session_path with your sign-out route and user_signed_in? / current_user with your session helpers. Keep the DELETE method and the button_to form.
  3. Multiple layouts? Render the same shared/user_chip partial in each — that's the point of extracting it. Don't copy the markup per layout.
  4. 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.