How to build an about section with team intro in React
An about-team-intro section in React uses a two-column grid: the left column holds a badge, heading, description and animated highlight rows that slide in from the left on scroll; the right column shows a square team image that fades and scales into view. Both columns use Framer Motion's whileInView with once:true so they animate exactly once as the user scrolls past.
- Stack: React 18, Framer Motion 11, Lucide React, Tailwind v4. ~145 lines, zero extra runtime dependencies.
- Icons are resolved dynamically from the lucide-react namespace, any Lucide icon name can be passed as a string prop.
- Fully themeable via CSS custom properties (--color-accent, --color-background-alt, --radius-md, etc.), no hardcoded colours.
- Accessible: semantic section + h2, icons are decorative (aria-hidden by default via Lucide), responsive down to 375px.
- All props are optional, badge, image and highlights can be omitted without breaking the layout.
About Team Intro is a two-column React section that pairs a text block with a team photo to give visitors a face behind the product. The left side carries badge, heading, paragraph and a vertical list of icon-backed highlights; the right side frames a square image that scales subtly into view as the user scrolls. Together they communicate trust and culture without adding cognitive overhead.
Anatomy
A full-width section with a max-w-6xl centered container holds a responsive CSS grid (1 column on mobile, 2 on md+). The left column is a motion.div that wraps an optional badge pill, h2 heading, description paragraph and a flex-col list of highlight rows. Each highlight row is its own motion.div containing an optional icon container (10×10 rounded square with a tinted accent background) and a text pair (semibold label + muted description). The right column is a square motion.div with overflow:hidden that renders the team image as a CSS background-image.
How it works
Two distinct Framer Motion entrance patterns run in parallel. The left column uses initial:{opacity:0, y:20} with whileInView:{opacity:1, y:0}, a 500ms duration and a viewport margin of -80px so the animation fires slightly before the element enters the viewport. Each highlight row adds a staggered delay of 100ms + (index × 80ms) and slides in from x:-12 instead of y, giving the list a cascading feel. The right column uses initial:{opacity:0, scale:0.96} with a custom cubic-bezier ease [0.16, 1, 0.3, 1] over 600ms for a subtle spring-like pop. All animations fire once (once:true) regardless of scroll-back.
How to build it in React
Set up the two-column grid
Create a section with a centered max-w-6xl container. Inside, use a CSS grid that collapses to one column on mobile and expands to two on md breakpoint. Pass items-center so the columns align vertically when they have unequal heights.
<div className="grid grid-cols-1 gap-12 items-center md:grid-cols-2"> {/* text column */} {/* image column */} </div>Animate the text column on scroll
Wrap the text column in a motion.div with initial opacity 0 and y offset of 20px. Use whileInView to restore them and set viewport once:true with a -80px margin so the fade starts before the element fully enters the screen.
<motion.div initial={{ opacity: 0, y: 20 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true, margin: "-80px" }} transition={{ duration: 0.5 }} >Stagger the highlight rows
Each highlight row is a separate motion.div that slides in from x:-12. Add a delay computed from the item index, 0.1s base plus 0.08s per item. This creates a natural cascade without any extra orchestration API.
<motion.div initial={{ opacity: 0, x: -12 }} whileInView={{ opacity: 1, x: 0 }} viewport={{ once: true }} transition={{ delay: 0.1 + i * 0.08, duration: 0.4 }} >Reveal the team image with a scale pop
The image column is a square motion.div (aspect-square) with overflow:hidden and a border radius token. Set initial scale to 0.96 and animate to 1 using a custom ease [0.16, 1, 0.3, 1] over 600ms, this mimics a spring without importing the spring API.
<motion.div initial={{ opacity: 0, scale: 0.96 }} whileInView={{ opacity: 1, scale: 1 }} viewport={{ once: true, margin: "-60px" }} transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1] }} className="aspect-square rounded-[var(--radius-xl)] overflow-hidden" >
When to use it
Use this section mid-page on landing pages for agencies, SaaS products and startups that need to establish trust before a pricing or testimonials block. It works well as the second or third section, after a hero. Skip it if your audience already knows the team well (internal tools, developer docs) or if the page is purely conversion-focused and a team photo would be a distraction. On mobile the grid collapses to single column, so verify the image aspect-ratio looks intentional at 375px rather than cropped awkwardly.
Used by
- Stripe, Split layout pairing editorial text with a team/office photo on the about page to humanise a developer-facing brand.
- Linear, Company narrative with icon-backed value bullets alongside imagery, the exact pattern this component implements.
- Loom, Two-column about sections with staggered text reveals and team photography to communicate culture.
FAQ
How do I resolve a Lucide icon by string name at runtime?
Cast the lucide-react namespace to a Record<string, React.ElementType> and look up the icon name as a key. Return null if the key is missing so the layout degrades gracefully without an icon container.
Can I replace the image with a video or a grid of headshots?
The right column is a plain div with overflow:hidden, swap the background-image div for any element. A video (autoplay muted loop) or a CSS grid of headshots fits without touching the animation code.
Why use CSS background-image instead of an img tag for the photo?
background-image with background-size:cover handles arbitrary aspect ratios without layout shift. For production, swap it for a Next.js Image component with objectFit:cover to get automatic optimisation and lazy loading.
The stagger animation feels too slow on long lists. How do I tune it?
Reduce the per-item delay increment from 0.08 to 0.05 or less, and cap the total delay with Math.min(0.1 + i * 0.05, 0.4) so items beyond the fifth don't wait noticeably longer.