Layr Components

Components / Numbers and data

Diff

A result written as a code change: the old number struck out on a minus line, the new one arriving on a plus line as the row scrolls in.

Scroll insideFull screen
Props
56 px
16 px
#0E0E0D
#A3A3A0
#FF4A1C

Scroll down to apply the change. Replay starts it again.

In Framer, open Assets, add a new code file, paste this over everything in it and save. Diff shows up in the Insert menu, and its controls appear in the right panel.
// Diff · Layr Components
//
// One result written as a code change: the old number sits on a removed line
// and is struck out, then the new number arrives on an added line, the first
// time the row scrolls into view. "One line" shows only the two numbers side
// by side. Without JavaScript both lines simply show.
// Paste into a new code file in Framer. Give it a width; its height follows the text.

import { addPropertyControls, ControlType, useIsStaticRenderer } from "framer"
import { useEffect, useRef, type CSSProperties } from "react"

interface DiffProps {
    measure: string
    was: string
    figure: string
    unit: string
    inline: boolean
    size: number
    labelSize: number
    fontFamily: string
    ink: string
    quietTone: string
    accent: string
    style?: CSSProperties
}

const CSS = `
.lyr-dif{width:100%;-webkit-font-smoothing:antialiased;font-variant-numeric:tabular-nums}
.lyr-dif-l{display:grid;grid-template-columns:1.9em minmax(0,1fr) auto;align-items:baseline;column-gap:0;padding:.22em 0}
.lyr-dif-s{font-size:1.5em;line-height:1}
.lyr-dif-t{min-width:0;padding-right:1em}
.lyr-dif-v{font-size:var(--v);line-height:1;letter-spacing:-0.05em;white-space:nowrap}
.lyr-dif-old{color:var(--quiet)}
.lyr-dif-x{background:linear-gradient(currentColor,currentColor) no-repeat 0 54% / 100% .07em;transition:background-size .5s ease}
.lyr-dif-old .lyr-dif-v .lyr-dif-x{background-position-y:52%;background-size:100% .055em}
.lyr-dif-new{color:var(--ink);transition:opacity .5s ease .35s,transform .5s ease .35s}
.lyr-dif-new .lyr-dif-s{color:var(--accent)}
.lyr-dif.lyr-dif-wait .lyr-dif-x{background-size:0% .07em;transition:none}
.lyr-dif.lyr-dif-wait .lyr-dif-new{opacity:0;transform:translate3d(0,.5em,0);transition:none}
.lyr-dif.lyr-dif-i{width:100%;display:flex;justify-content:flex-end;align-items:baseline;gap:.5em;white-space:nowrap}
.lyr-dif-i .lyr-dif-old .lyr-dif-x{background-position-y:52%;background-size:100% .055em}
.lyr-dif-i.lyr-dif-wait .lyr-dif-old .lyr-dif-x{background-size:0% .055em}
@media (prefers-reduced-motion: reduce){.lyr-dif-x,.lyr-dif-new{transition:none}}
`

/**
 * @framerIntrinsicWidth 600
 *
 * @framerSupportedLayoutWidth fixed
 * @framerSupportedLayoutHeight auto
 */
export default function Diff(props: DiffProps) {
    const {
        measure = "Page load, 95th percentile",
        was = "840 ms",
        figure = "96",
        inline = false,
        size = 56,
        labelSize = 16,
        fontFamily = "Inter",
        ink = "#F0ECE4",
        quietTone = "#625F58",
        accent = "#FFB02E",
    } = props
    const unit = String(props.unit ?? "ms").trim()
    const isStatic = useIsStaticRenderer()
    const root = useRef<HTMLDivElement>(null)
    const now = `${figure}${unit ? (/^[%°]/.test(unit) ? "" : " ") + unit : ""}`
    const family = `"${fontFamily}", "Inter", system-ui, sans-serif`

    useEffect(() => {
        if (isStatic) return
        const el = root.current
        if (!el || typeof IntersectionObserver !== "function") return
        if (typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches) return
        const r = el.getBoundingClientRect()
        if (r.top < window.innerHeight * 0.7 && r.bottom > 0) return
        el.classList.add("lyr-dif-wait")
        const io = new IntersectionObserver(
            (entries) => {
                if (entries.some((e) => e.isIntersecting)) {
                    el.getBoundingClientRect()
                    el.classList.remove("lyr-dif-wait")
                    io.disconnect()
                }
            },
            { rootMargin: "0px 0px -30% 0px" }
        )
        io.observe(el)
        return () => io.disconnect()
    }, [isStatic, figure, was])

    if (inline) {
        const line = { "--v": `${size}px`, "--ink": ink, "--quiet": quietTone, fontFamily: family, ...props.style } as CSSProperties
        return (
            <div ref={root} className="lyr-dif lyr-dif-i" style={line}>
                <style dangerouslySetInnerHTML={{ __html: CSS }} />
                {was ? (
                    <span className="lyr-dif-v lyr-dif-old">
                        <span className="lyr-dif-x">{was}</span>
                    </span>
                ) : null}
                <span className="lyr-dif-v lyr-dif-new">{now}</span>
            </div>
        )
    }
    const vars = { "--v": `${size}px`, "--ink": ink, "--quiet": quietTone, "--accent": accent, fontFamily: family, fontSize: labelSize, letterSpacing: "-0.012em", lineHeight: 1.3, ...props.style } as CSSProperties
    return (
        <div ref={root} className="lyr-dif" style={vars}>
            <style dangerouslySetInnerHTML={{ __html: CSS }} />
            {was ? (
                <div className="lyr-dif-l lyr-dif-old">
                    <span className="lyr-dif-s" aria-hidden="true">
                        −
                    </span>
                    <span className="lyr-dif-t">
                        <span className="lyr-dif-x">{measure}</span>
                    </span>
                    <span className="lyr-dif-v">
                        <span className="lyr-dif-x">{was}</span>
                    </span>
                </div>
            ) : null}
            <div className="lyr-dif-l lyr-dif-new">
                <span className="lyr-dif-s" aria-hidden="true">
                    +
                </span>
                <span className="lyr-dif-t">{measure}</span>
                <span className="lyr-dif-v">{now}</span>
            </div>
        </div>
    )
}

addPropertyControls(Diff, {
    measure: { type: ControlType.String, title: "What it measures", defaultValue: "Page load, 95th percentile" },
    was: { type: ControlType.String, title: "Before", defaultValue: "840 ms", description: "The number before the work, with its unit. Leave empty to show only the added line." },
    figure: { type: ControlType.String, title: "Number", defaultValue: "96" },
    unit: { type: ControlType.String, title: "Unit", defaultValue: "ms" },
    inline: { type: ControlType.Boolean, title: "One line", defaultValue: false, description: "Only the two numbers, side by side, for a card or panel header." },
    size: { type: ControlType.Number, title: "Number size", defaultValue: 56, min: 16, max: 200, step: 1, unit: "px" },
    labelSize: { type: ControlType.Number, title: "Text size", defaultValue: 16, min: 10, max: 40, step: 1, unit: "px" },
    fontFamily: { type: ControlType.String, title: "Font", defaultValue: "Inter", description: "The family name used by the site's text styles." },
    ink: { type: ControlType.Color, title: "Ink", defaultValue: "#F0ECE4" },
    quietTone: { type: ControlType.Color, title: "Quiet tone", defaultValue: "#625F58", description: "The removed line." },
    accent: { type: ControlType.Color, title: "Accent", defaultValue: "#FFB02E", description: "The plus sign." },
})

In this preview

No pictures needed.