How to build a features icon grid section in React
A features icon grid in React renders a responsive CSS grid of cards, each with a dynamically resolved Lucide icon, a title, and a short description. Each card enters the viewport with a staggered whileInView animation from Framer Motion, so the grid fills in naturally as the user scrolls.
- Stack: React + Framer Motion + Lucide React, ~170 lines, zero extra dependencies.
- Icons resolved at runtime from the Lucide namespace, any Lucide icon name works as a string prop.
- Fully theme-aware: all colors and spacing use CSS custom properties (--color-background, --color-accent, etc.).
- Responsive: auto-fit grid with minmax(280px, 1fr) collapses to a single column on small screens.
- Accessible: semantic h2/h3 hierarchy, icons are decorative and do not require aria-label.
Features Icon Grid is a clean, scroll-animated React section that presents up to six product features in a centered 3-column layout. Each feature card shows a tinted icon badge, a short title, and a one-line description. The Apple/Stripe aesthetic, generous whitespace, centered text, restrained color, makes it drop into almost any SaaS or agency landing page without restyling.
Anatomy
The section wraps two main regions. At the top, a centered header block holds an optional uppercase badge, an h2 title with fluid clamped font-size, and a subtitle paragraph; the whole block animates in as one unit. Below it, a CSS grid with `auto-fit, minmax(280px, 1fr)` renders the feature cards. Each card is a centered flex column: a 48px square icon container with a 10% accent tint, an h3, and a description capped at 280px wide for comfortable line length.
How it works
The animation uses Framer Motion's `whileInView` with `viewport={{ once: true, margin: '-40px' }}` so each card animates exactly once when it enters the visible area. The header fades up as a single unit (opacity 0 → 1, y 16 → 0). Feature cards share the same keyframe but each gets a delay of `i * 0.06s`, creating a left-to-right stagger across the row. All transitions use the custom cubic-bezier `[0.16, 1, 0.3, 1]`, a fast-out-slow-in curve that feels snappy without overshooting. Icon components are resolved at render time: the prop is a string like `'Zap'` and the component does a keyed lookup on the Lucide namespace object.
How to build it in React
Define the data shape and props
Create a `FeatureItem` interface with `id`, `title`, `description`, and an optional `icon` string. Pass an array of these to the component along with an optional `badge`, `title`, and `subtitle` for the section header. Keep the icon field a plain string, resolution happens inside the component.
interface FeatureItem { id: string; title: string; description: string; icon?: string; }Resolve Lucide icons from a string prop
Import the entire Lucide namespace as `* as LucideIcons`, then write a small helper that does a keyed lookup. Cast the namespace to a `Record<string, React.ElementType>` to avoid TypeScript errors. If the name is missing or unknown, the helper returns `null` and the icon slot stays empty.
import * as LucideIcons from "lucide-react"; function getIcon(name?: string) { if (!name) return null; return (LucideIcons as unknown as Record<string, React.ElementType>)[name] || null; }Build the responsive grid with CSS auto-fit
Use a plain `div` with `display: grid` and `gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))'`. This gives you three columns on desktop, two on tablet, and one on mobile with no media queries. Set `gap` to roughly 3rem vertically and 2.5rem horizontally for that airy SaaS look.
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))", gap: "3rem 2.5rem", maxWidth: 960, margin: "0 auto", }}>Add staggered whileInView animations
Wrap each card in a `motion.div` with `initial={{ opacity: 0, y: 16 }}` and `whileInView={{ opacity: 1, y: 0 }}`. Set `viewport={{ once: true, margin: '-40px' }}` so the animation fires once when 40px of the card is visible. Multiply the card index by 0.06 for the delay to get a natural left-to-right stagger.
<motion.div key={feature.id} initial={{ opacity: 0, y: 16 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true, margin: "-40px" }} transition={{ delay: i * 0.06, duration: 0.5, ease: [0.16, 1, 0.3, 1] }} >
When to use it
Place this section directly after the hero on a SaaS or product landing page, it works best as the first explanation of what your product does. Six features is the sweet spot; below four the grid looks sparse, and above eight you should consider a different layout like an alternating split. Skip it on content-heavy pages like documentation or pricing tables where cognitive load is already high. On mobile the auto-fit grid collapses gracefully to a single column, so no extra work is needed there.
Used by
- Stripe, Uses a centered icon grid on its product pages to present API capabilities with tinted icon containers and short, punchy descriptions.
- Linear, Presents workflow features in a sparse multi-column grid with icon badges and minimal prose, same airy spacing philosophy.
- Vercel, Feature grids with monochrome icon containers are a recurring pattern across its platform and infrastructure pages.
- Notion, Deploys a centered icon-and-text grid on its for-teams pages to break down collaboration features in a scannable format.
FAQ
How do I add or change icons without touching the component?
Pass the exact PascalCase Lucide icon name as the `icon` string in your data, for example `'Zap'`, `'Shield'`, or `'BarChart2'`. The component resolves it at runtime from the Lucide namespace, so any valid Lucide icon name works without changing the component code.
Can I use a different number of features than six?
The auto-fit grid adapts to any count. Four items give a 2×2 layout on desktop, five give an uneven row. For the most balanced result, stick to multiples of three (3, 6, 9). If you need five or seven features, set an explicit `gridTemplateColumns` like `repeat(3, 1fr)` so the last row aligns left instead of stretching.
How do I turn off the scroll animations?
Replace `motion.div` with a plain `div` and remove the `initial`, `whileInView`, and `transition` props. The grid and layout remain intact, Framer Motion is only used for the entrance animation, not for positioning.
Does this section support dark and light mode?
Yes. Every color value reads from a CSS custom property (--color-background, --color-foreground, --color-accent, --color-foreground-muted). Switching the theme at the `data-theme` attribute level is enough; no changes inside the component are needed.