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
| Prop | Type | Default | Description |
|---|---|---|---|
email* | string | — | |
name | string | null | — | |
avatarUrl | string | null | — | |
size | keyof typeof SIZES | 'sm' |