# Team Section v2

> The same team title, description, and portraits as an index. Names sit in a list, and a greyscale portrait slides to the row you hover or reach with the arrow keys.

A shadcn/ui block from the [@uiception](https://uiception.com/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/uiception/team-section-v2).

- Page, with a live preview: https://shadcn.exchange/r/uiception/team-section-v2
- Screenshot: https://shadcn.exchange/shot/uiception/team-section-v2/light.png
- Registry item: https://uiception.com/r/team-section-v2.json
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/uiception/team-section-v2.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `geist`
- Registry items: `https://uiception.com/r/block-media.json`

## Files

### `registry/new-york/blocks/team-section-v2/page.tsx`

```tsx
import TeamSectionV2 from "./components/team-section-v2"

export default function Page() {
  return <TeamSectionV2 />
}
```

### `registry/new-york/blocks/team-section-v2/components/team-section-v2.tsx`

```tsx
import { createBlockImage } from "@/lib/block-media"

import { TeamSectionV2Root } from "./team-section-v2-root"
import { teamFiles, type TeamMember } from "../lib/config"

const blockImage = createBlockImage("team-section-v2")

const teamMembers: TeamMember[] = teamFiles.map((member, index) => ({
  id: String(index + 1),
  name: member.name,
  title: member.title,
  avatarSrc: blockImage(member.file),
}))

export type TeamSectionV2Props = {
  members?: TeamMember[]
}

export default function TeamSectionV2({
  members = teamMembers,
}: TeamSectionV2Props = {}) {
  return <TeamSectionV2Root members={members} />
}
```

### `registry/new-york/blocks/team-section-v2/components/team-section-v2-root.tsx`

```tsx
"use client"

import { GeistPixelCircle } from "geist/font/pixel"
import Image from "next/image"
import {
  useCallback,
  useLayoutEffect,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent,
} from "react"

import { sectionHeader, type TeamMember } from "../lib/config"
import "../styles/team-section-v2.css"

type TeamSectionV2RootProps = {
  members: TeamMember[]
}

function optionId(id: string) {
  return `team-section-v2-option-${id}`
}

export function TeamSectionV2Root({ members }: TeamSectionV2RootProps) {
  const count = members.length
  const [activeIndex, setActiveIndex] = useState(0)
  const [previousIndex, setPreviousIndex] = useState(0)
  const [revealKey, setRevealKey] = useState(0)
  const [offsetY, setOffsetY] = useState(0)
  const [motionReady, setMotionReady] = useState(false)
  const listRef = useRef<HTMLUListElement>(null)
  const frameRef = useRef<HTMLDivElement>(null)
  const active = members[activeIndex]

  const measure = useCallback(() => {
    const list = listRef.current
    const frame = frameRef.current
    const row = list?.querySelector<HTMLElement>(
      `[data-index="${activeIndex}"]`
    )
    if (!list || !frame || !row) return

    const listRect = list.getBoundingClientRect()
    const rowRect = row.getBoundingClientRect()
    const circle = frame.querySelector<HTMLElement>("[data-tsv2-portrait]")
    const circleHeight = circle?.offsetHeight ?? frame.offsetHeight
    const rowCenter = rowRect.top - listRect.top + rowRect.height / 2
    const raw = rowCenter - circleHeight / 2
    const sectionRect = list.closest("section")?.getBoundingClientRect()
    const minY = sectionRect ? sectionRect.top + 32 - listRect.top : raw
    const next = Math.round(Math.max(raw, minY))
    setOffsetY((current) => (current === next ? current : next))
  }, [activeIndex])

  useLayoutEffect(() => {
    measure()
    const list = listRef.current
    const frame = frameRef.current
    if (!list) return
    const observer = new ResizeObserver(() => measure())
    observer.observe(list)
    if (frame) observer.observe(frame)
    return () => observer.disconnect()
  }, [measure])

  useLayoutEffect(() => {
    const frame = requestAnimationFrame(() => setMotionReady(true))
    return () => cancelAnimationFrame(frame)
  }, [])

  function select(index: number) {
    if (count < 1) return
    const normalized = ((index % count) + count) % count
    if (normalized === activeIndex) return
    setPreviousIndex(activeIndex)
    setActiveIndex(normalized)
    setRevealKey((key) => key + 1)
  }

  function onKeyDown(event: KeyboardEvent<HTMLUListElement>) {
    if (event.key === "ArrowDown") {
      event.preventDefault()
      select(activeIndex + 1)
    } else if (event.key === "ArrowUp") {
      event.preventDefault()
      select(activeIndex - 1)
    } else if (event.key === "Home") {
      event.preventDefault()
      select(0)
    } else if (event.key === "End") {
      event.preventDefault()
      select(count - 1)
    }
  }

  if (!active) return null

  return (
    <section aria-label="Team" className="pt-16 pb-24 lg:pt-24 lg:pb-36">
      <div className="mx-auto max-w-6xl px-6">
        <header className="max-w-xl">
          <h2 className="text-4xl leading-[1.1] font-medium tracking-[-0.03em] text-foreground lg:text-5xl">
            {sectionHeader.title}
          </h2>
          <p className="mt-4 max-w-md text-[0.9375rem] leading-relaxed text-muted-foreground">
            {sectionHeader.description}
          </p>
        </header>

        <div className="mt-12 grid items-start gap-10 lg:mt-16 lg:grid-cols-[minmax(0,1fr)_16.5rem] lg:gap-x-16">
          <ul
            ref={listRef}
            role="listbox"
            aria-label="Team members"
            aria-activedescendant={optionId(active.id)}
            tabIndex={0}
            className="border-t border-border outline-none focus-visible:outline-2 focus-visible:outline-offset-8 focus-visible:outline-ring"
            onKeyDown={onKeyDown}
          >
            {members.map((member, index) => {
              const selected = index === activeIndex
              return (
                <li
                  key={member.id}
                  id={optionId(member.id)}
                  role="option"
                  aria-selected={selected}
                  aria-label={`${member.name}, ${member.title}`}
                  data-index={index}
                  className="flex cursor-pointer items-start border-b border-border py-5"
                  onMouseEnter={() => select(index)}
                  onClick={() => select(index)}
                >
                  <span
                    className={`min-w-0 transition-opacity duration-300 motion-reduce:transition-none ${
                      selected ? "opacity-100" : "opacity-30"
                    }`}
                  >
                    <span
                      className={`${GeistPixelCircle.className} block text-[1.65rem] leading-none font-normal tracking-normal text-foreground lg:text-4xl`}
                    >
                      {member.name}
                    </span>
                    <span className="mt-1.5 block text-sm text-muted-foreground">
                      {member.title}
                    </span>
                  </span>
                </li>
              )
            })}
          </ul>

          <div className="relative order-first lg:order-0">
            <div
              ref={frameRef}
              data-ready={motionReady ? "true" : "false"}
              style={{ "--tsv2-y": `${offsetY}px` } as CSSProperties}
              className="tsv2-frame mx-auto w-full max-w-xs lg:absolute lg:inset-x-0 lg:top-0 lg:max-w-none"
            >
              <Portrait
                members={members}
                activeIndex={activeIndex}
                previousIndex={previousIndex}
                revealKey={revealKey}
              />
              <p className="mt-4 text-center lg:hidden">
                <span className="block text-sm font-medium text-foreground">
                  {active.name}
                </span>
                <span className="mt-0.5 block text-sm text-muted-foreground">
                  {active.title}
                </span>
              </p>
            </div>
          </div>
        </div>
      </div>
    </section>
  )
}

function Portrait({
  members,
  activeIndex,
  previousIndex,
  revealKey,
}: {
  members: TeamMember[]
  activeIndex: number
  previousIndex: number
  revealKey: number
}) {
  return (
    <div
      data-tsv2-portrait
      className="relative aspect-square overflow-hidden rounded-full bg-muted ring-1 ring-border"
    >
      {members.map((member, index) => {
        const active = index === activeIndex
        const leaving = revealKey > 0 && index === previousIndex && !active
        return (
          <Image
            key={member.id}
            src={member.avatarSrc}
            alt=""
            fill
            unoptimized
            sizes="280px"
            priority={index === 0}
            draggable={false}
            className={`object-cover grayscale ${
              revealKey > 0 && active
                ? "tsv2-portrait-in"
                : leaving
                  ? "tsv2-portrait-out"
                  : active
                    ? "opacity-100"
                    : "opacity-0"
            }`}
            style={{ zIndex: active ? 2 : leaving ? 1 : 0 }}
          />
        )
      })}
    </div>
  )
}
```

### `registry/new-york/blocks/team-section-v2/lib/config.ts`

```ts
export type TeamMember = {
  id: string
  name: string
  title: string
  avatarSrc: string
}

export const sectionHeader = {
  title: "Who we are",
  description:
    "Engineers, operators, designers, and data people who build and run Acme every day.",
} as const

export const teamFiles = [
  { file: "avatar-1.png", name: "Jane Doe", title: "VP of Engineering" },
  { file: "avatar-2.png", name: "John Doe", title: "Director of Operations" },
  { file: "avatar-3.png", name: "Jane Smith", title: "Chief Product Officer" },
  { file: "avatar-4.png", name: "John Smith", title: "Head of Autonomy" },
  { file: "avatar-5.png", name: "Alex Johnson", title: "Staff Data Engineer" },
  { file: "avatar-6.png", name: "Sam Wilson", title: "SVP of Digital" },
] as const
```

### `registry/new-york/blocks/team-section-v2/styles/team-section-v2.css`

```css
.tsv2-frame {
  transition:
    transform 420ms cubic-bezier(0.22, 1, 0.36, 1),
    opacity 240ms ease-out;
}

.tsv2-frame[data-ready="false"] {
  transition: none;
}

@media (min-width: 1024px) {
  .tsv2-frame {
    transform: translateY(var(--tsv2-y, 0px));
  }

  /* The server can't measure the active row, so the frame renders at 0px.
     Keep it hidden until the client has measured and moved it into place. */
  .tsv2-frame[data-ready="false"] {
    opacity: 0;
  }
}

@keyframes tsv2-portrait-in {
  from {
    opacity: 0;
    transform: scale(1.08);
  }
  to {
    opacity: 1;
    transform: scale(1);
  }
}

@keyframes tsv2-portrait-out {
  from {
    opacity: 1;
    transform: scale(1);
  }
  to {
    opacity: 0;
    transform: scale(0.94);
  }
}

.tsv2-portrait-in {
  opacity: 1;
  transform: scale(1);
  animation: tsv2-portrait-in 560ms cubic-bezier(0.22, 1, 0.36, 1) both;
}

.tsv2-portrait-out {
  opacity: 0;
  transform: scale(0.94);
  animation: tsv2-portrait-out 420ms cubic-bezier(0.4, 0, 1, 1) both;
}

@media (prefers-reduced-motion: reduce) {
  .tsv2-frame,
  .tsv2-portrait-in,
  .tsv2-portrait-out {
    animation: none;
    transition: none;
  }
}
```

### `public/images/blocks/team-section-v2/avatar-1.png.gitkeep`

```gitkeep
Place your asset here. Same base name, any supported format: jpg, jpeg, png, webp, avif, gif, or svg. Remove the .gitkeep extension from the filename.
```

### `public/images/blocks/team-section-v2/avatar-2.png.gitkeep`

```gitkeep
Place your asset here. Same base name, any supported format: jpg, jpeg, png, webp, avif, gif, or svg. Remove the .gitkeep extension from the filename.
```

### `public/images/blocks/team-section-v2/avatar-3.png.gitkeep`

```gitkeep
Place your asset here. Same base name, any supported format: jpg, jpeg, png, webp, avif, gif, or svg. Remove the .gitkeep extension from the filename.
```

### `public/images/blocks/team-section-v2/avatar-4.png.gitkeep`

```gitkeep
Place your asset here. Same base name, any supported format: jpg, jpeg, png, webp, avif, gif, or svg. Remove the .gitkeep extension from the filename.
```

### `public/images/blocks/team-section-v2/avatar-5.png.gitkeep`

```gitkeep
Place your asset here. Same base name, any supported format: jpg, jpeg, png, webp, avif, gif, or svg. Remove the .gitkeep extension from the filename.
```

### `public/images/blocks/team-section-v2/avatar-6.png.gitkeep`

```gitkeep
Place your asset here. Same base name, any supported format: jpg, jpeg, png, webp, avif, gif, or svg. Remove the .gitkeep extension from the filename.
```
