Anatomy
Twitter's image layout changes with the number of images attached to a post.
One image fills the frame. Two images sit side by side. Three images place one large image on the left with two stacked images on the right. Four images form a two by two grid.
I like this pattern because the component does not need many moving parts. One value, totalImages, drives the layout. It decides the grid columns, cell spans, rounded corners, and aspect ratios, which keeps the output consistent across each image count.
That is what makes this component interesting. The end result looks adaptive, but the rules behind it are small enough to hold in your head. You do not need a large layout engine. You need a few decisions that stay consistent across each image count.
The challenge is not raw complexity. It is making the grid feel intentional in every case. A single image should feel spacious. Two images should feel balanced. Three images need hierarchy. Four images should feel compact without becoming cramped. Those are different layouts, but they still need to feel like they belong to the same system.
One image
With one image, the layout is simple. The image fills the container and keeps its own shape.
Source images can be wide, square, or tall, so the component maps them to a small set of common aspect ratios.
export const getAspectRatio = (height: number, width: number): string => {
const aspectRatio = width / height;
if (aspectRatio >= 2) return "2 / 1";
if (aspectRatio >= 1.77 && aspectRatio < 2) return "16 / 9";
if (aspectRatio === 1) return "1 / 1";
if (aspectRatio >= 0.75 && aspectRatio < 1) return "3 / 4";
return "16 / 9";
};aspectRatio comes from width / height. The helper compares that value to a few thresholds and returns the nearest standard ratio. This path only runs for the one-image case. The other layouts use fixed ratios so the grid stays predictable.
I prefer snapping the image to a small set of ratios instead of using the exact source dimensions. If you follow every uploaded size too closely, the layout starts to feel unstable from post to post. Standard ratios keep the card recognizable while still respecting the general shape of the image.
The one-image case gets more freedom because there is no neighboring image to line up with. Once you add a second cell, consistency matters more than fidelity to the original file.
Two images
Two images use a two-column grid.
The grid should still read as one card. The outer edges get rounded corners. The inner edge stays square, so the pair feels like one surface instead of two separate images.
That outer-versus-inner corner treatment does a lot of work. Without it, the layout starts to read like two cards placed next to each other. Twitter's grid works because it treats the whole attachment set as one object.
export const imageGrid = (index: number, totalImages: number) => {
let roundedClass = "";
if (totalImages === 1) {
roundedClass = "rounded-xl";
} else if (totalImages === 2) {
roundedClass =
index === 0
? "rounded-tl-xl rounded-bl-xl"
: "rounded-tr-xl rounded-br-xl";
} else if (totalImages === 3) {
roundedClass =
index === 0
? "rounded-tl-xl rounded-bl-xl col-span-1 row-span-2"
: index === 1
? "rounded-tr-xl col-span-1"
: "rounded-br-xl col-span-1";
} else if (totalImages === 4) {
roundedClass =
index === 0
? "rounded-tl-xl"
: index === 1
? "rounded-tr-xl"
: index === totalImages - 1
? "rounded-br-xl"
: "rounded-bl-xl";
}
return ` ${roundedClass}`;
};index is the image position in the array. totalImages picks the grid shape. Together they decide which corners sit on the outer edge and need rounding. For three images, imageGrid also carries the span classes for the first cell, which keeps the position rules in one place.
I like tying corner logic to index and totalImages because it keeps the render function dumb. The JSX does not need to know why a cell gets a certain shape. It only needs to ask for the right classes.
Three images
Three images need a clear anchor. The first image spans the full height of the grid while the other two stack on the right.
imageSpan handles cell placement.
export const imageSpan = (index: number, totalImages: number) => {
let span = "";
if (totalImages === 1) {
span = "col-span-2 row-span-1";
} else if (totalImages === 2) {
span = "col-span-1 row-span-1";
} else if (totalImages === 3) {
span = index === 0 ? "col-span-1 row-span-2" : "col-span-1 row-span-1";
} else if (totalImages === 4) {
span = "col-span-1 row-span-1";
}
return ` ${span}`;
};col-span-1 row-span-2 stretches the first cell across both row tracks, so it becomes the anchor. In the three-image layout, that span already lives in imageGrid, so imageSpan ends up doing more work in the one and four-image cases.
This layout is the one that gives the component its character. A plain three-column or stacked layout would work, but it would not create the same sense of priority. The large image on the left gives the grid a focal point, which helps the whole card feel composed instead of auto-generated.
It also solves a practical problem. Three equal cells tend to create awkward cropping because there is no obvious dominant frame. Giving one image more space makes the result feel less cramped.
Four images
Four images use a two by two grid. Each cell carries the same weight, so no image takes priority.
gridClasses maps the image count to the right Tailwind grid classes.
export const gridClasses = (totalImages: number) => {
switch (totalImages) {
case 1:
return "grid-cols-1";
case 2:
return "grid-cols-2";
case 3:
return "grid-cols-2 grid-rows-2";
case 4:
return "grid-cols-2 grid-rows-2";
default:
return "grid-cols-2";
}
};Three and four images share the same grid definition. imageSpan and imageGrid create the visual difference by changing cell size and corner treatment.
I like this reuse because it keeps the system small. The grid template does not need to change every time the layout changes. A shared base grid, plus a few helpers that adjust spans and corners, is enough to cover both cases.
Aspect ratios per layout
Multi-image layouts ignore the source image dimensions. getImageStyle assigns a fixed aspect ratio to each cell based on the layout.
export const getImageStyle = (
index: number,
totalImages: number,
height: number,
width: number
) => {
switch (totalImages) {
case 1:
return { aspectRatio: getAspectRatio(height, width) };
case 2:
return { aspectRatio: "7 / 8" };
case 3:
return index === 0 ? { aspectRatio: "7 / 8" } : { aspectRatio: "7 / 4" };
case 4:
return { aspectRatio: "2 / 1" };
default:
return { aspectRatio: "1 / 1" };
}
};One image uses height and width. Fixed ratios keep the other layouts consistent across mixed source images. Two images use 7 / 8. Three images use 7 / 8 for the anchor and 7 / 4 for the stacked cells. Four images use 2 / 1 to keep the grid compact.
This is one of the tradeoffs that makes the component work. Exact source dimensions sound more correct, but they make shared layouts harder to control. If one image is tall and the next one is wide, the grid can end up fighting itself.
Fixed ratios shift the goal from preserving every original proportion to preserving a stable composition. In a feed, that tradeoff is usually worth it. The images still read well, and the card stays coherent.
Rendering the grid
The helpers keep the render function small, which is the main reason I like splitting the logic this way.
<div className={`grid ${gridClasses(totalImages)}`}>
{images.map((image, index) => (
<div
key={image.url}
className={`relative w-full ${imageSpan(index, totalImages)}`}
style={getImageStyle(index, totalImages, image.height, image.width)}
>
<div className="absolute p-[1px] inset-0 h-full w-full">
<img
className={`h-full w-full object-cover transition-opacity duration-300 ease-in-out ${imageGrid(index, totalImages)}`}
src={image.url}
alt={`Preview image ${index + 1}`}
style={{
overflowClipMargin: "content-box",
overflow: "clip",
}}
/>
</div>
</div>
))}
</div>Each inner wrapper uses p-[1px] instead of grid gap. That keeps the separator inside each cell, which works better with rounded corners. object-cover fills each cell without distortion. overflow: clip and overflowClipMargin: "content-box" keep pixels inside the rounded edges.
I like this part because the render function ends up feeling declarative. It loops through the images, asks a few helpers for layout decisions, and renders the result. Most of the thinking already happened elsewhere.
Using p-[1px] instead of gap is a good example of a small implementation detail that changes the finish of the component. Grid gaps live between cells, which can make rounded corners feel less clean. Internal padding keeps the separator effect while preserving the outer shape of the card.
Closing thoughts
Twitter's image grid is a good example of how far a few layout rules can go.
totalImages sets the structure. The helpers turn that value into grid tracks, spans, corner rounding, and aspect ratios. Each helper stays small, and the full layout stays easy to reason about.
That is the part worth keeping. Once the rules are clear, the component stops feeling clever and starts feeling dependable.
That is usually a good sign in UI work. The user does not need to notice the rules. They only need the layout to feel consistent each time they see it. When the system is small and the outputs are predictable, you can get that result without making the component hard to maintain.
