v1.0

Avatar

Preview

Basic

Loading…

Preview

Code

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

src/components/layout/Avatar.tsx

AI prompt

text
Build a user avatar component with an initials fallback in React + TypeScript + Tailwind CSS.

## Look
- Sizes: `sm` 28px (`w-7 h-7 text-[10px]`, the default), `md` 36px (`w-9 h-9 text-xs`), `lg` 64px (`w-16 h-16 text-lg`). Always `rounded-full shrink-0`.
- With an image: a plain `<img alt="">`, `object-cover bg-slate-100 dark:bg-slate-800`.
- Without: a circle `bg-indigo-600 text-white font-semibold` holding the initials, `aria-hidden`.

## Initials
- From the trimmed name: two or more words → first letter of the first and last word ("Ada Lovelace" → "AL"); one word → its first two letters ("Ada" → "AD"). Upper-cased.
- No name (an admin-created user may have none) → the first letter of the email. Never an empty circle, which reads as a broken avatar.

## Centring (load-bearing)
- The fallback is `inline-flex items-center justify-center leading-none`. Pin `leading-none`: an arbitrary size like `text-[10px]` sets font-size alone, so the line box is whatever is inherited — inside a table row with a 40px line-height, the initials sit visibly below the circle's middle. Use flex, not `grid place-items-center`, which degrades the same way in an auto row.

## API
`name?: string | null`, `email: string`, `avatarUrl?: string | null`, `size?: 'sm' | 'md' | 'lg'`.

## Demo
Ada Lovelace (ada@example.com) at sm, md and lg, plus an email-only "ops@example.com" at md showing "O".

## 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
/* Origin: bonus-adjustment (96S2), verbatim. */
/**
 * A user's avatar, or their initials when they have no image. The initials
 * helper lives in `lib/initials.ts` so the test suite can import it — Node
 * cannot parse `.tsx`.
 */
import { initialsFor } from '@/lib/initials';

/**
 * `sm` uses an arbitrary `text-[10px]` because there is no Tailwind step between
 * `text-xs` and nothing. That matters: a scale step like `text-xs` ships a
 * line-height with it, an arbitrary size sets `font-size` ALONE — so the line
 * box these glyphs sit in is whatever was inherited. The fallback branch below
 * pins it rather than leaving it to the caller's context.
 */
const SIZES = {
  sm: 'w-7 h-7 text-[10px]',
  md: 'w-9 h-9 text-xs',
  lg: 'w-16 h-16 text-lg',
} as const;

export default function Avatar({
  name,
  email,
  avatarUrl,
  size = 'sm',
}: {
  name?: string | null;
  email: string;
  avatarUrl?: string | null;
  size?: keyof typeof SIZES;
}) {
  const dimensions = SIZES[size];

  if (avatarUrl) {
    return (
      // Plain <img>: avatars are served by our own route handler off the local
      // disk, so next/image's optimiser buys nothing and would need the route
      // whitelisted in next.config.
      // eslint-disable-next-line @next/next/no-img-element
      <img
        src={avatarUrl}
        alt=""
        className={`${dimensions} rounded-full object-cover shrink-0 bg-slate-100 dark:bg-slate-800`}
      />
    );
  }

  return (
    // `leading-none` is the load-bearing class here, for the same reason
    // `.data-table thead th` carries `leading-4` in globals.css: a font-size
    // shrinks the GLYPHS, never the line box around them.
    //
    // The density rules (`html[data-density] table td { line-height: 40px }`)
    // inherit straight into this div, and `text-[10px]` — being arbitrary
    // rather than a scale step — brings no line-height of its own to shadow
    // them. A 40px line box does not fit a 28px circle, so the auto row could
    // not stretch, centring degenerated to start, and the initials sat 6.4px
    // BELOW the circle's middle in the users table. `md`/`lg` were never
    // affected: `text-xs`/`text-lg` do ship a line-height, which shadowed the
    // inherited one. Pinning the box makes all three sizes behave the same in
    // any context rather than only outside a table.
    //
    // Flex rather than `grid place-items-center`: an anonymous grid item in an
    // auto row is what silently degraded above, and a flex line centres the
    // real box. No optical nudge on top — measured against glyph ink (canvas
    // actualBoundingBox*, not the em box), all three sizes land within 0.6px of
    // the centre, which is the residual the uncomplained-about `lg` already had.
    <div
      aria-hidden
      className={`${dimensions} rounded-full shrink-0 inline-flex items-center justify-center leading-none font-semibold bg-indigo-600 text-white`}
    >
      {initialsFor(name, email)}
    </div>
  );
}

Props

PropTypeDefaultDescription
email*string—
namestring | null—
avatarUrlstring | null—
sizekeyof typeof SIZES'sm'