Velocity X Components by Aidxn Design

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.

tsx
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. 1. In Framer: Assets → Code → Create Code File.
  2. 2. Delete the placeholder and paste this entire file — it already carries its default export and addPropertyControls.