# Team 5

> Portrait cards that turn to colour on hover and reveal contact buttons (from Marbrook)

A shadcn/ui block from the [@cnippet-blocks](https://blocks.cnippet.dev/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/cnippet-blocks/team-5).

- Page, with a live preview: https://shadcn.exchange/r/cnippet-blocks/team-5
- Screenshot: https://shadcn.exchange/shot/cnippet-blocks/team-5/light.png
- Registry item: https://blocks.cnippet.dev/r/team-5.json
- Categories: team
- Listed: 2026-11-01, source last changed 2026-11-01

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/cnippet-blocks/team-5.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: `lucide-react`
- Registry items: `@cnippet/tooltip`, `@cnippet/utils`

## Files

### `registry/default/sections/team-5/index.tsx`

```tsx
import { ArrowUpRight, Mail, Phone } from "lucide-react";
import Image from "next/image";
import Link from "next/link";

import { Tooltip, TooltipPopup, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";

import type { Surface } from "../_types/common";
import type { TeamContent, TeamMember } from "../_types/team";
import { defaultTeam5Content } from "./data";
import { SectionHeader, surfaceClass, toneFor } from "./section-header";

function telHref(phone: string): string {
  return `tel:${phone.replace(/[^\d+]/g, "")}`;
}

const contactButton =
  "flex size-9 items-center justify-center rounded-md bg-white/95 text-primary transition-colors hover:bg-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white";

function MemberCard({
  member,
  showBio,
  sizes,
}: {
  member: TeamMember;
  showBio: boolean;
  sizes: string;
}) {
  return (
    <article className="group">
      <div className="relative aspect-4/5 overflow-hidden rounded-lg bg-muted">
        <Image
          alt={member.image.alt}
          className="object-cover grayscale transition-all duration-700 ease-out group-hover:scale-105 group-hover:grayscale-0"
          fill
          sizes={sizes}
          src={member.image.src}
        />
        <div className="pointer-events-none absolute inset-x-0 bottom-0 h-24 bg-linear-to-t from-primary/80 to-transparent opacity-0 transition-opacity duration-500 group-hover:opacity-100" />
        {/* The contact row is revealed on hover but stays reachable by
            keyboard: focus-within keeps it visible while a link holds focus. */}
        {(member.phone || member.email) && (
          <div className="absolute inset-x-0 bottom-0 flex translate-y-2 items-center gap-3 p-4 opacity-0 transition-all duration-500 group-focus-within:translate-y-0 group-focus-within:opacity-100 group-hover:translate-y-0 group-hover:opacity-100">
            {member.phone && (
              <Tooltip>
                <TooltipTrigger
                  render={
                    <a
                      aria-label={`Call ${member.name}`}
                      className={contactButton}
                      href={telHref(member.phone)}
                    >
                      <Phone className="size-4" />
                    </a>
                  }
                />
                <TooltipPopup>{member.phone}</TooltipPopup>
              </Tooltip>
            )}
            {member.email && (
              <Tooltip>
                <TooltipTrigger
                  render={
                    <a
                      aria-label={`Email ${member.name}`}
                      className={contactButton}
                      href={`mailto:${member.email}`}
                    >
                      <Mail className="size-4" />
                    </a>
                  }
                />
                <TooltipPopup>{member.email}</TooltipPopup>
              </Tooltip>
            )}
          </div>
        )}
      </div>

      <h3 className="mt-5 font-display text-2xl text-primary">{member.name}</h3>
      <p className="mt-1 text-muted-foreground text-sm">{member.role}</p>

      {showBio && member.bio && (
        <p className="mt-3 text-muted-foreground text-sm leading-relaxed">
          {member.bio}
        </p>
      )}

      {(member.area || member.note) && (
        <div className="mt-4 flex items-center justify-between border-border border-t pt-3 text-muted-foreground text-xs">
          <span>{member.area}</span>
          <span className="text-muted-foreground/70">{member.note}</span>
        </div>
      )}
    </article>
  );
}

/** Portrait cards that turn from grey to colour on hover and reveal call and
 *  email buttons. Four across without bios, three across with them. */
export default function Team5({
  columns = 4,
  content = defaultTeam5Content,
  showBio = false,
  surface = "default",
}: {
  columns?: 3 | 4;
  content?: TeamContent;
  showBio?: boolean;
  surface?: Surface;
}) {
  const sizes =
    columns === 3
      ? "(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
      : "(min-width: 1024px) 25vw, (min-width: 640px) 50vw, 100vw";

  return (
    <section className={cn("py-20 sm:py-28", surfaceClass[surface])}>
      <div className="mx-auto max-w-7xl px-5 sm:px-8 lg:px-12">
        <SectionHeader
          action={
            content.action && (
              <Link
                className="group inline-flex items-center gap-2 text-primary text-sm underline-offset-4 hover:underline"
                href={content.action.href}
              >
                {content.action.label}
                <ArrowUpRight className="size-4 transition-transform duration-300 group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
              </Link>
            )
          }
          className="border-border border-b pb-8"
          description={content.description}
          eyebrow={content.eyebrow}
          title={content.title}
          tone={toneFor(surface)}
        />

        <div
          className={cn(
            "mt-10 grid gap-6 sm:grid-cols-2",
            columns === 3 ? "lg:grid-cols-3" : "lg:grid-cols-4",
          )}
        >
          {content.members.map((member) => (
            <MemberCard
              key={member.name}
              member={member}
              showBio={showBio}
              sizes={sizes}
            />
          ))}
        </div>
      </div>
    </section>
  );
}
```

### `registry/default/sections/team-5/data.ts`

```ts
import type { TeamContent } from "../_types/team";

export const defaultTeam5Content: TeamContent = {
  action: {
    href: "/team",
    label: "The whole team",
  },
  eyebrow: "The people",
  members: [
    {
      area: "Harbour District",
      bio: "Nadia joined Marbrook from a listed-buildings practice and still reads a survey before she reads a brochure. She handles the majority of our private sales.",
      email: "nadia@marbrook.com",
      image: {
        alt: "Nadia Ellery",
        src: "https://res.cloudinary.com/dvwtcsh5v/image/upload/v1770279333/a1.jpg",
      },
      name: "Nadia Ellery",
      note: "$620M placed",
      phone: "+1 (800) 555-0111",
      role: "Principal, Private Sales",
    },
    {
      area: "Northgate Heights",
      bio: "Theo trained as an architect and spent six years on site before selling a single house. He is the person we send when a building is unusual.",
      email: "theo@marbrook.com",
      image: {
        alt: "Theo Marchetti",
        src: "https://res.cloudinary.com/dvwtcsh5v/image/upload/v1770279333/a2.jpg",
      },
      name: "Theo Marchetti",
      note: "$410M placed",
      phone: "+1 (800) 555-0122",
      role: "Director, Architectural Homes",
    },
    {
      area: "Marina Bay & Riverside",
      bio: "Imogen negotiates our developer instructions, from the first plot release to the last completion. She has placed eleven schemes across the bay.",
      email: "imogen@marbrook.com",
      image: {
        alt: "Imogen Bell",
        src: "https://res.cloudinary.com/dvwtcsh5v/image/upload/v1770279333/a3.jpg",
      },
      name: "Imogen Bell",
      note: "$285M placed",
      phone: "+1 (800) 555-0133",
      role: "Head of New Developments",
    },
    {
      area: "Old Town & Cedar Park",
      bio: "Rafael runs lettings and the managed portfolio. He knows every reliable roofer within thirty miles, which is worth more than it sounds.",
      email: "rafael@marbrook.com",
      image: {
        alt: "Rafael Okonjo",
        src: "https://res.cloudinary.com/dvwtcsh5v/image/upload/v1770279333/a4.jpg",
      },
      name: "Rafael Okonjo",
      note: "$140M placed",
      phone: "+1 (800) 555-0144",
      role: "Senior Advisor, Lettings",
    },
  ],
  title: "Advisors, not salespeople",
};
```

### `registry/default/sections/team-5/section-header.tsx`

```tsx
import type React from "react";

import { cn } from "@/lib/utils";

import type { Surface } from "../_types/common";

/** The small uppercase label that opens most headers. */
export const eyebrowClass =
  "inline-flex items-center gap-2 font-medium text-[0.6875rem] uppercase tracking-[0.22em]";

export const surfaceClass: Record<Surface, string> = {
  default: "bg-background text-foreground",
  muted: "bg-muted text-foreground",
  primary: "bg-primary text-primary-foreground",
  secondary: "bg-secondary text-secondary-foreground",
};

/**
 * Which band the header sits on. `photo` keeps literal white, because
 * `--primary-foreground` inverts in dark mode and would be unreadable over a
 * photograph.
 */
export type SectionTone = "light" | "photo" | "primary" | "secondary";

export function toneFor(surface: Surface): SectionTone {
  if (surface === "primary") return "primary";
  if (surface === "secondary") return "secondary";
  return "light";
}

const toneClasses: Record<
  SectionTone,
  { eyebrow: string; heading: string; lede: string; tick: string }
> = {
  light: {
    eyebrow: "text-muted-foreground/70",
    heading: "text-primary",
    lede: "text-muted-foreground",
    tick: "bg-secondary",
  },
  photo: {
    eyebrow: "text-white/50",
    heading: "text-white",
    lede: "text-white/65",
    tick: "bg-secondary",
  },
  primary: {
    eyebrow: "text-primary-foreground/40",
    heading: "text-primary-foreground",
    lede: "text-primary-foreground/60",
    tick: "bg-secondary",
  },
  secondary: {
    eyebrow: "text-secondary-foreground/50",
    heading: "text-secondary-foreground",
    lede: "text-secondary-foreground/70",
    tick: "bg-primary",
  },
};

export function SectionHeader({
  action,
  actionAt = "sm",
  className,
  description,
  eyebrow,
  size = "default",
  title,
  tone = "light",
}: {
  /** Rendered beside the heading block once `actionAt` is reached. */
  action?: React.ReactNode;
  /** A text link fits from `sm`; a row of filter tabs needs `lg`. */
  actionAt?: "lg" | "sm";
  className?: string;
  description?: React.ReactNode;
  eyebrow: string;
  /** `sm` is the narrow-sidebar size used by 4/8 splits. */
  size?: "default" | "sm";
  title: React.ReactNode;
  tone?: SectionTone;
}): React.ReactElement {
  const tones = toneClasses[tone];

  return (
    <div
      className={cn(
        action && "flex flex-col gap-6",
        action &&
          (actionAt === "lg"
            ? "gap-8 lg:flex-row lg:items-end lg:justify-between"
            : "sm:flex-row sm:items-end sm:justify-between"),
        className,
      )}
    >
      <div className={cn(action && "max-w-xl")}>
        <p className={cn(eyebrowClass, tones.eyebrow)}>
          <span className={cn("size-1", tones.tick)} />
          {eyebrow}
        </p>

        <h2
          className={cn(
            "mt-5 font-display",
            tones.heading,
            size === "sm"
              ? "text-3xl leading-[1.1] sm:text-4xl"
              : "text-4xl leading-[1.05] sm:text-5xl",
          )}
        >
          {title}
        </h2>

        {description && (
          <p className={cn("mt-4 max-w-xl leading-relaxed", tones.lede)}>
            {description}
          </p>
        )}
      </div>

      {action}
    </div>
  );
}
```

### `registry/default/sections/_types/common.ts`

```ts
/**
 * Shapes shared by every token-based section. Each section ships this file
 * alongside its own category types, so installing several sections simply
 * rewrites the same file.
 */

/** Which band a section sits on. A style choice, never part of `content`. */
export type Surface = "default" | "muted" | "primary" | "secondary";

export type ImageAsset = {
  alt: string;
  src: string;
};

export type LinkAction = {
  href: string;
  label: string;
};

/** Eyebrow + heading + optional lede, the header most sections open with. */
export type SectionIntro = {
  description?: string;
  eyebrow: string;
  title: string;
};

/** A figure that counts up when it scrolls into view. */
export type Stat = {
  decimals?: number;
  label: string;
  prefix?: string;
  suffix?: string;
  value: number;
};

/** A figure that is displayed as written ("1,240+", "4.9/5"). */
export type Highlight = {
  label: string;
  value: string;
};

export type FormField = {
  label: string;
  name: string;
  /** Only for `type: "select"`. The first option is the default. */
  options?: string[];
  placeholder?: string;
  required?: boolean;
  /** `full` spans both columns of a two-column form. */
  span?: "full" | "half";
  type?: "email" | "select" | "tel" | "text" | "textarea";
};

export type FormContent = {
  description?: string;
  fields: FormField[];
  note?: string;
  submitLabel: string;
  success: {
    description: string;
    resetLabel: string;
    title: string;
  };
  title: string;
};
```

### `registry/default/sections/_types/team.ts`

```ts
import type { ImageAsset, LinkAction, SectionIntro } from "./common";

export type TeamMember = {
  area?: string;
  bio?: string;
  email?: string;
  image: ImageAsset;
  name: string;
  /** Short right-aligned note, e.g. "$620M placed". */
  note?: string;
  phone?: string;
  role: string;
};

export type TeamContent = SectionIntro & {
  action?: LinkAction;
  members: TeamMember[];
};
```
