RizTech Academy logo
RizTech Academy
Building the InterfaceLesson 3 of 530 min

Responsive layouts for a real product grid

A product grid is the most-viewed screen in any shop and the one most often broken on a real phone. This lesson builds a complete, working one and covers the specific failures that make grids look wrong.

Mobile first, genuinely

Most of your customers are on a mid-range Android on mobile data, standing up. Design for that and widen, rather than shrinking a desktop layout.

Tailwind enforces this: unprefixed classes are the small-screen styles.

<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4 lg:grid-cols-4 lg:gap-6">

Two columns on a phone, not one. One column means one product per screen and a lot of scrolling; two is what every successful shop uses. Test it at 320px — the smallest phone still in real use.

The complete grid

// apps/web/src/components/product/product-grid.tsx
import type { Product } from "@kirana/shared";
import { EmptyState } from "@/components/ui/empty-state";
import { ProductCard } from "./product-card";

export function ProductGrid({
  products,
  emptyAction,
}: {
  products: Product[];
  emptyAction?: React.ReactNode;
}) {
  if (products.length === 0) {
    return (
      <EmptyState
        title="No products found"
        description="Try removing a filter or searching for something else."
        action={emptyAction}
      />
    );
  }

  return (
    <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4 lg:grid-cols-4 lg:gap-6">
      {products.map((product) => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  );
}

The complete card

// apps/web/src/components/product/product-card.tsx
import Image from "next/image";
import Link from "next/link";
import type { Product } from "@kirana/shared";
import { Badge } from "@/components/ui/badge";
import { Money } from "@/components/ui/money";

export function ProductCard({
  product,
  action,
}: {
  product: Product;
  action?: React.ReactNode;
}) {
  return (
    <article className="group flex h-full flex-col overflow-hidden rounded-xl border border-gray-200 bg-white">
      <Link
        href={`/products/${product.slug}`}
        className="relative block aspect-square bg-gray-50"
      >
        <Image
          src={product.imageUrl}
          alt={product.name}
          fill
          sizes="(min-width: 1024px) 25vw, (min-width: 640px) 33vw, 50vw"
          className="object-cover transition-transform group-hover:scale-105"
        />
        {!product.inStock && (
          <div className="absolute inset-0 grid place-items-center bg-white/70">
            <Badge tone="danger">Out of stock</Badge>
          </div>
        )}
      </Link>

      <div className="flex flex-1 flex-col p-3">
        <Link href={`/products/${product.slug}`} className="min-w-0">
          <h3 className="line-clamp-2 text-sm font-medium text-gray-900 group-hover:text-emerald-700">
            {product.name}
          </h3>
        </Link>

        <p className="mt-0.5 text-xs text-gray-500">{product.unit}</p>

        <div className="mt-auto pt-3">
          <Money paise={product.pricePaise} className="font-semibold tabular-nums" />
          {action && <div className="mt-2">{action}</div>}
        </div>
      </div>
    </article>
  );
}

The five things that break grids

Each of these is a real failure you will otherwise ship.

1. Cards of different heights

Longer titles make taller cards, and the grid goes ragged.

The fix is in the code above: h-full on the card, flex flex-col inside, and mt-auto on the price block. The card fills its grid cell, the content stacks, and mt-auto pushes the price to the bottom regardless of title length.

Every price lines up across the row, which is what makes a grid look designed.

2. Images of different shapes

Product photos arrive at every aspect ratio. Without constraint, the grid jumps.

aspect-square on the container plus fill and object-cover on the image gives every card the same shape, cropping rather than distorting.

object-contain if you must show the whole product — you get whitespace, which is often correct for packaged goods.

3. Layout shift while images load

The page renders, then images arrive and shove everything down. Users tap the wrong thing.

next/image with fill inside a sized container reserves the space, so nothing moves. This is also a Core Web Vitals measure, so it affects ranking.

The sizes attribute tells the browser how wide the image will actually be at each breakpoint, so it downloads an appropriately sized file rather than a desktop-sized one to a phone. Getting sizes wrong is the most common next/image mistake — the symptom is a slow grid on mobile data.

Read sizes="(min-width: 1024px) 25vw, (min-width: 640px) 33vw, 50vw" as: four columns on a large screen means each image is 25% of the viewport, three columns is 33%, and two columns is 50%.

4. Long words overflowing

A product name with no spaces — AashirvaadSelectSharbatiAtta — pushes the card wider and breaks the grid.

line-clamp-2 handles the common case. For genuine unbroken strings, add break-words, and min-w-0 on flex children so they are allowed to shrink below their content width. min-w-0 is the fix for "my flex layout overflows and I cannot see why", because flex items default to min-width: auto.

5. Tap targets that are too small

A 20px button is difficult on a phone. Aim for at least 44px on anything tappable.

<button className="grid size-10 place-items-center rounded-lg" aria-label="Add to cart">

size-10 is 40px — acceptable with surrounding padding. Icon-only buttons need aria-label, because a screen reader announces nothing otherwise.

The card above has two Links — image and title — rather than wrapping everything in one. That is deliberate: a Link around the whole card cannot contain an Add to cart button, because nesting interactive elements is invalid HTML and behaves unpredictably.

The alternative is the "stretched link" pattern: one link, absolutely positioned over the card:

<article className="relative">
  <Link href={...} className="absolute inset-0 z-10" aria-label={product.name} />
  <Button className="relative z-20">Add to cart</Button>
</article>

The whole card is clickable and the button sits above the overlay. Use this when the entire card should be one target.

Skeletons that match

export function ProductGridSkeleton({ count = 8 }: { count?: number }) {
  return (
    <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4 lg:grid-cols-4 lg:gap-6">
      {Array.from({ length: count }).map((_, i) => (
        <div key={i} className="overflow-hidden rounded-xl border border-gray-200">
          <div className="aspect-square animate-pulse bg-gray-100" />
          <div className="space-y-2 p-3">
            <div className="h-4 animate-pulse rounded bg-gray-100" />
            <div className="h-4 w-2/3 animate-pulse rounded bg-gray-100" />
            <div className="h-5 w-1/3 animate-pulse rounded bg-gray-100" />
          </div>
        </div>
      ))}
    </div>
  );
}

The same grid classes as the real grid. If they differ, the layout jumps when content arrives — which is exactly what the skeleton was supposed to prevent.

Testing it properly

In DevTools, device toolbar, check:

Width Expect
320px 2 columns, no horizontal scroll
375px 2 columns, comfortable
768px 3 columns
1024px 4 columns
1440px 4 columns, content capped by max-w-7xl

The check that catches most bugs:

document.documentElement.scrollWidth - document.documentElement.clientWidth

Zero at every width. Anything above zero means something overflows, and on a phone that shows as the whole page sliding sideways.

Also throttle to "Slow 4G" and reload. That is the real experience for a lot of your customers.

Check your work

Why mt-auto on the price: with flex flex-col and h-full, mt-auto takes all remaining space as top margin, pushing the price to the bottom so prices align across a row regardless of title length.

Why aspect-square plus fill and object-cover: the container defines the shape so every card matches, and the image crops to fill it rather than stretching.

What sizes does: tells the browser how wide the image will render at each breakpoint so it downloads an appropriate file. Wrong values mean a phone downloading a desktop image.

Why min-w-0: flex items default to min-width: auto, so they refuse to shrink below their content. Long unbroken text then overflows. min-w-0 allows shrinking.

Why not one Link around the card: nesting a button inside a link is invalid HTML. Use two links, or a stretched absolutely positioned link.

Minimum tap target: around 44px.

Skeleton grid classes: identical to the real grid, or the layout jumps.

Practice

  1. Build the grid and card. Render twelve products.
  2. Give one product a very long name and one a very short one. Confirm the prices still align.
  3. Remove mt-auto and watch them stop aligning.
  4. Use images of different aspect ratios. Confirm the grid stays even.
  5. Remove sizes and compare the downloaded image size in the Network tab on a mobile viewport.
  6. Add a product name with no spaces and 40 characters. Fix the overflow.
  7. Run the scrollWidth - clientWidth check at 320, 375, 768 and 1024. Get zero at all four.
  8. Build the skeleton with matching classes. Toggle between them and confirm nothing shifts.
  9. Add an icon-only add-to-cart button at least 40px with an aria-label. Tab to it and confirm the focus ring is visible.
  10. Throttle to Slow 4G and reload. Note what appears first.

Next: forms that everyone can use.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship