v1.0

UserDropdown

Preview

Basic

Loading…

Preview

Code

ts
import UserDropdown from '@/components/layout/UserDropdown';

src/components/layout/UserDropdown.tsx

AI prompt

text
Build an account dropdown menu component for an app header in React + TypeScript + Tailwind CSS.

## Look
- Trigger: `flex items-center gap-2 p-1 pr-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800` (kept on that background while open): a 28px round avatar then a 14px `ChevronDown` in slate-400 that rotates 180° when open.
- Avatar: the user's photo (`object-cover`), else white semibold initials on an indigo-600 circle — first + last word initials of the name, else the email's first letter.
- Panel: floating surface, `absolute right-0 mt-2 w-64 p-2 z-50`.
- Identity header: `flex items-center gap-3 px-2 py-2 mb-1 border-b border-slate-100 dark:border-slate-800`, a 36px avatar, then in a `min-w-0` block, each line truncating: name (or email) `text-xs font-semibold text-slate-800 dark:text-slate-100`; email `text-[11px] text-slate-500 dark:text-slate-400`; roles joined with ", " (or "No role assigned") plus " · ENG"-style department code, `mt-0.5 text-[10px] text-slate-400 dark:text-slate-500`.
- Items: `w-full flex items-center gap-2.5 px-3 py-2 text-xs text-left rounded-lg text-slate-700 dark:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800`, each with a 14px slate-400 icon: "My profile" (`User`, links to /profile), "Change password" (`KeyRound`, /change-password). Clicking a link closes the menu.
- "Sign out" (`LogOut`): same row in `text-rose-600 dark:text-rose-400 hover:bg-rose-50 dark:hover:bg-rose-950/40`.

## Behaviour
- Sign-out is a submit button inside `<form action={logoutAction}>`, posting a real server action that clears the session server-side so the cookie cannot be replayed — not a client-side redirect.

## API
`user: { name: string | null; email: string; avatarUrl: string | null; roleNames: string[]; departmentCode: string | null }`, `logoutAction: () => Promise<void>`.

## Demo
Right-aligned in a row: Ada Lovelace, ada@example.com, Administrator · ENG.

## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.

Source

tsx
'use client';

/* Origin: bonus-adjustment (96S2), verbatim. */

import Link from 'next/link';
import { useRef, useState } from 'react';
import { ChevronDown, KeyRound, LogOut, User } from 'lucide-react';
import { cn } from '@/lib/cn';
import { useDismiss } from '@/lib/use-dismiss';
import Avatar from './Avatar';

export type DropdownUser = {
  name: string | null;
  email: string;
  avatarUrl: string | null;
  roleNames: string[];
  departmentCode: string | null;
};

/**
 * The account menu in the topbar. Sign-out posts the real `logout` server
 * action (passed in as `logoutAction`, since this is a client component) —
 * which clears `sessionToken` server-side, so the cookie cannot be replayed.
 */
export default function UserDropdown({
  user,
  logoutAction,
}: {
  user: DropdownUser;
  logoutAction: () => Promise<void>;
}) {
  const [open, setOpen] = useState(false);
  const ref = useRef<HTMLDivElement>(null);

  useDismiss(ref, open, () => setOpen(false));

  const itemClasses =
    'w-full flex items-center gap-2.5 px-3 py-2 text-xs text-left rounded-lg transition-colors text-slate-700 dark:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800';

  return (
    <div className="relative" ref={ref}>
      <button
        type="button"
        onClick={() => setOpen((o) => !o)}
        className={cn(
          'flex items-center gap-2 p-1 pr-2 rounded-lg transition-colors hover:bg-slate-100 dark:hover:bg-slate-800',
          open && 'bg-slate-100 dark:bg-slate-800',
        )}
      >
        <Avatar name={user.name} email={user.email} avatarUrl={user.avatarUrl} size="sm" />
        <ChevronDown
          className={cn('w-3.5 h-3.5 text-slate-400 transition-transform', open && 'rotate-180')}
        />
      </button>

      {/* `panel-solid` is `.panel` without the translucency: this floats over
          page content rather than sitting on the gradient ground, so the
          frosted default shows whatever is behind it straight through the
          menu. Shared with the other two header dropdowns. */}
      {open && (
        <div className="absolute right-0 mt-2 w-64 panel panel-solid p-2 z-50">
          <div className="flex items-center gap-3 px-2 py-2 border-b border-slate-100 dark:border-slate-800 mb-1">
            <Avatar name={user.name} email={user.email} avatarUrl={user.avatarUrl} size="md" />
            <div className="min-w-0">
              <p className="text-xs font-semibold text-slate-800 dark:text-slate-100 truncate">
                {user.name || user.email}
              </p>
              <p className="text-[11px] text-slate-500 dark:text-slate-400 truncate">{user.email}</p>
              <p className="text-[10px] text-slate-400 dark:text-slate-500 truncate mt-0.5">
                {user.roleNames.join(', ') || 'No role assigned'}
                {user.departmentCode ? ` · ${user.departmentCode}` : ''}
              </p>
            </div>
          </div>

          <Link href="/profile" onClick={() => setOpen(false)} className={itemClasses}>
            <User className="w-3.5 h-3.5 text-slate-400" />
            My profile
          </Link>
          <Link href="/change-password" onClick={() => setOpen(false)} className={itemClasses}>
            <KeyRound className="w-3.5 h-3.5 text-slate-400" />
            Change password
          </Link>

          <form action={logoutAction}>
            <button
              type="submit"
              className={cn(
                itemClasses,
                'text-rose-600 dark:text-rose-400 hover:bg-rose-50 dark:hover:bg-rose-950/40',
              )}
            >
              <LogOut className="w-3.5 h-3.5" />
              Sign out
            </button>
          </form>
        </div>
      )}
    </div>
  );
}

Props

PropTypeDefaultDescription
user*DropdownUser—
logoutAction*() => Promise<void>—