Team Grid
Real people with their photos or monograms, names and roles, as portraits or a compact list.
npx shadcn@latest add @vibebox/team-gridControls
<TeamGrid />About
A new section type for Launchpad (the site kit had none). Honest by construction: it never shows stock faces; monograms and fill-ins stand in until real people are named.
Use it when
- Customers choose the person as much as the business (clinics, salons, firms).
Skip it when
- The team is one person: use
about-story.
Install
- 1Add the VibeBox registry to
components.json:{ "registries": { "@vibebox": "https://motion.vibeboxph.com/r/{name}.json" } } - 2Add the block with the shadcn CLI:
npx shadcn@latest add @vibebox/team-grid - 3Use it:
import { TeamGrid } from "@/components/vibebox/team-grid"; import { home } from "@/content/pages/home"; <TeamGrid content={home.team} variant="portraits" id="team" />
Code
2 filescomponents/vibebox/team-grid/index.tsx
/**
* VibeBox Motion · Team Grid
* Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
* https://motion.vibeboxph.com/license
* Use it in unlimited personal, commercial and client projects.
* Do not sell or redistribute it as a component, kit, template or builder.
*/
import { Container, headingId, Icon, initials, Media, REVEAL, Section, SectionHead, SlotText, type BlockProps } from "@/lib/vibebox/block-kit";
import { isFillIn } from "@/lib/vibebox/content/runtime";
import { cn } from "@/lib/utils";
import type { Content, Variant } from "./schema";
/*
* The real people behind the business: photos when given, otherwise a monogram from the name
* (or a neutral mark while the name is still a fill-in). Names and roles are facts.
*/
type Member = Content["members"][number];
function Portrait({ member, round }: { member: Member; round?: boolean }) {
if (member.photo) return <Media image={member.photo} sizes="(min-width: 1024px) 25vw, 50vw" />;
const shape = cn("grid size-full place-items-center bg-[linear-gradient(150deg,color-mix(in_oklab,var(--spectrum-2)_22%,var(--card)),color-mix(in_oklab,var(--spectrum-5)_16%,var(--card)))]", round && "rounded-full");
return (
<div aria-hidden="true" className={shape}>
{isFillIn(member.name) ? (
<Icon name="users" className="size-1/4 text-muted-foreground" />
) : (
<span className="font-display text-[clamp(1rem,4vw,2.5rem)] font-semibold tracking-tight text-foreground/80">{initials(member.name)}</span>
)}
</div>
);
}
export type TeamGridProps = BlockProps<Content, Variant>;
export function TeamGrid({ content, variant = "portraits", id, tone, className }: TeamGridProps) {
const compact = variant === "compact";
return (
<Section slug="team-grid" variant={variant} id={id} tone={tone} labelledBy={headingId(id)} className={className}>
<Container className="grid gap-[clamp(32px,4vw,56px)]">
<SectionHead eyebrow={content.eyebrow} heading={content.heading} lead={content.lead} id={id} />
<ul className={cn("grid", compact ? "gap-x-8 gap-y-6 sm:grid-cols-2 lg:grid-cols-3" : "grid-cols-2 gap-x-4 gap-y-8 sm:gap-x-6 lg:grid-cols-4")}>
{content.members.map((member, index) => (
<li key={index} className={cn(compact ? "flex min-w-0 items-center gap-4" : "grid content-start gap-3", REVEAL)}>
<div className={cn("shrink-0 overflow-hidden ring-1 ring-border", compact ? "size-16 rounded-full" : "aspect-[4/5] rounded-xl")}>
<Portrait member={member} round={compact} />
</div>
<div className="grid min-w-0 gap-0.5">
<SlotText as="h3" value={member.name} path={`members.${index}.name`} className="font-semibold tracking-tight" />
<SlotText as="p" value={member.role} path={`members.${index}.role`} className="text-small text-muted-foreground" />
</div>
</li>
))}
</ul>
</Container>
</Section>
);
}components/vibebox/team-grid/schema.ts
/**
* VibeBox Motion · Team Grid
* Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
* https://motion.vibeboxph.com/license
* Use it in unlimited personal, commercial and client projects.
* Do not sell or redistribute it as a component, kit, template or builder.
*/
import { sections, type z } from "@/lib/vibebox/content";
export const variants = ["portraits", "compact"] as const;
export type Variant = (typeof variants)[number];
export const content = sections.team;
export type Content = z.infer<typeof content>;Content
Typed slots from schema.ts. Facts (hours, prices, quotes, names) come only from the business; until then they render as fill-ins.
| Slot | Label | Type | Limit | Fact |
|---|---|---|---|---|
| eyebrow | Kicker | text | 32 chars | — |
| heading | Heading | text | 60 chars | — |
| lead | Intro | text | 160 chars | — |
| members | People | list | up to 12 | — |
| members[].name | Name | fact | 48 chars | Yes, a fill-in until given |
| members[].role | Role | fact | 48 chars | Yes, a fill-in until given |
| members[].photo | Photo | image | — | — |
Variants
Portraitsdefault
portraits
Two columns on phones, four on desktop, 4:5 portraits.
Compact
compact
Round avatars beside names, three columns on desktop.
Accessibility and motion
- Keyboard
- Nothing to focus.
- Screen readers
- A heading and a list of people, each name a heading with the role after it; monograms are hidden.
- Touch
- Nothing to touch.
- Reduced motion
- Portraits appear without their reveal.
Edge cases and limits
- Twelve people make three portrait rows.
- A name still a fill-in shows a neutral mark, never initials of the hint.
Related

Duotone Team
ProPortraits in a two-tone wash of the brand's spectrum that bloom into full colour on hover or focus.
Team block · Server

About Story
FreeThe business in its own words: a tall photo with a caption beside the story and a few honest details.
About block · Server

Bento Hero
FreeThe promise beside a three-tile mosaic: a tall photo, a second image and a card of what the business offers.
Heroes block · Server