People Grid
alphav0.2.0Responsive grid of person cards — avatar with initials fallback, name, title, per-card links, and a custom item renderer.
Context
Use for conference speakers, team / about-us pages, board / committee lists, contributor grids, podcast guests, course instructors, judge lineups. Migration origin: kasder kas-social-front-v0 events/[id]/page.tsx Konuşmacılar (Speakers) block. The `getInitials` helper is reusable for mention chips, comment headers, contact rows.
Installation
pnpm dlx shadcn@latest initpnpm dlx shadcn@latest add @ilinxa/people-gridAdd -fixtures for dummy data:
pnpm dlx shadcn@latest add @ilinxa/people-grid-fixturesCLI can't resolve @ilinxa? The namespace is listed in the official shadcn registry directory, so current CLIs need no configuration. If yours can't resolve it (older or pinned versions, self-hosted mirrors), register it manually in components.json:
"registries": {
"@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}Preview
Konuşmacılar
Prof. Dr. Ahmet Yılmaz
Şehir Plancısı
Dr. Elif Kaya
Çevre Mühendisi
Mehmet Demir
Akıllı Şehir Uzmanı
Demo source
"use client"; import { AtSign, Briefcase, Globe } from "lucide-react";import { Tabs, TabsContent, TabsTrigger } from "@/components/ui/tabs";import { SwipeTabsList } from "@/components/site/swipe-tabs-list";import { PeopleGrid } from "./people-grid";import { dummyBoard, dummySpeakers, dummyTeam } from "./dummy-data"; const speakerSocial: Record< string, { handle?: string; work?: string; site?: string }> = { s1: { handle: "#", work: "#" }, s2: { work: "#", site: "#" }, s3: { handle: "#", site: "#" },}; export default function PeopleGridDemo() { return ( <Tabs defaultValue="default" className="w-full"> <SwipeTabsList> <TabsTrigger value="default">Default (TR)</TabsTrigger> <TabsTrigger value="initials">Initials fallback</TabsTrigger> <TabsTrigger value="columns">Columns 2-5</TabsTrigger> <TabsTrigger value="linked">Linked + actions</TabsTrigger> <TabsTrigger value="custom">Custom renderItem</TabsTrigger> </SwipeTabsList> {/* 1. Default — kasder Konuşmacılar verbatim */} <TabsContent value="default" className="mt-6"> <div className="max-w-4xl mx-auto"> <PeopleGrid heading="Konuşmacılar" items={dummySpeakers} columns={3} /> </div> </TabsContent> {/* 2. Initials fallback — names only, no images */} <TabsContent value="initials" className="mt-6"> <div className="max-w-4xl mx-auto space-y-2"> <p className="text-sm text-muted-foreground"> No <code>image</code> fields — falls back to{" "} <code>getInitials(name)</code>. Note titles (Dr./Prof./Mr./Ms./etc.) are skipped when computing initials. </p> <PeopleGrid heading="Board of Directors" items={dummyBoard} columns={5} avatarSize="md" /> </div> </TabsContent> {/* 3. Columns variants — 2 / 3 / 4 / 5 stacked */} <TabsContent value="columns" className="mt-6"> <div className="max-w-5xl mx-auto space-y-10"> <p className="text-sm text-muted-foreground"> Same 6-person team rendered with <code>columns</code> 2 / 3 / 4 / 5. All grids start at <code>grid-cols-1</code> on mobile and scale up at sm/md/lg breakpoints. For wider columns (<code>4</code> / <code>5</code>), pair with{" "} <code>avatarSize="sm"</code> or{" "} <code>"md"</code> for narrow containers. </p> <PeopleGrid heading="2 columns (lg avatar)" headingAs="h3" items={dummyTeam} columns={2} avatarSize="lg" /> <PeopleGrid heading="3 columns (lg avatar — kasder default)" headingAs="h3" items={dummyTeam} columns={3} avatarSize="lg" /> <PeopleGrid heading="4 columns (md avatar)" headingAs="h3" items={dummyTeam} columns={4} avatarSize="md" /> <PeopleGrid heading="5 columns (sm avatar, start-aligned)" headingAs="h3" items={dummyTeam} columns={5} avatarSize="sm" alignment="start" /> </div> </TabsContent> {/* 4. Linked — entire card is a link */} <TabsContent value="linked" className="mt-6"> <div className="max-w-4xl mx-auto space-y-2"> <p className="text-sm text-muted-foreground"> Each card has <code>href</code> — entire card surface is clickable. Tab to a card and you'll see the focus-visible ring covering the whole rectangle. Polymorphic{" "} <code>linkComponent</code> works with NextLink / RemixLink / etc. </p> <PeopleGrid heading="Speakers" items={dummySpeakers.map((s) => ({ ...s, href: `/speakers/${s.id}`, }))} columns={3} /> </div> </TabsContent> {/* 5. Custom renderItem — speakers with social links */} <TabsContent value="custom" className="mt-6"> <div className="max-w-4xl mx-auto space-y-2"> <p className="text-sm text-muted-foreground"> <code>renderItem</code> — full per-card takeover. Here: append a social-link row beneath the title. </p> <PeopleGrid heading="Konuşmacılar" items={dummySpeakers} columns={3} renderItem={(item) => { const social = speakerSocial[item.id]; return ( <div className="text-center"> <div className="w-24 h-24 rounded-full overflow-hidden border-4 border-primary/20 mx-auto mb-3"> {item.image && ( <img src={item.image} alt={item.name} loading="lazy" className="w-full h-full object-cover" /> )} </div> <h4 className="font-semibold text-foreground"> {item.name} </h4> <p className="text-sm text-muted-foreground mb-2"> {item.title} </p> {social && ( <div className="flex justify-center gap-2 text-muted-foreground"> {social.handle && ( <a href={social.handle} aria-label={`${item.name} social handle`} className="hover:text-primary" > <AtSign aria-hidden="true" className="w-4 h-4" /> </a> )} {social.work && ( <a href={social.work} aria-label={`${item.name} professional profile`} className="hover:text-primary" > <Briefcase aria-hidden="true" className="w-4 h-4" /> </a> )} {social.site && ( <a href={social.site} aria-label={`${item.name} personal website`} className="hover:text-primary" > <Globe aria-hidden="true" className="w-4 h-4" /> </a> )} </div> )} </div> ); }} /> </div> </TabsContent> </Tabs> );} Usage
When to use
Reach for PeopleGrid when you need a section heading + responsive grid of person cards (round avatar + name + title) — for conference speakers, team / about-us pages, board / committee lists, contributors, podcast guests, course instructors, judge lineups. Avatar gracefully falls back to initials when image is missing.
Minimal example
import { PeopleGrid } from "@/components/people-grid";
<PeopleGrid
heading="Konuşmacılar"
items={event.speakers.map((s, i) => ({
id: String(i),
name: s.name,
title: s.title,
image: s.image,
}))}
columns={3}
/>;Public helper kernel — getInitials
The initials helper is exported as a pure function. Reuse it in mention chips, comment headers, contact rows, presence badges:
import { getInitials } from "@/components/people-grid";
getInitials("Dr. Ahmet Yılmaz") // → "AY"
getInitials("Prof. Dr. Elif Kaya") // → "EK"
getInitials("Madonna") // → "M"
getInitials("") // → "?"
// Common honorifics skipped: Dr., Prof., Mr., Mrs., Ms., Sr., Jr.Columns × avatar size
All grids start at grid-cols-1 on mobile and scale up at sm/md/lg breakpoints based on columns:
columns=2— 1 col mobile, 2 col sm:+columns=3— 1 col mobile, 2 col sm:, 3 col md:+columns=4— 1 col mobile, 2 col sm:, 4 col md:+columns=5— 1 col mobile, 2 col sm:, 3 col md:, 5 col lg:+
For columns: 4 or 5 in narrow containers, pair with avatarSize: "sm" or "md" — large avatars (w-24 h-24) at 4-5 columns need ~600px+ to render without crowding. Defaults (columns: 3, avatarSize: "lg") match kasder and work everywhere down to mobile.
Avatar fallback
Items without image render an initials circle with bg-primary/10 text-primary font-semibold. The component handles common honorifics (Dr./Prof./Mr./etc.) by skipping them when computing initials:
<PeopleGrid
heading="Board of Directors"
items={[
{ id: "1", name: "Dr. Sara Ahmed", title: "Chair" }, // → "SA"
{ id: "2", name: "Prof. James O'Neill", title: "Vice-Chair" }, // → "JO"
{ id: "3", name: "Madonna", title: "Director" }, // → "M"
]}
// no image fields — all render with initials
/>Polymorphic linkComponent
import NextLink from "next/link";
<PeopleGrid
heading="Our Team"
items={team.map((m) => ({ ...m, href: `/team/${m.slug}` }))}
linkComponent={NextLink}
columns={4}
/>When hrefis supplied, the entire card becomes clickable via the overlay-link pattern. The link's accessible name = the person's name (via aria-labelledby) — screen reader announces just the name, not the flattened title/image-alt.
Custom renderItem
<PeopleGrid
items={speakers}
renderItem={(person) => (
<div className="text-center">
<Avatar src={person.image} />
<h4 className="font-semibold">{person.name}</h4>
<p className="text-sm text-muted-foreground">{person.title}</p>
<SocialLinks links={person.social} />
</div>
)}
/>Empty state
Empty items → renders emptyState if provided, else <p role="status"> with labels.emptyText(default "No people to display.").
Notes
- Renders
<ul role="list">; sectionaria-labelledbyresolves to the heading id (viauseId) when heading is supplied. - Heading defaults to
h2(people grids are top-level page sections — Speakers, Team, Contributors). Differs from sidebar info cards which default toh3. - No
framedprop — people grids are typically standalone page sections, not nested cards. Wrap externally if you need card chrome. - Initials fallback is
aria-hidden; the name in the adjacent<h4>is what screen readers announce. - Hover-color transition on the name is gated via
motion-safe:— reduced-motion users see static text.
Features
- Responsive grid — columns 2/3/4/5 with built-in breakpoint scaling (all start at 1 col mobile)
- Avatar size variants (sm/md/lg)
- Alignment (center/start)
- Initials fallback when image is missing — handles Dr./Prof./etc. honorifics
- Public getInitials helper kernel exported as pure function
- Polymorphic per-card link via linkComponent + per-item href (overlay-link pattern)
- Custom renderItem slot for full per-person takeover
- Optional section heading with configurable level (h2/h3/h4, default h2)
- Soft-failure on optional fields (title / image / imageAlt / href)
- Empty state slot + labels.emptyText fallback
- <ul role='list'> semantics + section aria-labelledby
- aria-labelledby on per-card link → accessible name = person's name only