Skip to content
Consultation

The Two-Column Mobile Grid I Took Back

Adityo Guni Waluyo

Splitting a 4-column grid into 2 columns made cards cramped on phones. The fix: an embla carousel on mobile, a grid on desktop, a doubled DOM.

Pulling up the client's services page at 375px, all I could see were two cramped cards squeezed against the screen edges, headings about to spill out. My first guess pointed at the client's design. Wrong. It was my own call from an earlier commit: the 4-column desktop grid got split into grid-cols-2 gap-3 on small screens, reasoning that "two columns beat four on a phone". At 375 pixels, each card got half the viewport minus the container padding. Cramped.

I first thought this was a spacing problem. Shrink the font, drop the gap, done. But once I inspected the element, the issue wasn't the type size. The ratio between card width and its contents simply made no sense. Thumbnail, two-line title, short description, all squeezed into roughly 45% of the screen. A two-column grid was the wrong call for this kind of content.

Commit 321dfea changed the approach. On mobile there's no grid anymore — it's a swipe carousel built on embla-carousel-react through the shadcn/ui Carousel component. The desktop stays a grid, only now the two renders differ: one page ships two structures for the same content.

{/* Mobile: Carousel */}
<div className="block sm:hidden -mx-4 px-4">
  <Carousel opts={&#123; align: "start" &#125;} className="w-full">
    <CarouselContent className="-ml-4">
      &#123;detail.coreServices.map((service) => (
        <CarouselItem key={service.title} className="pl-4 basis-[85%]">
          <ServiceCard service={service} className="h-full" />
        </CarouselItem>
      ))&#125;
    </CarouselContent>
  </Carousel>
</div>

{/* Desktop: Grid */}
<Reveal stagger staggerAmount={0.1} className="hidden sm:grid sm:grid-cols-2 sm:gap-5 lg:grid-cols-4">
  &#123;detail.coreServices.map((service) => (
    <ServiceCard key={service.title} service={service} />
  ))&#125;
</Reveal>

The smallest detail with the biggest effect: basis-[85%] on the CarouselItem. If a slide fits the screen exactly, nobody knows there are more cards to the right. That visible 15% sliver of the next card is the visual hint "this scrolls". The -mx-4 px-4 wrapper is the full-bleed trick: negative margin pulls the carousel past the layout container's default padding, then the same padding is reapplied inside so card content doesn't touch the screen edge.

The shadcn carousel.tsx is "use client" because of the useEmblaCarousel hook inside. Called from a page.tsx that is a server component, it's the same client island pattern I covered when writing about the Show More pattern and its SEO trade-off in the same services components. The initial HTML still comes from the server; only the swipe interactivity activates on the client.

One thing that's easy to miss: keyboard access. The stock shadcn carousel.tsx already ships ArrowLeft/ArrowRight handlers for sliding, plus CarouselPrevious/CarouselNext if you want explicit nav buttons. Details like this are why I'd rather take the ready-made component than write my own wrapper around useEmblaCarousel — the baseline behavior is handled, I only adjust styling and slide width.

I checked the official sources before adding the dependency. embla-carousel-react 8.6.0 is stable, MIT licensed, with roughly 6.2 million weekly downloads on npm (npm). Version 9 is still a release candidate and there's a v8-to-v9 migration guide on the releases page, so I'm staying on v8 for now. The React wrapper is what makes it sticky: automatic cleanup on unmount, so no idle event listeners dangling after the component goes away (Embla docs). The shadcn carousel itself is built on top of embla-carousel-react (shadcn docs).

The price I pay: a doubled DOM

The honest trade-off: the same content renders twice in the DOM. The carousel lives in block sm:hidden, the grid in hidden sm:grid. Exactly one of them displays, the other is only hidden through Tailwind classes. Both stay in the server-rendered HTML, so mobile-first indexing is safe; content hidden via CSS isn't removed from the markup crawlers read. When the client asks to change a card's text tomorrow, don't edit just one place — it's easy to get fooled because what shows on mobile and on desktop are different elements.

A carousel is not a universal answer. With only two or three cards, a single-column grid still reads best, plain vertical scrolling does the job. I only reach for a carousel when there are many items and a grid makes each card too small, which is exactly what happened on this services page. When new cards get added later, remember: two places need filling.

Related articles