How to build a before/after comparison section in React
A before/after comparison section in React renders two side cards in a CSS grid (1fr auto 1fr) separated by an animated arrow. Each card fades up on scroll via Framer Motion whileInView with a staggered delay. The 'after' card uses an accent border to visually emphasize the improved state.
- Stack: React + Framer Motion + Tailwind v4 + lucide-react, ~75 lines total.
- Animation: whileInView with once:true, triggers once as each card enters the viewport.
- The arrow icon (lucide ArrowRight) scales from 0 to 1 with a 300ms delay, giving a natural left-to-right reveal order.
- Fully theme-aware via CSS custom properties; no hardcoded colors.
- On mobile the grid stacks to a single column; the arrow remains visible between the two cards.
About Comparison is a React section that shows the transformation story in a structured side-by-side layout: a 'before' card on the left, an animated arrow in the middle, and an 'after' card on the right. Each card lists key metrics or facts as label/value rows, making the delta between two states immediately readable at a glance. The scroll-triggered fade-in keeps the section engaging without requiring any user interaction beyond scrolling.
Anatomy
The outer section holds a centered header block (badge, h2, subtitle) followed by a three-column grid. The grid uses the template `1fr auto 1fr` so the arrow column only takes exactly the space it needs. Each SideCard is an independent motion.div with a rounded border, an optional image in a 3:2 aspect-ratio container at the top, and a list of label/value pairs separated by thin horizontal rules. The 'after' card gets a colored accent border and an accent-colored heading to draw the eye.
How it works
Three Framer Motion `whileInView` animations drive the reveal. The header fades up with no delay. The 'before' card fades up 100ms later. The 'after' card follows at 200ms. The arrow between them uses a separate scale animation starting from 0, delayed to 300ms, so it appears after both cards have already started entering, reinforcing the left-to-right narrative. All animations use `viewport={{ once: true }}` so replaying a scroll does not re-trigger them.
How to build it in React
Define the data shape
Create a `ComparisonSide` interface with a label, an optional image URL, and an array of `{ label, value }` points. Pass `before` and `after` as optional props so the section degrades gracefully if one side is missing.
interface ComparisonSide { label: string; image?: string; points: { label: string; value: string }[]; }Build the SideCard sub-component
Wrap the card in a `motion.div` with `initial={{ opacity: 0, y: 30 }}` and `whileInView={{ opacity: 1, y: 0 }}`. Accept a `delay` and an `accent` boolean. When `accent` is true, switch the border and heading color to the accent token, this distinguishes the 'after' state without any extra class.
<motion.div initial={{ opacity: 0, y: 30 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }} transition={{ delay, duration: 0.5 }} style={{ borderColor: accent ? "var(--color-accent)" : "var(--color-border)", }} >Set up the three-column grid with the arrow
Use `grid-cols-[1fr_auto_1fr]` on large screens (single column on mobile). Between the two cards, render a `motion.div` that animates from `scale: 0` to `scale: 1` at 300ms delay. Fill a 48x48 circle with the accent background and place the ArrowRight icon inside.
<div className="grid grid-cols-1 lg:grid-cols-[1fr_auto_1fr] gap-6 items-center"> <SideCard side={before} delay={0.1} accent={false} /> <motion.div initial={{ opacity: 0, scale: 0 }} whileInView={{ opacity: 1, scale: 1 }} viewport={{ once: true }} transition={{ delay: 0.3, duration: 0.4 }} > <div className="flex h-12 w-12 items-center justify-center rounded-full" style={{ backgroundColor: "var(--color-accent)" }}> <ArrowRight style={{ color: "var(--color-background)" }} /> </div> </motion.div> <SideCard side={after} delay={0.2} accent={true} /> </div>Wire up the header and pass real data
Add a centered header above the grid: a badge span, an h2, and a subtitle paragraph, each conditionally rendered. Animate the whole block as one `motion.div` at delay 0. Then pass your actual before/after data, three to five points per side work best for readability.
When to use it
Reach for this section on SaaS landing pages, agency portfolio pages, or any product story where you can quantify the transformation with concrete metrics. It works best mid-page, after the hero, when the user has enough context to appreciate what the numbers mean. Avoid it when the difference between before and after cannot be expressed as short label/value pairs, a timeline or prose narrative serves that case better. Do not pair two of these back-to-back on the same page.
Used by
- Notion, Uses side-by-side metric cards on its 'for teams' pages to show productivity gains before and after adoption.
- Linear, Employs a structured before/after layout in case studies to contrast old workflows with Linear-powered ones.
- Stripe, Uses comparison layouts in its Payments and Billing product pages to show legacy checkout friction versus Stripe's flow.
- Webflow, Presents before/after developer-vs-designer workflow comparisons to position its no-code builder against hand-coded sites.
FAQ
How many points per side should I include?
Three to five points strike the right balance. Below three, the card feels thin; above five, users stop reading. Prioritize the metrics that mean most to your target reader.
Can I use this without the optional image?
Yes. The image block is guarded by `side.image &&` so the card renders cleanly without it. The stats list starts right at the top of the padding area.
How do I change the accent color for the 'after' card?
The component reads `var(--color-accent)` from the active theme preset. Swap the theme at the `data-theme` attribute level and both the border and heading update automatically.
Does the animation replay when the user scrolls back up?
No. All three motion.div elements use `viewport={{ once: true }}`, so each card animates only the first time it enters the viewport.