Custom Framer Component · Free, unlicensed
Before/After Results
An animated before/after results comparison that crossfades each photo pair and lands a value-improved figure per row.
resultsbefore-aftercomparisongsap
This is a standalone Framer Code File — not installed with the CLI, not gated behind a subscription. Copy the whole file and paste it into Framer via Assets → Code → Create Code File.
Property controls
These are Framer's own addPropertyControls fields,
which show up as the right-hand properties panel once this is pasted into Framer. They are
not React props and have nothing to do with this site's licensed component API.
| Panel label | Control key | Control type |
|---|---|---|
| Before/after pairs | rows | Array |
| Headline | headlineTop | String |
| Accent headline | headlineAccent | String |
| Left heading | initialHeading | String |
| Right heading | improvedHeading | String |
| Photo label | replacedLabel | String |
| Outcome labels | showOutcomeLabels | Boolean |
| Disclaimer | footnote | String |
| Animate | autoplay | Boolean |
| Loop | loop | Boolean |
| Start delay | startDelay | Number |
| Fade | fadeDuration | Number |
| Row stagger | rowStagger | Number |
| Finished hold | finishedHold | Number |
| Content width | maxWidth | Number |
| Top padding | paddingTop | Number |
| Bottom padding | paddingBottom | Number |
| Background | background | Color |
| Initial | initial | Color |
| Denied | denied | Color |
| Improved | improved | Color |
| Final amount | improvedInk | Color |
| Heading | headingInk | Color |
| Accent | accent | Color |
| Arrow + label | arrow | Color |
Component source
The complete, unmodified file — copy the whole thing.
import { addPropertyControls, ControlType } from "framer"
import { useId, useRef } from "react"
import { gsap } from "gsap"
import { useGSAP } from "@gsap/react"
gsap.registerPlugin(useGSAP)
export const META = {
slug: "before-after-results",
title: "Before/After Results",
description: "An animated before/after results comparison that crossfades each photo pair and lands a value-improved figure per row.",
tags: ["results", "before-after", "comparison", "gsap"],
category: "custom-framer",
}
/**
* Animated before/after results comparison.
*
* Each row begins on a "before" photo, crossfades to the "after" photo, then lands a badge
* confirming the change.
*
* Images are Framer Responsive Image properties. Do not replace them with local paths: a
* pasted code component cannot resolve local assets after publish.
*
* The example figures shown are illustrative — swap them for your own, and keep the disclaimer
* visible until your own figures and images are approved for publish.
*
* @framerSupportedLayoutWidth fixed
* @framerSupportedLayoutHeight any-prefer-fixed
* @framerIntrinsicWidth 1200
* @framerIntrinsicHeight 940
* @framerDisableUnlink
*/
export default function BeforeAfterResults(props) {
const {
rows = DEFAULT_ROWS,
headlineTop = "Real Results.",
headlineAccent = "Real People,",
initialHeading = "Before",
improvedHeading = "After",
replacedLabel = "Updated",
footnote = DEFAULT_FOOTNOTE,
showOutcomeLabels = true,
autoplay = true,
loop = true,
startDelay = 0.45,
fadeDuration = 1.15,
rowStagger = 0.14,
finishedHold = 2.8,
background = "#F7F7F4",
initial = "#C57B2C",
denied = "#CC3C30",
improved = "#D7E8D4",
improvedInk = "#397A45",
headingInk = "#29313A",
accent = "#F97316",
arrow = "#2DAA50",
maxWidth = 1180,
paddingTop = 72,
paddingBottom = 72,
style,
} = props
const rootRef = useRef(null)
const scope = "fc-" + useId().replace(/[^a-zA-Z0-9]/g, "")
const list = Array.isArray(rows) ? rows : []
useGSAP(
() => {
if (!rootRef.current || list.length === 0) return
const finishedImages = gsap.utils.toArray(".fc-outcome-after")
const badges = gsap.utils.toArray(".fc-replaced-badge")
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches
if (!autoplay || reduceMotion) {
gsap.set(finishedImages, { autoAlpha: 1 })
gsap.set(badges, { autoAlpha: 1, scale: 1, y: 0 })
return
}
gsap.set(finishedImages, { autoAlpha: 0, scale: 1.035 })
gsap.set(badges, { autoAlpha: 0, scale: 0.86, y: 8 })
const duration = Math.max(0.2, Number(fadeDuration) || 1.15)
const stagger = Math.max(0, Number(rowStagger) || 0)
const hold = Math.max(0, Number(finishedHold) || 0)
const timeline = gsap.timeline({
paused: true,
repeat: loop ? -1 : 0,
repeatDelay: 0,
defaults: { ease: "power2.inOut" },
})
timeline.addLabel("reveal", Math.max(0, Number(startDelay) || 0))
timeline.to(finishedImages, { autoAlpha: 1, scale: 1, duration, stagger }, "reveal")
timeline.to(
badges,
{ autoAlpha: 1, scale: 1, y: 0, duration: 0.42, ease: "back.out(1.6)", stagger },
`reveal+=${duration * 0.62}`
)
if (loop) {
const lastReveal = duration + Math.max(0, list.length - 1) * stagger
timeline.to(
badges,
{ autoAlpha: 0, scale: 0.92, duration: 0.25, stagger: stagger * 0.5 },
`reveal+=${lastReveal + hold}`
)
timeline.to(
finishedImages,
{ autoAlpha: 0, scale: 1.035, duration: duration * 0.7, stagger },
"<"
)
}
const observer = new IntersectionObserver(
([entry]) => {
if (!entry.isIntersecting) return
timeline.play()
observer.disconnect()
},
{ threshold: 0.22 }
)
observer.observe(rootRef.current)
return () => observer.disconnect()
},
{
scope: rootRef,
dependencies: [list.length, autoplay, loop, startDelay, fadeDuration, rowStagger, finishedHold],
revertOnUpdate: true,
}
)
if (list.length === 0) return <Empty style={style} />
return (
<div className={scope} style={style} ref={rootRef}>
<style>{css(scope, { background, initial, denied, improved, improvedInk, headingInk, accent, arrow, maxWidth, paddingTop, paddingBottom })}</style>
<section className="fc-outcomes" aria-label="Before and after results">
<div className="fc-outcome-inner">
<h2 className="fc-outcome-headline">
<strong>{headlineAccent}</strong>{" "}
<span>{headlineTop}</span>
</h2>
<div className="fc-column-heads" aria-hidden="true">
<span />
<span>{initialHeading}</span>
<span />
<span>{improvedHeading}</span>
</div>
<div className="fc-outcome-list">
{list.map((row, i) => {
const isDenied = row.type === "denied"
const before = row.damagedImage?.src ? row.damagedImage : null
const after = row.finishedImage?.src ? row.finishedImage : null
const itemLabel = row.itemLabel || `Example ${i + 1}`
return (
<article className="fc-outcome-row" key={`${row.initialAmount}-${row.improvedAmount}-${i}`}>
<figure className="fc-outcome-pair">
{before ? (
<img className="fc-outcome-before" src={before.src} srcSet={before.srcSet} alt="" loading="lazy" decoding="async" />
) : (
<span className="fc-outcome-placeholder fc-outcome-before">Before</span>
)}
{after ? (
<img className="fc-outcome-after" src={after.src} srcSet={after.srcSet} alt="" loading="lazy" decoding="async" />
) : (
<span className="fc-outcome-placeholder fc-outcome-after">After</span>
)}
<span className="fc-replaced-badge" aria-hidden="true">{replacedLabel}</span>
<figcaption className="fc-sr-only">{itemLabel}: before and after.</figcaption>
</figure>
<div className={isDenied ? "fc-before is-denied" : "fc-before"}>
<span className="fc-status" aria-hidden="true"><CrossIcon /></span>
<span className="fc-before-amount">{row.initialAmount}</span>
{showOutcomeLabels && row.outcome ? <span className="fc-before-label">{row.outcome}</span> : null}
</div>
<div className="fc-bridge" aria-hidden="true">
<span className="fc-arrow"><ArrowIcon /></span>
</div>
<div className="fc-after"><span className="fc-after-amount">{row.improvedAmount}</span></div>
</article>
)
})}
</div>
{footnote ? <p className="fc-outcome-footnote">{footnote}</p> : null}
</div>
</section>
</div>
)
}
/* Invented round numbers, deliberately. These started life as six REAL
settlement amounts from a client's actual claim file — renaming the labels
around them did not make them demo data, it just made them harder to spot.
Never reintroduce figures carried over from a source project here: a
component in a public catalogue is published data. */
const DEFAULT_ROWS = [
{ initialAmount: "$40,000", outcome: "Below expectations", improvedAmount: "$65,000", type: "partial", itemLabel: "Example one" },
{ initialAmount: "$60,000", outcome: "Significant gap", improvedAmount: "$145,000", type: "partial", itemLabel: "Example two" },
{ initialAmount: "$0", outcome: "Resolved", improvedAmount: "$85,000", type: "denied", itemLabel: "Example three" },
{ initialAmount: "$30,000", outcome: "Below expectations", improvedAmount: "$105,000", type: "partial", itemLabel: "Example four" },
{ initialAmount: "$0", outcome: "Resolved", improvedAmount: "$60,000", type: "denied", itemLabel: "Example five" },
{ initialAmount: "$5,000", outcome: "Below expectations", improvedAmount: "$70,000", type: "partial", itemLabel: "Example six" },
]
const DEFAULT_FOOTNOTE = "*Example results shown for illustration."
function CrossIcon() {
return <svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="m7.5 7.5 9 9m0-9-9 9" stroke="currentColor" strokeWidth="3.2" strokeLinecap="round" /></svg>
}
function ArrowIcon() {
return <svg viewBox="0 0 42 24" fill="none" aria-hidden="true"><path d="M3 12h30M26 5l8 7-8 7" stroke="currentColor" strokeWidth="4.5" strokeLinecap="square" strokeLinejoin="miter" /></svg>
}
function Empty({ style }) {
return <div style={{ ...style, display: "grid", placeItems: "center", minHeight: 160, background: "#F7F7F4", color: "#29313A", font: "500 13px/1.4 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" }}>Add at least one outcome in the properties panel.</div>
}
function css(scope, colors) {
const { background, initial, denied, improved, improvedInk, headingInk, accent, arrow, maxWidth, paddingTop, paddingBottom } = colors
const s = `.${scope}`
const width = Math.max(320, Number(maxWidth) || 1180)
const top = Math.max(0, Number(paddingTop) || 0)
const bottom = Math.max(0, Number(paddingBottom) || 0)
return `
${s} { container: fc-outcomes-shell / inline-size; width: 100%; min-width: 0; max-width: 100%; }
${s} .fc-outcomes {
--fc-content-max: 1356px; --fc-gutter: 30px;
--fc-edge: max(var(--fc-gutter), calc((100% - var(--fc-content-max)) / 2 + var(--fc-gutter)));
container: fc-outcomes / inline-size; box-sizing: border-box; width: 100%;
padding: ${top}px var(--fc-edge) ${bottom}px; background: ${background}; color: ${headingInk};
font-family: var(--framer-font-family-preview, var(--framer-font-family, inherit));
font-style: var(--framer-font-style-preview, var(--framer-font-style, normal));
font-variation-settings: var(--framer-font-variation-axes-preview, var(--framer-font-variation-axes, normal));
-moz-font-feature-settings: var(--framer-font-open-type-features, initial);
-webkit-font-feature-settings: var(--framer-font-open-type-features, initial);
font-feature-settings: var(--framer-font-open-type-features, initial);
-webkit-font-smoothing: antialiased;
}
${s} .fc-outcomes *, ${s} .fc-outcomes *::before, ${s} .fc-outcomes *::after { box-sizing: border-box; }
${s} .fc-outcome-inner { width: min(100%, ${width}px); margin: 0 auto; }
${s} .fc-outcome-headline {
margin: 0 0 clamp(30px, 5cqi, 62px);
font-family: var(--framer-font-family-preview, var(--framer-font-family, inherit));
font-style: var(--framer-font-style-preview, var(--framer-font-style, normal));
font-weight: var(--framer-font-weight-preview, var(--framer-font-weight, 600));
font-size: calc(var(--framer-font-size, clamp(34px, 4.4cqi, 56px)) * var(--framer-font-size-scale, 1));
line-height: var(--framer-line-height, 1.1em);
letter-spacing: var(--framer-letter-spacing, -.02em);
text-transform: var(--framer-text-transform, none);
text-align: var(--framer-text-alignment, start);
color: var(--framer-text-color, ${headingInk});
font-variation-settings: var(--framer-font-variation-axes-preview, var(--framer-font-variation-axes, normal));
-moz-font-feature-settings: var(--framer-font-open-type-features, initial);
-webkit-font-feature-settings: var(--framer-font-open-type-features, initial);
font-feature-settings: var(--framer-font-open-type-features, initial);
text-wrap: var(--framer-text-wrap-override, var(--framer-text-wrap, balance));
}
${s} .fc-outcome-headline span, ${s} .fc-outcome-headline strong {
display: inline; font: inherit; letter-spacing: inherit;
}
${s} .fc-outcome-headline strong { color: ${accent}; }
${s} .fc-column-heads, ${s} .fc-outcome-row {
display: grid; grid-template-columns: minmax(150px, .82fr) minmax(260px, 1.36fr) clamp(48px, 5.8cqi, 68px) minmax(210px, 1fr);
}
${s} .fc-column-heads {
align-items: end; padding-bottom: 7px; font-family: inherit;
font-size: clamp(15px, 2.15cqi, 27px); font-weight: 600;
line-height: .96; letter-spacing: -.025em; text-transform: uppercase;
}
${s} .fc-column-heads span:nth-child(2), ${s} .fc-column-heads span:nth-child(4) { padding-inline: clamp(8px, 1.1cqi, 14px); }
${s} .fc-outcome-list { display: grid; gap: clamp(4px, .55cqi, 7px); }
${s} .fc-outcome-row { min-width: 0; min-height: clamp(70px, 8.2cqi, 98px); }
${s} .fc-outcome-pair {
position: relative; min-width: 0; min-height: inherit; margin: 0; overflow: hidden;
border-radius: 9px 0 0 9px; background: color-mix(in srgb, ${headingInk} 12%, ${background});
}
${s} .fc-outcome-pair img, ${s} .fc-outcome-placeholder {
position: absolute; inset: 0; display: grid; width: 100%; height: 100%; place-items: center; object-fit: cover;
}
${s} .fc-outcome-placeholder {
background: linear-gradient(135deg, color-mix(in srgb, ${headingInk} 22%, ${background}), color-mix(in srgb, ${headingInk} 8%, ${background}));
color: color-mix(in srgb, ${headingInk} 65%, ${background}); font-size: 11px; font-weight: 700; text-transform: uppercase;
}
${s} .fc-replaced-badge {
position: absolute; z-index: 3; left: 50%; bottom: clamp(8px, 1cqi, 12px); padding: .42em .65em .38em;
border-radius: 3px; background: ${arrow}; color: #fff; font-family: inherit;
font-size: clamp(12px, 1.65cqi, 20px); font-weight: 600; line-height: 1;
letter-spacing: -.02em; text-transform: uppercase; translate: -50% 0;
box-shadow: 0 4px 14px color-mix(in srgb, ${headingInk} 22%, transparent);
}
${s} .fc-before, ${s} .fc-after { min-width: 0; border-block: 1px solid color-mix(in srgb, ${headingInk} 30%, transparent); }
${s} .fc-before {
display: grid; grid-template-columns: auto minmax(0, auto) minmax(48px, 1fr); align-items: center;
gap: clamp(7px, 1cqi, 13px); padding: 0 clamp(8px, 1.1cqi, 14px); background: ${initial}; color: #fff;
}
${s} .fc-before.is-denied { background: ${denied}; }
${s} .fc-status {
display: grid; place-items: center; width: clamp(27px, 3.2cqi, 38px); aspect-ratio: 1;
border-radius: 50%; background: color-mix(in srgb, #fff 94%, transparent); color: ${initial};
}
${s} .fc-before.is-denied .fc-status { color: ${denied}; }
${s} .fc-status svg { display: block; width: 72%; height: 72%; }
${s} .fc-before-amount, ${s} .fc-after-amount {
white-space: nowrap; font-family: inherit;
font-size: clamp(25px, 4.1cqi, 49px); font-weight: 600; line-height: .9; letter-spacing: -.03em;
}
${s} .fc-before-label {
min-width: 0; padding-left: clamp(7px, 1cqi, 12px); border-left: 1px solid color-mix(in srgb, #fff 62%, transparent);
color: color-mix(in srgb, #fff 76%, ${initial}); font-size: clamp(9px, 1.25cqi, 14px); font-weight: 600;
font-style: italic; line-height: .94; letter-spacing: -.025em; text-transform: uppercase;
}
${s} .fc-before.is-denied .fc-before-label { color: #fff; font-style: normal; }
${s} .fc-bridge { position: relative; min-width: 0; background: ${improved}; }
${s} .fc-arrow {
position: absolute; z-index: 2; top: 50%; left: 50%; display: grid; place-items: center;
width: clamp(48px, 6.1cqi, 72px); aspect-ratio: 1; border-radius: 50%;
background: color-mix(in srgb, ${background} 93%, white); color: ${arrow}; transform: translate(-50%, -50%);
}
${s} .fc-arrow svg { display: block; width: 69%; height: auto; }
${s} .fc-after {
display: flex; align-items: center; padding: 0 clamp(12px, 2cqi, 25px);
border-right: 1px solid color-mix(in srgb, ${headingInk} 30%, transparent); border-radius: 0 9px 9px 0;
background: ${improved}; color: ${improvedInk};
}
${s} .fc-after-amount { display: block; margin-left: clamp(12px, 1.8cqi, 22px); }
${s} .fc-outcome-footnote { margin: clamp(14px, 1.8cqi, 20px) 0 0; color: color-mix(in srgb, ${headingInk} 68%, ${background}); font-size: clamp(10px, 1.05cqi, 12px); line-height: 1.5; }
${s} .fc-sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
@container fc-outcomes-shell (max-width: 767px) {
${s} .fc-outcomes { --fc-gutter: 15px; padding-top: ${top * 0.5}px; padding-bottom: ${bottom * 0.5}px; }
}
@container fc-outcomes (max-width: 620px) {
${s} .fc-outcome-headline { margin-bottom: 30px; font-size: calc(var(--framer-font-size, clamp(28px, 8cqi, 40px)) * var(--framer-font-size-scale, 1)); }
${s} .fc-column-heads { grid-template-columns: minmax(0, 1fr) 44px minmax(0, 1fr); font-size: clamp(14px, 5cqi, 20px); }
${s} .fc-column-heads span:first-child, ${s} .fc-column-heads span:nth-child(3) { display: none; }
${s} .fc-column-heads span:nth-child(2) { grid-column: 1; }
${s} .fc-column-heads span:nth-child(4) { grid-column: 3; }
${s} .fc-outcome-row { grid-template-columns: minmax(0, 1fr) 44px minmax(0, 1fr); min-height: 0; }
${s} .fc-outcome-pair { grid-column: 1 / -1; min-height: clamp(128px, 45cqi, 190px); border-radius: 10px 10px 0 0; }
${s} .fc-before, ${s} .fc-bridge, ${s} .fc-after { min-height: clamp(60px, 18cqi, 74px); }
${s} .fc-before { grid-template-columns: auto minmax(0, 1fr); gap: 7px; padding-inline: 8px; border-radius: 0 0 0 9px; }
${s} .fc-before-label { display: none; }
${s} .fc-status { width: clamp(25px, 8cqi, 31px); }
${s} .fc-before-amount, ${s} .fc-after-amount { font-size: clamp(23px, 8cqi, 33px); }
${s} .fc-arrow { width: 50px; }
${s} .fc-after { padding-inline: 8px; }
${s} .fc-after-amount { margin-left: 7px; }
${s} .fc-replaced-badge { bottom: 12px; font-size: 16px; }
}
`
}
addPropertyControls(BeforeAfterResults, {
rows: {
type: ControlType.Array, title: "Before/after pairs",
control: {
type: ControlType.Object,
controls: {
damagedImage: { type: ControlType.ResponsiveImage, title: "Before" },
finishedImage: { type: ControlType.ResponsiveImage, title: "After" },
itemLabel: { type: ControlType.String, title: "Description", defaultValue: "Example" },
initialAmount: { type: ControlType.String, title: "Initial", defaultValue: "$0" },
outcome: { type: ControlType.String, title: "Outcome", defaultValue: "Resolved" },
improvedAmount: { type: ControlType.String, title: "Final", defaultValue: "$0" },
type: { type: ControlType.Enum, title: "Tone", options: ["partial", "denied"], optionTitles: ["Partial / gap", "Fully resolved"], defaultValue: "partial" },
},
},
defaultValue: DEFAULT_ROWS, maxCount: 12,
},
headlineTop: { type: ControlType.String, title: "Headline", defaultValue: "Real Results." },
headlineAccent: { type: ControlType.String, title: "Accent headline", defaultValue: "Real People," },
initialHeading: { type: ControlType.String, title: "Left heading", defaultValue: "Before" },
improvedHeading: { type: ControlType.String, title: "Right heading", defaultValue: "After" },
replacedLabel: { type: ControlType.String, title: "Photo label", defaultValue: "Updated" },
showOutcomeLabels: { type: ControlType.Boolean, title: "Outcome labels", defaultValue: true },
footnote: { type: ControlType.String, title: "Disclaimer", displayTextArea: true, defaultValue: DEFAULT_FOOTNOTE },
autoplay: { type: ControlType.Boolean, title: "Animate", defaultValue: true },
loop: { type: ControlType.Boolean, title: "Loop", defaultValue: true, hidden: (p) => !p.autoplay },
startDelay: { type: ControlType.Number, title: "Start delay", defaultValue: 0.45, min: 0, max: 5, step: 0.05, unit: "s", hidden: (p) => !p.autoplay },
fadeDuration: { type: ControlType.Number, title: "Fade", defaultValue: 1.15, min: 0.2, max: 5, step: 0.05, unit: "s", hidden: (p) => !p.autoplay },
rowStagger: { type: ControlType.Number, title: "Row stagger", defaultValue: 0.14, min: 0, max: 1, step: 0.02, unit: "s", hidden: (p) => !p.autoplay },
finishedHold: { type: ControlType.Number, title: "Finished hold", defaultValue: 2.8, min: 0, max: 15, step: 0.1, unit: "s", hidden: (p) => !p.autoplay || !p.loop },
maxWidth: { type: ControlType.Number, title: "Content width", defaultValue: 1180, min: 320, max: 1356, step: 10, unit: "px" },
paddingTop: { type: ControlType.Number, title: "Top padding", defaultValue: 72, min: 0, max: 240, step: 4, unit: "px" },
paddingBottom: { type: ControlType.Number, title: "Bottom padding", defaultValue: 72, min: 0, max: 240, step: 4, unit: "px" },
background: { type: ControlType.Color, title: "Background", defaultValue: "#F7F7F4" },
initial: { type: ControlType.Color, title: "Initial", defaultValue: "#C57B2C" },
denied: { type: ControlType.Color, title: "Denied", defaultValue: "#CC3C30" },
improved: { type: ControlType.Color, title: "Improved", defaultValue: "#D7E8D4" },
improvedInk: { type: ControlType.Color, title: "Final amount", defaultValue: "#397A45" },
headingInk: { type: ControlType.Color, title: "Heading", defaultValue: "#29313A" },
accent: { type: ControlType.Color, title: "Accent", defaultValue: "#F97316" },
arrow: { type: ControlType.Color, title: "Arrow + label", defaultValue: "#2DAA50" },
})
- 1. In Framer: Assets → Code → Create Code File.
- 2. Delete the placeholder and paste this entire file — it already carries its default export and addPropertyControls.