How to build a split photo about section in React
A split photo about section in React places a tall portrait image on the left and labeled mission/vision copy on the right inside a two-column CSS grid. Framer Motion's whileInView triggers an opacity + horizontal slide for each column as the section enters the viewport.
- Stack: React + Framer Motion + lucide-react, ~173 lines, zero extra dependencies.
- Animation: whileInView with once:true, 0.6s duration, custom cubic-bezier ease [0.16, 1, 0.3, 1], 0.1s stagger between columns.
- Image block: fixed 4/5 aspect ratio, rounded via CSS token, accent-colored background as a placeholder.
- Accessible: semantic h2 + h3 hierarchy, standard anchor for the CTA, no ARIA hacks needed.
- Responsive caveat: the two-column grid is set inline; add a media query or Tailwind breakpoint to stack columns on mobile.
About Split Photo is a React section that pairs a large portrait image with concise mission and vision copy in a two-column layout. It anchors the credibility of a brand or team above the fold of any about page, making the visual do half the storytelling work while the text stays scannable.
Anatomy
A full-width section wraps a centered container capped at `--container-max-width`. Inside, a CSS grid with `1fr 1fr` columns and a 4rem gap holds two animated blocks. The left block is a motion.div with a fixed 4/5 aspect ratio, rounded corners via `--radius-lg`, and an accent-colored background acting as an image placeholder. The right block stacks an h2 with a serif italic accent, two labeled subsections (Mission, Vision) with uppercase tracking headings, and a pill-shaped CTA link with an ArrowRight icon.
How it works
Each column is a `motion.div` that starts offset: the photo starts at `{ opacity: 0, x: -24 }` and the text at `{ opacity: 0, x: 24 }`. Both animate to `{ opacity: 1, x: 0 }` when they enter the viewport (`whileInView`, `viewport={{ once: true }}`). The custom cubic-bezier ease `[0.16, 1, 0.3, 1]` gives the motion an aggressive start and a soft landing, so the columns appear to snap into place. A 0.1s delay on the text column creates a stagger effect without a third-party orchestration utility.
How to build it in React
Set up the two-column grid
Wrap the section content in a div with `display: grid`, `gridTemplateColumns: '1fr 1fr'`, `gap: '4rem'`, and `alignItems: 'center'`. Use CSS tokens for padding and max-width so the layout adapts to every theme preset without extra overrides.
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "4rem", alignItems: "center", maxWidth: "var(--container-max-width)", padding: "0 var(--container-padding-x)", margin: "0 auto", }}>Animate the photo column into view
Replace the photo div with a `motion.div`. Set `initial={{ opacity: 0, x: -24 }}` and `whileInView={{ opacity: 1, x: 0 }}` with `viewport={{ once: true }}`. Use the custom ease array for a snappy, natural feel. Fix the aspect ratio to 4/5 so the block stays proportional at any column width.
const EASE = [0.16, 1, 0.3, 1] as const; <motion.div initial={{ opacity: 0, x: -24 }} whileInView={{ opacity: 1, x: 0 }} viewport={{ once: true }} transition={{ duration: 0.6, ease: EASE }} style={{ aspectRatio: "4/5", borderRadius: "var(--radius-lg)" }} />Stagger the text column
Wrap the text content in a second `motion.div` with `initial={{ opacity: 0, x: 24 }}` (slides from the right) and a `delay: 0.1` in the transition. The 100ms offset is enough to read as a deliberate stagger without making the user wait.
<motion.div initial={{ opacity: 0, x: 24 }} whileInView={{ opacity: 1, x: 0 }} viewport={{ once: true }} transition={{ duration: 0.6, delay: 0.1, ease: EASE }} />Structure the mission/vision copy
Give each subsection an h3 with uppercase tracking (font-size 0.75rem, letter-spacing 0.1em, accent color) as a label, then a paragraph for the body copy. Keep the h2 above them and use a serif italic span for the accent part of the title. Finish with a pill CTA link.
When to use it
Reach for this layout on agency, studio, or SaaS about pages where a strong visual anchors the brand story. It works equally well mid-page as an interlude between feature blocks and at the top of a dedicated /about route. Skip it when the image is unavailable or purely decorative, a text-only about section avoids the placeholder awkwardness. On mobile, always stack the columns vertically so the photo does not get crushed to a narrow strip.
Used by
FAQ
How do I make the two columns stack on mobile?
The grid is set via inline styles, so add a CSS media query or a Tailwind responsive class to switch `gridTemplateColumns` to `1fr` below the `md` breakpoint. A quick approach is to replace the inline style with a Tailwind class like `grid-cols-1 md:grid-cols-2`.
Can I replace the placeholder with an actual image?
Yes. Swap the accent-colored div for a Next.js `<Image>` or a plain `<img>` with `object-fit: cover` and `width: 100%; height: 100%` inside the motion.div. Keep the `aspectRatio: '4/5'` on the container so the layout does not shift.
Why does the animation trigger only once?
`viewport={{ once: true }}` tells Framer Motion to fire whileInView a single time and leave the element in its animated state. Remove that option if you want the columns to reset and re-animate each time the user scrolls past them.
How do I change the CTA style from filled to outlined?
Remove `background: 'var(--color-accent)'` from the link and add `border: '2px solid var(--color-accent)'` plus `color: 'var(--color-accent)'`. Adjust the hover state accordingly via a CSS class or Framer Motion whileHover.