Team Mosaic

( Team / Gallery / About / Marketing )

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

Admin
Content Reviewer

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:

Terminal
npm install framer-motion lucide-react

The 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:

Example.tsx
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.