EmpireUI
Get Pro
← Blog7 min read#tailwind-css#features-grid#responsive-layout

Tailwind Features Grid: Responsive Icon + Text Layout

Build a responsive features grid with Tailwind CSS — icons, headings, and body text that reflow cleanly at every breakpoint without a single media query hack.

A grid of UI cards with icons and descriptive text arranged in a clean responsive layout on a dark background

Why Features Grids Break More Than They Should

Honestly, a features grid is one of those UI patterns that looks trivial until you're three breakpoints deep and your icon is floating 40px above the text it's supposed to sit next to. Every marketing site needs one. Almost everyone builds it slightly wrong.

The core problem isn't the grid itself — it's the icon-plus-text pairing inside each cell. You've got an SVG icon at 24px or 32px, a short heading, and two lines of body copy. At desktop widths that stacks vertically just fine. At 375px it either squishes into illegibility or wraps in a way that looks accidental.

Tailwind CSS (specifically Tailwind v4.0.2 and the new engine it ships with) gives you enough primitives to nail this pattern without resorting to custom CSS. This article walks through the layout decisions, not just the class names.

The Grid Container: Columns That Actually Respond

The standard approach is grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6. That covers you from 320px to 1280px with zero custom breakpoints. But there's a better option when your content is variable: grid-cols-[repeat(auto-fill,minmax(280px,1fr))]. You're telling the browser to fit as many 280px columns as available space allows, then stretch them to fill. It's inherently responsive without a single sm: prefix.

The 280px minimum matters. Below that, icon-plus-text combinations start feeling cramped — especially if the icon carries a label. Test at exactly 279px wide and you'll see why. The gap-6 (24px) gives breathing room between cells without making a 3-column layout look like it's floating in space.

If you're on Tailwind v4 features you can define the grid column logic as a CSS custom property in your @theme block and reference it with grid-cols-(--feature-cols). Slightly more setup, but it means the value lives in one place if you're reusing this pattern across pages.

Icon Placement: Inline vs Stacked

There are two common approaches for the icon-plus-heading relationship: stacked (icon above heading) and inline (icon left of heading). Neither is universally better. Stacked works when you want the grid to feel editorial — each cell is a mini card. Inline works when you want density, more of a spec-list feel.

For the stacked layout, each cell is flex flex-col gap-3. The icon sits in its own container: w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center. That bg-white/10 gives a subtle tint without hardcoding a color — it adapts to dark and light backgrounds. If you want something more specific, rgba(255,255,255,0.08) via an inline style or a CSS variable is more predictable than opacity utilities when you're layering multiple semi-transparent elements.

For the inline layout, the cell becomes flex items-start gap-4. The icon wrapper uses shrink-0 so it never collapses when the text wraps. That shrink-0 is the one class people forget, and it's the one that causes the layout to look broken at medium widths.

Building the Feature Card Component in React

Here's a minimal but production-ready implementation. It accepts an icon component, a title, and a description. The grid wrapper lives in the parent — this component only cares about one cell.

interface FeatureCardProps {
  icon: React.ReactNode;
  title: string;
  description: string;
  variant?: 'stacked' | 'inline';
}

export function FeatureCard({
  icon,
  title,
  description,
  variant = 'stacked',
}: FeatureCardProps) {
  const isInline = variant === 'inline';

  return (
    <div
      className={[
        'rounded-xl border border-white/10 p-6 transition-colors',
        'hover:border-white/20 hover:bg-white/5',
        isInline ? 'flex items-start gap-4' : 'flex flex-col gap-3',
      ].join(' ')}
    >
      <div className="shrink-0 w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center text-indigo-400">
        {icon}
      </div>
      <div className="flex flex-col gap-1">
        <h3 className="text-sm font-semibold text-white">{title}</h3>
        <p className="text-sm text-white/60 leading-relaxed">{description}</p>
      </div>
    </div>
  );
}

A few decisions worth noting: border-white/10 with hover:border-white/20 gives a tactile hover without a box-shadow. The text-indigo-400 on the icon wrapper is a starting point — in practice you'd probably pass a color prop or derive it from a design token. The leading-relaxed on the description prevents the text from feeling compressed when it wraps.

The Parent Grid and Responsive Spacing

The grid that wraps these cards determines the final rhythm. An 8px base gap (gap-2) is too tight for a features section. A 32px gap (gap-8) is too airy for dense content. gap-6 (24px) is the sweet spot for a 3-column layout. For 2-column layouts at sm: breakpoint, you might want to bump to sm:gap-8 to give each pair more room to breathe.

export function FeaturesGrid({ features }: { features: FeatureCardProps[] }) {
  return (
    <section className="w-full max-w-6xl mx-auto px-4 py-16">
      <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 sm:gap-8">
        {features.map((feature, i) => (
          <FeatureCard key={i} {...feature} />
        ))}
      </div>
    </section>
  );
}

The max-w-6xl mx-auto container keeps the grid from stretching obscenely wide on 4K displays. You could also use max-w-5xl if your icon-plus-text cells read better with less horizontal space per card. Test with real content — placeholder icons and lorem ipsum will lie to you about how the spacing feels.

Dark Mode and Color Strategy

Features grids are almost always displayed on dark backgrounds in SaaS products. The bg-white/10 and border-white/10 approach works there. But you'll want to invert for light mode — and that's where Tailwind glassmorphism patterns become a useful reference, since that article goes deeper on the layered-transparency approach that holds up on both backgrounds.

The minimal approach: use dark: variants for every color utility. bg-slate-100 dark:bg-white/10, text-slate-900 dark:text-white, border-slate-200 dark:border-white/10. It's verbose but explicit, and it means you're not fighting a cascade you can't see. If you've set up a theme toggle you already know where the dark class lives.

One thing to avoid: don't use opacity-50 on the description text if you're also using semi-transparent backgrounds. The visual result is unpredictable across different background colors. Instead, use explicit color utilities like text-slate-500 dark:text-white/60. Same perceived contrast, but it doesn't interact with the background opacity.

Accessibility: Icon Labels and Focus States

Icons without accessible labels are an accessibility failure, not just a nice-to-have fix. If your icon is purely decorative (the title already names the feature), add aria-hidden="true" to the SVG. If the icon conveys information the text doesn't, it needs either an aria-label on the SVG or a visually hidden span inside the icon wrapper.

Focus states on the card matter too. The default outline on the div is usually invisible or styled away by a CSS reset. Add focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 to the card element if it's interactive (e.g., the whole card links somewhere). If it's not interactive, don't make it focusable at all — tabIndex on a non-interactive div is one of those patterns that feels clever and hurts real users.

Also worth checking: does your grid maintain reading order in the DOM that matches the visual order? Tailwind's grid doesn't reorder elements by default, so you're fine — but if you've used order- utilities to swap columns at certain breakpoints, screen readers will encounter the content in DOM order, not visual order. Worth a quick test with VoiceOver or NVDA.

When to Use This Pattern vs. Alternatives

A features grid makes sense when you have 4–12 roughly equal-weight items to present. Below 4, a simple list or two-column layout usually reads better. Above 12, you're probably better served by grouping into categories with a tabbed interface or an accordion. Who wants to scan 15 cards looking for the one feature they care about?

If your features have very different amounts of description text, a masonry layout or a list with inline icons will handle the varying heights better than a strict grid. Tailwind's grid-rows-[masonry] is still experimental in most browsers as of late 2026, so you'd need a CSS column fallback or a JS library for true masonry. For component patterns that handle variable-height content, there's more detail on that tradeoff.

The pattern in this article assumes static content. If you're fetching features from a CMS or API and the count varies, make sure your grid degrades gracefully at 1 item (just a single full-width card) and at 5 items (2+2+1 or 3+2 depending on breakpoint). Both of those layouts look intentional with auto-fill and minmax() — they look broken with fixed column counts that leave orphaned cells.

FAQ

How do I make the features grid work with an odd number of items at 3 columns?

Use grid-cols-[repeat(auto-fill,minmax(280px,1fr))] instead of fixed column counts. The browser fills each row naturally and the last row's items stretch to fill the available width without leaving a single orphaned card in the corner. If you need the last card centered specifically, a [&:last-child:nth-child(3n-1)]:col-span-2 trick works but it's fragile — the auto-fill approach is simpler.

What's the right icon size for a features grid card?

24px (w-6 h-6) to 32px (w-8 h-8) for the SVG itself, inside a 40px (w-10 h-10) container. The container gives you padding and a background shape without needing extra utility classes. Going larger than 40px container makes cards feel more like hero elements than list items — use that sizing when you have 3 cards maximum, not 9.

Can I use CSS Grid gap values that aren't in the default Tailwind scale?

Yes. In Tailwind v4.0.2 you can use arbitrary values: gap-[22px] gives you exactly 22px if that's what your design calls for. You can also define it in your @theme block as --spacing-11: 2.75rem and then use gap-11. The arbitrary value syntax is faster for one-offs; the theme extension is better if you're reusing the value.

How do I handle icon color when the card has a hover state?

Use group on the card and group-hover: on the icon. For example: <div className='group ...'><div className='text-slate-400 group-hover:text-indigo-400 transition-colors'>. This way the icon color changes as part of the card hover, not independently. Avoid transition on the background color of the icon wrapper separately — it'll lag behind the icon color change visually.

Is it better to link the entire card or just the heading for SEO?

Link the heading, not the entire card div. Wrapping a div in an anchor tag is valid HTML5 but the anchor text becomes everything inside the card — icon aria-label, heading text, description — which is noisy for screen readers and creates unusually long link text that search engines may weigh differently. A heading link with after:absolute after:inset-0 creates a full-card click target without the accessibility downsides.

My features grid looks fine in Chrome but icons are misaligned in Safari. Why?

Safari handles align-items on flex containers differently when one flex child has an explicit height and another doesn't. Add min-h-0 to the text wrapper div and self-start to the icon wrapper. That forces both children to start at the top of the flex container rather than stretching. It's a one-line fix once you know what's happening.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Tailwind Footer Design: 4-Column Link Layout with NewsletterTailwind Button Collection: 15 Variants for Every Use CaseIridescent Gradient Text: CSS Techniques for Shimmer EffectsNeon Text Glow in CSS: Text-Shadow Techniques for Dark UIs