Skip to content
ilinxa/pro-ui

People Grid

alphav0.2.0

Responsive grid of person cards — avatar with initials fallback, name, title, per-card links, and a custom item renderer.

Category: Data DisplayUpdated: 2026-08-11Created: 2026-05-02Author: ilinxa

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

Initialize shadcn (once per project)Seeds lib/utils.ts and components.json. Skip if you've already used any shadcn component.
pnpm dlx shadcn@latest init
Install the component
pnpm dlx shadcn@latest add @ilinxa/people-grid

Add -fixtures for dummy data:

pnpm dlx shadcn@latest add @ilinxa/people-grid-fixtures

CLI 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

    Prof. Dr. Ahmet Yılmaz

    Şehir Plancısı

  • Dr. Elif Kaya

    Dr. Elif Kaya

    Çevre Mühendisi

  • Mehmet Demir

    Mehmet Demir

    Akıllı Şehir Uzmanı

Demo source

demo.tsxtsx
"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=&quot;sm&quot;</code> or{" "}            <code>&quot;md&quot;</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&apos;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">; section aria-labelledby resolves to the heading id (via useId) 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 to h3.
  • No framed prop — 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

Tags

people-gridteamspeakersgridavatar

Dependencies