bottega

sections

Team Grid

A team / people section with an optional eyebrow label, heading, and subtext, followed by a responsive grid of member cards (4-up → 2-up → 1-up). Each card shows a circular avatar disc with the member's initials on a token-driven --primary-tinted --muted gradient (no external images), their name, role, an optional one-line bio, and a small row of icon-only social links (Twitter/X, LinkedIn, GitHub, Website — inline SVG, currentColor). The header block fades and slides up on scroll entry; cards stagger in from below 80ms apart. On hover each card lifts 5px via Motion whileHover and its border transitions to --primary via CSS. The avatar disc breathes with a gentle outer glow pulse on a 4s cadence. All four social link types carry aria-label; focus-visible rings use --ring on every interactive element.

The People

Built by a team that cares about craft

We bring together strategy, design, and engineering under one roof — so nothing gets lost in translation.

Mara Osei

Creative Director

Shapes brand systems that move between cultures without losing clarity.

Julien Voss

Lead Engineer

Builds tools that feel inevitable in hindsight.

Aisha Nkosi

Head of Strategy

Turns market confusion into a clear path and a sharp brief.

Tomás Reyes

Design Engineer

Lives in the gap between a design file and a shipped interaction.


Install

1. Register the namespace (once per project):

json
// components.json — register the @bottega namespace once
{
  "registries": {
    "@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
  }
}

2. Add the component:

bash
npx shadcn add @bottega/team-grid

Usage

Usagetsx
<TeamGrid />

Props

PropTypeDefaultDescription
eyebrowstringThe PeopleSmall label rendered above the heading (e.g. "The Team").
headingstringBuilt by a team that cares about craft
subtextstringWe bring together strategy, design, and engineering under one roof — so nothing gets lost in translation.
membersTeamMember[][ { name: "Mara Osei", role: "Creative Director", initials: "MO", bio: "Shapes brand systems that move between cultures without losing clarity.", socials: [ { type: "twitter", href: "https://x.com" }, { type: "linkedin", href: "https://linkedin.com" }, ], }, { name: "Julien Voss", role: "Lead Engineer", initials: "JV", bio: "Builds tools that feel inevitable in hindsight.", socials: [ { type: "github", href: "https://github.com" }, { type: "linkedin", href: "https://linkedin.com" }, ], }, { name: "Aisha Nkosi", role: "Head of Strategy", initials: "AN", bio: "Turns market confusion into a clear path and a sharp brief.", socials: [ { type: "twitter", href: "https://x.com" }, { type: "website", href: "https://example.com" }, ], }, { name: "Tomás Reyes", role: "Design Engineer", initials: "TR", bio: "Lives in the gap between a design file and a shipped interaction.", socials: [ { type: "github", href: "https://github.com" }, { type: "twitter", href: "https://x.com" }, ], }, ]Member data. Defaults to four built-in demo members.
classNamestringExtra class names merged onto the outer <section>.

Source

team-grid.tsxtsx
"use client";

import { useEffect, useRef, useState } from "react";
import { motion, useInView, useReducedMotion } from "motion/react";
import styles from "./team-grid.module.css";

// Motion JS timings — SECONDS, not the --duration CSS token (≈300ms).
const EASE = [0.16, 1, 0.3, 1] as const;
const CARD_DURATION = 0.45;
const HEADER_DELAY = 0.15; // cards stagger in after header starts revealing
const STAGGER_GAP = 0.08; // 80ms between each card

type SocialType = "twitter" | "linkedin" | "github" | "website";

const PLATFORM_LABELS: Record<SocialType, string> = {
  twitter: "Twitter / X",
  linkedin: "LinkedIn",
  github: "GitHub",
  website: "Website",
};

export interface TeamMemberSocial {
  type: SocialType;
  href: string;
  /** Accessible label; defaults to the platform name. */
  label?: string;
}

export interface TeamMember {
  name: string;
  role: string;
  /** Two-letter initials displayed in the circular avatar disc. */
  initials: string;
  /** Optional one-line bio shown below the role. */
  bio?: string;
  socials?: TeamMemberSocial[];
}

export interface TeamGridProps {
  /** Small label rendered above the heading (e.g. "The Team"). */
  eyebrow?: string;
  heading?: string;
  subtext?: string;
  /** Member data. Defaults to four built-in demo members. */
  members?: TeamMember[];
  /** Extra class names merged onto the outer <section>. */
  className?: string;
}

const DEFAULT_MEMBERS: TeamMember[] = [
  {
    name: "Mara Osei",
    role: "Creative Director",
    initials: "MO",
    bio: "Shapes brand systems that move between cultures without losing clarity.",
    socials: [
      { type: "twitter", href: "https://x.com" },
      { type: "linkedin", href: "https://linkedin.com" },
    ],
  },
  {
    name: "Julien Voss",
    role: "Lead Engineer",
    initials: "JV",
    bio: "Builds tools that feel inevitable in hindsight.",
    socials: [
      { type: "github", href: "https://github.com" },
      { type: "linkedin", href: "https://linkedin.com" },
    ],
  },
  {
    name: "Aisha Nkosi",
    role: "Head of Strategy",
    initials: "AN",
    bio: "Turns market confusion into a clear path and a sharp brief.",
    socials: [
      { type: "twitter", href: "https://x.com" },
      { type: "website", href: "https://example.com" },
    ],
  },
  {
    name: "Tomás Reyes",
    role: "Design Engineer",
    initials: "TR",
    bio: "Lives in the gap between a design file and a shipped interaction.",
    socials: [
      { type: "github", href: "https://github.com" },
      { type: "twitter", href: "https://x.com" },
    ],
  },
];

function cx(...parts: (string | false | undefined)[]): string {
  return parts.filter(Boolean).join(" ");
}

// Inline SVG icons — aria-hidden; accessible label lives on the parent <a>.
function SocialIcon({ type }: { type: SocialType }) {
  const shared = { "aria-hidden": true as const, width: 16, height: 16 };

  if (type === "github") {
    return (
      <svg {...shared} viewBox="0 0 16 16" fill="currentColor">
        <path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
      </svg>
    );
  }

  if (type === "twitter") {
    return (
      <svg {...shared} viewBox="0 0 24 24" fill="currentColor">
        <path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-4.714-6.231-5.401 6.231H2.748l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
      </svg>
    );
  }

  if (type === "linkedin") {
    return (
      <svg {...shared} viewBox="0 0 24 24" fill="currentColor">
        <path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" />
      </svg>
    );
  }

  // website / globe
  return (
    <svg
      {...shared}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="2"
      strokeLinecap="round"
      strokeLinejoin="round"
    >
      <circle cx="12" cy="12" r="10" />
      <path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
    </svg>
  );
}

export function TeamGrid({
  eyebrow = "The People",
  heading = "Built by a team that cares about craft",
  subtext =
    "We bring together strategy, design, and engineering under one roof — so nothing gets lost in translation.",
  members = DEFAULT_MEMBERS,
  className,
}: TeamGridProps) {
  const sectionRef = useRef<HTMLElement>(null);

  // Mount-gate + null-safe reduced-motion (Bottega contract, avoids React #418).
  // Server + first client render are byte-identical (static); animation turns
  // on only after hydration for users who haven't asked for reduced motion.
  const [mounted, setMounted] = useState(false);
  useEffect(() => {
    setMounted(true);
  }, []);
  const prefersReducedMotion = useReducedMotion();
  const reduce = prefersReducedMotion === null || prefersReducedMotion;
  const animate = mounted && !reduce;

  const inView = useInView(sectionRef, { once: true, amount: 0.15 });
  const show = animate && inView;

  return (
    <section
      ref={sectionRef}
      className={cx(
        "relative w-full py-16 sm:py-24",
        "bg-background text-foreground",
        className,
      )}
    >
      <div className="mx-auto max-w-6xl px-4 sm:px-6 lg:px-8">
        {/* Header block — fades + slides up as a unit on scroll entry. */}
        <motion.div
          className="mx-auto max-w-2xl text-center"
          initial={animate ? { y: 20, opacity: 0 } : false}
          animate={
            show ? { y: 0, opacity: 1 } : animate ? { y: 20, opacity: 0 } : false
          }
          transition={{ duration: 0.5, ease: EASE }}
        >
          {eyebrow && (
            <p className="mb-3 text-xs font-semibold uppercase tracking-[0.18em] text-primary">
              {eyebrow}
            </p>
          )}
          {heading && (
            <h2 className="text-3xl font-bold tracking-tight text-foreground sm:text-4xl">
              {heading}
            </h2>
          )}
          {subtext && (
            <p
              className="mt-4 text-base leading-relaxed"
              style={{
                color: "color-mix(in oklch, var(--foreground) 65%, var(--background))",
              }}
            >
              {subtext}
            </p>
          )}
        </motion.div>

        {/* Card grid: 1-up → 2-up → 4-up. Each card staggered in from below.
            whileHover lift is gated under animate so it never clashes with
            CSS transforms on reduced-motion users. */}
        <div className="mt-12 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
          {members.map((member, i) => (
            <motion.article
              key={member.name}
              initial={animate ? { y: 28, opacity: 0 } : false}
              animate={
                show
                  ? { y: 0, opacity: 1 }
                  : animate
                    ? { y: 28, opacity: 0 }
                    : false
              }
              transition={{
                duration: CARD_DURATION,
                ease: EASE,
                delay: show ? HEADER_DELAY + i * STAGGER_GAP : 0,
              }}
              // Hover lift via Motion only — no CSS transform utility on this
              // element so there is no transform collision with motion style.
              whileHover={
                animate
                  ? { y: -5, transition: { duration: 0.22, ease: EASE } }
                  : undefined
              }
              className={cx(
                "relative flex flex-col items-center gap-4 text-center",
                "rounded-[var(--radius)] border border-border bg-background",
                "px-5 py-7",
                // Border emphasis via CSS color transition — not a transform,
                // so no conflict with Motion's y-axis control above.
                "transition-[border-color] duration-[var(--duration)] ease-[var(--ease)]",
                "hover:border-primary",
              )}
            >
              {/* Circular avatar disc — initials on a token-driven gradient.
                  Gradient + subtle outer glow pulse are in the CSS module. */}
              <div
                aria-hidden="true"
                className={cx(
                  "flex h-16 w-16 items-center justify-center",
                  "rounded-full",
                  "text-lg font-bold tracking-wide text-foreground",
                  styles.avatar,
                )}
              >
                {member.initials}
              </div>

              {/* Name + role */}
              <div className="space-y-0.5">
                <p className="text-sm font-semibold text-foreground">{member.name}</p>
                <p
                  className="text-xs font-medium uppercase tracking-wide"
                  style={{
                    color: "color-mix(in oklch, var(--foreground) 55%, var(--background))",
                  }}
                >
                  {member.role}
                </p>
              </div>

              {/* Optional one-line bio */}
              {member.bio && (
                <p
                  className="text-xs leading-relaxed"
                  style={{
                    color: "color-mix(in oklch, var(--foreground) 65%, var(--background))",
                  }}
                >
                  {member.bio}
                </p>
              )}

              {/* Social links — icon-only, aria-label on each <a>.
                  Opacity on the inner <span> preserves full-opacity focus ring on <a>. */}
              {member.socials && member.socials.length > 0 && (
                <div className="mt-auto flex items-center justify-center gap-3 pt-1">
                  {member.socials.map((social) => (
                    <a
                      key={social.type + social.href}
                      href={social.href}
                      target="_blank"
                      rel="noopener noreferrer"
                      aria-label={social.label ?? PLATFORM_LABELS[social.type]}
                      className={cx(
                        "group rounded-sm",
                        "text-foreground hover:text-primary",
                        "transition-colors duration-[var(--duration)] ease-[var(--ease)]",
                        "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
                        "focus-visible:ring-offset-2 focus-visible:ring-offset-background",
                      )}
                    >
                      <span className="block opacity-50 transition-opacity duration-[var(--duration)] ease-[var(--ease)] group-hover:opacity-100">
                        <SocialIcon type={social.type} />
                      </span>
                    </a>
                  ))}
                </div>
              )}
            </motion.article>
          ))}
        </div>
      </div>
    </section>
  );
}

Dependencies

  • motion
  • @bottega/tokens