Team Mosaic
A team section built from a cluster of portrait tiles on a staggered grid of empty ones, annotated with collaborative-cursor role pills.
Live preview and source code for Team Mosaic
"use client"
import React from "react";
import { motion, useReducedMotion } from "framer-motion";
import { ImageIcon } from "lucide-react";
export interface TeamMosaicPerson {
/** Image source. Omitted or `null` renders an empty placeholder tile */
src?: string | null;
/** Who is in the photo. Becomes the image alt text; without it the tile is decorative */
name?: string;
}
export interface TeamMosaicLabel {
/** Text inside the pill */
name: string;
/** Where the tip of the cursor lands across the mosaic, in grid steps from its centre */
x?: number;
/** Where the tip of the cursor lands down the mosaic, in grid steps from its centre */
y?: number;
/** Which way the cursor points, and which way its pill runs from it */
side?: "left" | "right";
}
export interface TeamMosaicProps {
/** The six photo tiles around the centre, in reading order: the top pair, then
* the two either side of the middle tile, then the bottom pair */
people?: TeamMosaicPerson[];
/** Whatever sits in the middle tile — a logo, a monogram, an avatar */
brand?: React.ReactNode;
/** Cursor-and-pill annotations floating over the grid */
labels?: TeamMosaicLabel[];
/** Width of the empty lattice behind the photos, in tiles. Forced odd */
columns?: number;
/** Height of the empty lattice behind the photos, in tiles. Forced odd */
rows?: number;
/** Extra classes for consumers using a utility CSS framework */
className?: string;
}
/** Six empty slots, so the block renders its full cluster before any image exists. */
const PLACEHOLDER_PEOPLE: TeamMosaicPerson[] = Array.from({ length: 6 }, () => ({}));
/** Both tips sit just inside the photo they annotate, so the cursor reads as
* resting on the picture rather than floating beside it. */
const DEFAULT_LABELS: TeamMosaicLabel[] = [
{ name: "Admin", x: -1.04, y: -1, side: "left" },
{ name: "Content Reviewer", x: 0.96, y: 1.4, side: "right" },
];
/** The cursor, drawn once and stroked twice — a white rim under a filled copy.
* Both strokes use round joins, which is what takes the points off the corners:
* the outline of the shape is the path inflated by half a stroke width, so the
* path itself is drawn a little inside where the arrow is meant to land. */
const CURSOR_PATH = "M2.0 1.7 L14.6 7.6 L8.4 8.9 L7.0 14.8 Z";
/** Half the cluster, in tiles: it is three across and three down whatever size the
* lattice is, and the fade has to clear it. */
const CLUSTER_RADIUS = 1.5;
/** A tile of margin between the end of the cluster and the start of the fade. */
const FADE_CLEARANCE = 1;
/** The vertical fade starts earlier than the horizontal one, which is what makes
* the surviving grid an upright oval rather than a circle. */
const FADE_VERTICAL_BIAS = 0.78;
type Cell =
| { kind: "ghost"; hatched: boolean }
| { kind: "person"; person: TeamMosaicPerson; delay: number }
| { kind: "brand"; delay: number };
/** Always odd: an even lattice has no middle tile for the logo to sit in. */
function toOdd(value: number, fallback: number) {
const rounded = Math.round(value);
if (!Number.isFinite(rounded) || rounded < 3) return fallback;
return rounded % 2 === 0 ? rounded + 1 : rounded;
}
/**
* TeamMosaic — a cluster of seven portrait tiles on a staggered grid of empty
* ones, with collaborative cursors pointing into it. Renders standalone with
* zero props.
*/
export default function TeamMosaic({
people = PLACEHOLDER_PEOPLE,
brand,
labels = DEFAULT_LABELS,
columns = 7,
rows = 7,
className = "",
}: TeamMosaicProps) {
const reduceMotion = useReducedMotion();
const colCount = toOdd(columns, 7);
const rowCount = toOdd(rows, 7);
const halfRows = (rowCount - 1) / 2;
// Where the fade reaches full strength, as a percentage in from each edge.
// Derived from the counts rather than written down, so a shorter or wider
// lattice moves the plateau with it instead of dissolving the photographs.
const maskX = Math.max(0, 50 - (CLUSTER_RADIUS + FADE_CLEARANCE * 0.5) * (100 / colCount));
const maskY = Math.max(0, 50 - (CLUSTER_RADIUS + FADE_CLEARANCE * 0.5) * (100 / rowCount)) * FADE_VERTICAL_BIAS;
// Rows alternate between a full run of tiles and a run one shorter. Both are
// centred, so the short ones land half a tile off — the brick offset falls out
// of the centring rather than out of a transform.
const grid: Cell[][] = [];
let personIndex = 0;
for (let r = -halfRows; r <= halfRows; r++) {
const staggered = Math.abs(r) % 2 === 1;
const count = staggered ? colCount - 1 : colCount;
const row: Cell[] = [];
for (let k = 0; k < count; k++) {
const x = k - (count - 1) / 2;
// The cluster stays seven tiles wherever the lattice ends: three across the
// middle row, two on each of the rows that straddle it.
const filled = r === 0 ? Math.abs(x) <= 1 : Math.abs(r) === 1 && Math.abs(x) === 0.5;
if (filled && r === 0 && x === 0) {
row.push({ kind: "brand", delay: 0 });
} else if (filled) {
row.push({ kind: "person", person: people[personIndex] ?? {}, delay: Math.hypot(x, r) * 0.07 });
personIndex += 1;
} else {
row.push({
kind: "ghost",
// Checkerboard: hatching every tile turns the lattice into a grey block,
// and hatching none of them loses the texture entirely.
hatched: (((k + r) % 2) + 2) % 2 === 1,
});
}
}
grid.push(row);
}
return (
<section className={`tm-root ${className}`.trim()}>
<div className="tm-stage">
<div className="tm-lattice">
{/* The fade lives on this layer alone. It has to stay off the labels,
which reach past the grid and would dissolve with it. */}
<div
className="tm-grid"
style={{ "--tm-mask-x": `${maskX}%`, "--tm-mask-y": `${maskY}%` } as React.CSSProperties}
>
{grid.map((row, r) => (
<div className="tm-row" key={r}>
{row.map((cell, k) => {
if (cell.kind === "ghost") {
return (
<div
key={k}
className={`tm-tile tm-ghost${cell.hatched ? " tm-ghost-hatched" : ""}`}
aria-hidden="true"
/>
);
}
const isBrand = cell.kind === "brand";
return (
<motion.div
key={k}
className={`tm-tile tm-filled${isBrand ? " tm-brand" : ""}`}
initial={reduceMotion ? false : { opacity: 0, scale: 0.86 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5, delay: cell.delay, ease: [0.22, 1, 0.36, 1] }}
>
{isBrand ? (
brand ?? <TilePlaceholder />
) : cell.person.src ? (
<img
className="tm-img"
src={cell.person.src}
alt={cell.person.name ?? ""}
loading="lazy"
decoding="async"
/>
) : (
<TilePlaceholder />
)}
</motion.div>
);
})}
</div>
))}
</div>
{labels.map((label, i) => (
<motion.div
key={label.name}
className="tm-label"
data-side={label.side ?? "right"}
style={{ "--tm-lx": label.x ?? 0, "--tm-ly": label.y ?? 0 } as React.CSSProperties}
initial={reduceMotion ? false : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.45, delay: 0.5 + i * 0.1, ease: "easeOut" }}
>
{/* The cursor says what the pill already says — it points at the tile
the pill is naming — so it is decoration and the text carries it. */}
<svg className="tm-cursor" viewBox="-0.6 -0.6 20 20" aria-hidden="true" focusable="false">
<path
d={CURSOR_PATH}
fill="#ffffff"
stroke="#ffffff"
strokeWidth="4.8"
strokeLinejoin="round"
/>
<path
d={CURSOR_PATH}
fill="currentColor"
stroke="currentColor"
strokeWidth="2.6"
strokeLinejoin="round"
/>
</svg>
<span className="tm-pill">{label.name}</span>
</motion.div>
))}
</div>
</div>
<style>{`
.tm-root {
position: relative;
display: flex;
justify-content: center;
width: 100%;
overflow: hidden;
background: #ffffff;
color: #191919;
font-family: inherit;
/* Every length below is a share of this section's own width, so the
mosaic keeps its proportions in a narrow column as well as full bleed. */
container-type: inline-size;
}
.tm-stage {
/* The floor is deliberately low. A tile that stops shrinking pushes the
lattice wider than the section, and the edge of the grid is then a
hard cut instead of the fade the whole thing is built around. */
--tm-tile: clamp(38px, 11.5cqw, 100px);
--tm-gap: calc(var(--tm-tile) * 0.085);
--tm-pitch: calc(var(--tm-tile) + var(--tm-gap));
--tm-radius: calc(var(--tm-tile) * 0.265);
--tm-ink: #3d3a36;
/* One grey for the whole lattice: the dots and the hatch are the same
colour, and only the stroke widths tell them apart. */
--tm-grid-ink: rgba(25, 25, 25, 0.16);
/* The two halves of the bevel. Both run corner to corner and vanish in
between, and they use opposite diagonals, so together they light the
top-left and bottom-right and shade the other two. */
--tm-sheen: linear-gradient(
135deg,
rgba(255, 255, 255, 0.95) 0%,
rgba(255, 255, 255, 0.3) 22%,
rgba(255, 255, 255, 0) 44%,
rgba(255, 255, 255, 0) 56%,
rgba(255, 255, 255, 0.32) 78%,
rgba(255, 255, 255, 0.9) 100%
);
--tm-shade: linear-gradient(
45deg,
rgba(41, 39, 36, 0.5) 0%,
rgba(41, 39, 36, 0.15) 22%,
rgba(41, 39, 36, 0) 44%,
rgba(41, 39, 36, 0) 56%,
rgba(41, 39, 36, 0.16) 78%,
rgba(41, 39, 36, 0.45) 100%
);
display: flex;
flex: 1 1 auto;
align-items: center;
justify-content: center;
box-sizing: border-box;
width: 100%;
padding: clamp(18px, 3cqw, 48px);
}
.tm-lattice {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
}
/* Faded on all four sides at once: two gradients intersected, so the
lattice dissolves into the page along every edge and fades twice over
in the corners. The plateau clears the photo cluster in the middle,
which is why the pictures are never touched by it. */
.tm-grid {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--tm-gap);
-webkit-mask-image:
linear-gradient(to right, transparent 0%, #000 var(--tm-mask-x), #000 calc(100% - var(--tm-mask-x)), transparent 100%),
linear-gradient(to bottom, transparent 0%, #000 var(--tm-mask-y), #000 calc(100% - var(--tm-mask-y)), transparent 100%);
-webkit-mask-composite: source-in;
mask-image:
linear-gradient(to right, transparent 0%, #000 var(--tm-mask-x), #000 calc(100% - var(--tm-mask-x)), transparent 100%),
linear-gradient(to bottom, transparent 0%, #000 var(--tm-mask-y), #000 calc(100% - var(--tm-mask-y)), transparent 100%);
mask-composite: intersect;
}
.tm-row {
display: flex;
gap: var(--tm-gap);
}
.tm-tile {
box-sizing: border-box;
width: var(--tm-tile);
height: var(--tm-tile);
border-radius: var(--tm-radius);
}
.tm-ghost {
border: 2px dotted var(--tm-grid-ink);
}
/* Hatch inside a dotted outline: the fill is what stops an empty tile
reading as a hole, and the angle is what stops it reading as a button. */
.tm-ghost-hatched {
background-image: repeating-linear-gradient(
-45deg,
var(--tm-grid-ink) 0,
var(--tm-grid-ink) 1px,
transparent 1px,
transparent 7px
);
}
.tm-filled {
position: relative;
overflow: hidden;
background: #f2f2f0;
/* The hairline is a spread shadow rather than a border, so it sits
outside the crop and never eats a pixel of the photograph. */
box-shadow:
0 0 0 1px rgba(25, 25, 25, 0.07),
0 3px 12px rgba(25, 25, 25, 0.07);
}
/* The metallic edge: a one-pixel ring just inside the crop, lit white at
two opposite corners and gone at the other two, so it reads as a
bevel catching the light rather than as a border. Drawn as a gradient
with its own middle masked out — a border cannot hold a gradient. */
.tm-filled::after {
content: "";
position: absolute;
inset: 1px;
box-sizing: border-box;
padding: 1px;
border-radius: calc(var(--tm-radius) - 1px);
background: var(--tm-sheen);
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
mask-composite: exclude;
pointer-events: none;
}
.tm-brand {
display: flex;
align-items: center;
justify-content: center;
background: #e7e7e7;
/* No hairline and no drop shadow here. Both exist to lift a photograph
off the page, and the middle tile is already a grey block with a full
bevel of its own — over it they only muddy the edge. */
box-shadow: none;
}
/* White alone disappears against the middle tile, so it gets the other
half of the bevel too — the same corner-to-corner ramp on the opposite
diagonal, filling in the two corners the sheen leaves empty. */
.tm-brand::after {
background: var(--tm-shade), var(--tm-sheen);
}
.tm-img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.tm-placeholder {
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
width: 100%;
height: 100%;
background: linear-gradient(160deg, #f5f5f3 0%, #e9e9e6 100%);
border-radius: inherit;
}
/* The middle tile reads as the odd one out in the design, so its empty
state is lighter than a photo's rather than identical to it. */
.tm-brand .tm-placeholder {
background: #e7e7e7;
}
.tm-placeholder-icon {
width: 26%;
height: 26%;
color: #b9b9b3;
}
.tm-brand .tm-placeholder-icon {
color: #d2d2cc;
}
/* Anchored off the centre of the lattice in grid steps, so a cursor keeps
resting on the same tile at every size. A left-side one is pinned by its
right edge rather than shifted with a transform — the entrance animates
transform, and would overwrite the offset. */
.tm-label {
position: absolute;
top: calc(50% + var(--tm-ly) * var(--tm-pitch));
display: flex;
flex-direction: column;
color: var(--tm-ink);
pointer-events: none;
}
.tm-label[data-side="left"] {
right: calc(50% - var(--tm-lx) * var(--tm-pitch));
align-items: flex-end;
}
.tm-label[data-side="right"] {
left: calc(50% + var(--tm-lx) * var(--tm-pitch));
align-items: flex-start;
}
.tm-cursor {
display: block;
width: max(14px, calc(var(--tm-tile) * 0.21));
height: auto;
/* The white outline is what keeps the cursor legible over a photograph,
and it is why the arrow does not need a shadow. */
filter: drop-shadow(0 1px 2px rgba(25, 25, 25, 0.18));
}
.tm-label[data-side="left"] .tm-cursor {
transform: scaleX(-1);
}
.tm-pill {
/* Tucked under the tail of the cursor, close enough that the two read as
one object — a cursor wearing a name, not an arrow beside a chip. */
margin: min(-4px, calc(var(--tm-tile) * -0.07)) max(6px, calc(var(--tm-tile) * 0.12)) 0;
padding: max(5px, calc(var(--tm-tile) * 0.094)) max(10px, calc(var(--tm-tile) * 0.188));
font-size: max(10px, calc(var(--tm-tile) * 0.156));
font-weight: 600;
line-height: 1.2;
letter-spacing: -0.01em;
white-space: nowrap;
color: #ffffff;
background: var(--tm-ink);
border-radius: 9999px;
box-shadow: 0 4px 14px rgba(25, 25, 25, 0.16);
}
.tm-label[data-side="left"] .tm-pill {
margin-left: 0;
}
.tm-label[data-side="right"] .tm-pill {
margin-right: 0;
}
`}</style>
</section>
);
}
/** The empty state of one tile — used for every photo with no source yet, and for
* the middle tile until something is handed to `brand`. */
function TilePlaceholder() {
return (
<span className="tm-placeholder">
<ImageIcon className="tm-placeholder-icon" strokeWidth={1.5} aria-hidden="true" />
</span>
);
}






A team section that shows the team rather than listing it: seven rounded tiles clustered in the middle of a staggered grid of empty ones, with two collaborative cursors leaning on the photographs and naming their roles. The empty tiles are the point — the grid dissolves into the page on all four sides instead of ending on a border, so the group reads as part of something larger rather than as everyone there is. Each photograph carries a metallic bevel: a hairline edge and a white ring just inside it, lit at two corners and gone at the other two. It ships with placeholder tiles, so the section is finished before the photographs are.
Installation
Copy the source from the Code tab above into your project as TeamMosaic.tsx.
Its styles are a <style> block of tm- prefixed classes that travels with the
file, so there is no stylesheet to import and no Tailwind config to change.
It imports framer-motion for the entrance and lucide-react for the
placeholder icon:
npm install framer-motion lucide-reactThe file starts with "use client" — it uses a hook, so in the Next.js app
router it has to stay a client component.
Usage
Every prop is optional. people takes the six photo tiles around the middle in
reading order — the top pair, then the two either side of the centre, then the
bottom pair — and the middle tile is a slot of its own:
import TeamMosaic from "./components/TeamMosaic";
export default function Example() {
return (
<TeamMosaic
people={[
{ src: "/team/ada.jpg", name: "Ada" },
{ src: "/team/rae.jpg", name: "Rae" },
{ src: "/team/jun.jpg", name: "Jun" },
{ src: "/team/omar.jpg", name: "Omar" },
{ src: "/team/lena.jpg", name: "Lena" },
{ src: "/team/kit.jpg", name: "Kit" },
]}
brand={<img src="/logo.svg" alt="" width={40} height={40} />}
labels={[{ name: "Design", x: -1.04, y: -1, side: "left" }]}
/>
);
}Any entry without a src renders the grey placeholder tile, so the grid can be
filled in as the pictures arrive.
x and y place the tip of a cursor, in grid steps out from the centre of
the mosaic: x: 1 is one tile to the right, and the two defaults sit the tip
against the edge of the photograph each one names, pointing into it. Because the
unit is a grid step and not a pixel, a cursor stays on the same face as the
section resizes. side says which
way the cursor points and which way its pill runs from it — left points up and
to the right with the pill trailing left, right is the mirror.
columns and rows size the empty lattice behind the cluster, not the cluster
itself: the seven photo tiles stay seven whatever you pass, and a bigger lattice
buys more faded grid around them. Both are forced odd, because an even one has
no middle tile for brand to sit in.
When to reach for it
An about page that has to say there are real people here before it says anything else. Six faces and a logo do that in one glance; six names and job titles need a paragraph.
The team teaser on a homepage, where the actual team page is a click away and this section only has to be inviting enough to earn the click.
Anything with a small set of pictures and no natural order to them — a client wall, a set of case-study thumbnails, a community section. Nothing in the component knows the tiles are people; the pills are just text.
When not to
If the names matter. There is nowhere to put one. This is a portrait wall, not a directory: a team page where each person needs a name, a role and a link wants a plain grid of cards, where the text sits under the photo and can wrap.
If the group is more than six, or fewer than about four. The cluster is a fixed shape and does not reflow. Extra people have nowhere to go, and gaps in it read as tiles that failed to load rather than as a deliberate arrangement.
If the photographs are inconsistent. Every tile is a hard square crop at the same size, so mixed backgrounds, mixed crops and mixed lighting are all visible at once. The design leans on the photos matching.
Accessibility
Every empty tile is aria-hidden, so the lattice is skipped entirely and a
screen reader gets only the photographs and the pills. A photo with a name
gets it as alt text; one without gets alt="" and is skipped too, which is
the right default while the tiles are still placeholders.
The pills stay readable — they are short role labels and they are the only words
in the section. The cursors themselves are aria-hidden: an arrow pointing at a
tile the pill already names is decoration.
Nothing here is focusable or clickable, and the labels are pointer-events: none, so they never sit between a cursor and something underneath them.
The entrance is gated on prefers-reduced-motion through framer-motion’s
useReducedMotion: with the setting on, the tiles and the pills start at their
final values instead of scaling and fading in.
Notes
The brick offset is not a transform. Alternating rows hold one tile fewer and every row is centred, so the short rows land half a tile off on their own — which is why the cluster stays centred no matter how wide the lattice is.
The fade is two gradient masks — one across, one down — combined with
mask-composite: intersect, which is what makes the corners fade twice and the
whole grid dissolve rather than stop. It sits on the grid layer alone: the
cursors reach past the tiles and would have dissolved with them, and the
photographs sit inside the plateau where both gradients are still solid. That
plateau is computed from columns and rows rather than written down, so a
wider or shorter lattice moves the fade with it instead of eating a photograph.
The metallic edge is a gradient, not a border, because a border cannot hold one.
It is a one-pixel inset ring whose own middle is masked out, filled with a 135°
white gradient that is bright at the top-left and bottom-right and transparent
in between — the two lit corners are what make it read as a bevel catching the
light rather than as an outline. The hairline outside it is a spread box-shadow
rather than a real border, so it never eats a pixel of the crop.
The middle tile is the one place that needs the other half of it. It sits on a
grey rather than under a photograph, and white alone disappears there, so its
ring carries a second gradient on the opposite diagonal: the same corner to
corner ramp in a dark grey, filling the two corners the sheen leaves empty. Hand
it a brand on a dark ground and that pairing is what you would adjust first.
It also drops the hairline and the drop shadow: both are there to lift a
photograph off the page, and over a grey block they only muddy the edge.
Sizes are set in container query units, so the mosaic scales from the width of the section rather than the window and holds its proportions inside a narrow column. The tile has a low floor on purpose: one that stopped shrinking would push the lattice wider than the section, and the edge of the grid would then be a hard cut instead of the fade the whole design rests on. The pill text and the cursors do have pixel floors, so they stay legible after the tiles have shrunk past the point where a proportional size would not be.