Layr Components

Components / Numbers and data

Share Band

A hairline band split into rival slices; the client's photo slice widens from its before share to its after share as you scroll.

Scroll insideFull screen
Props
9%
41%
5
4
90 vh
1
#0E0E0D
#6E6E6B
#E6E5E1
#F5F4F1
#0F0F0E

Set in code: Photo, Link. The preview fills these in with demo content.

Scroll inside the preview.

In Framer, open Assets, add a new code file, paste this over everything in it and save. Share Band shows up in the Insert menu, and its controls appear in the right panel.
// Share Band · Layr Components
//
// A hairline band is the whole market, split into rival slices; the photo is one
// client's part of it. As the page scrolls the photo widens from "Share before" to
// "Share after", the other slices give way and their numbers count along.
// Paste into a new code file in Framer. It fills its frame and opens as it comes
// into view. With Pin on it holds itself in the window while the page scrolls on
// (set its height to Fit); inside a sticky frame of a tall section it follows that
// section's scroll instead. Without a photo the client's slice is a plain light tone.

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

type Img = { src: string; srcSet?: string; alt?: string; positionX?: string; positionY?: string }

interface ShareBandProps {
    photo?: Img
    client: string
    shareBefore: number
    shareAfter: number
    market: string
    result: string
    link?: string
    linkLabel: string
    rivals: string
    rivalsShown: number
    clientPlace: number
    labels: "All" | "Client only" | "None"
    motion: "Follows scroll" | "Open" | "Closed"
    pin: boolean
    pinLength: number
    caption: boolean
    fontFamily: string
    scale: number
    imageWidth: string
    loadFirst: boolean
    textColour: string
    mutedColour: string
    lineColour: string
    tintColour: string
    onPhotoColour: string
    style?: CSSProperties
}

const RIVALS = "The market leader, The big suite, The built-in tool, The free option, Everyone else"

const SHEET = `
.lyr-shb-track{position:relative;width:100%;height:100%}
.lyr-shb-pin{position:relative;width:100%;height:100%}
.lyr-shb-pinned{height:calc(100vh + var(--lyr-shb-len) * 1vh);height:calc(100svh + var(--lyr-shb-len) * 1lvh)}
@media (max-width:809px){.lyr-shb-pinned{height:calc(100vh + var(--lyr-shb-len) * .78vh);height:calc(100svh + var(--lyr-shb-len) * .78lvh)}}
.lyr-shb-pinned>.lyr-shb-pin{position:sticky;top:0;height:100vh;height:100svh;box-sizing:border-box;padding:clamp(16px,4vw,48px)}
.lyr-shb{position:relative;display:flex;flex-direction:column;width:100%;height:100%;min-height:0;--lyr-shb-p:0;-webkit-font-smoothing:antialiased}
.lyr-shb-band{position:relative;flex:1 1 0;min-height:calc(96px * var(--lyr-shb-s));display:flex;overflow:hidden}
.lyr-shb-s{position:relative;flex:0 1 calc((var(--b) + (var(--a) - var(--b)) * var(--lyr-shb-p)) * 1%);min-width:0;overflow:hidden;container-type:inline-size;box-shadow:inset -1px 0 0 var(--lyr-shb-line)}
.lyr-shb-s:last-child{box-shadow:none}
.lyr-shb-t{position:absolute;inset:0;background:var(--lyr-shb-tint);opacity:var(--o)}
.lyr-shb-lab{position:absolute;left:calc(16px * var(--lyr-shb-s));top:calc(15px * var(--lyr-shb-s));white-space:nowrap}
.lyr-shb-num{font-size:calc(30px * var(--lyr-shb-s));line-height:1;font-weight:500;letter-spacing:-.035em;font-variant-numeric:tabular-nums}
.lyr-shb-name{margin-top:calc(7px * var(--lyr-shb-s));font-size:calc(13px * var(--lyr-shb-s) + 1px * (1 - var(--lyr-shb-s)) * 6);line-height:1.3;letter-spacing:-.005em;color:var(--lyr-shb-muted)}
.lyr-shb-you{box-shadow:none}
.lyr-shb-you img{position:absolute;top:0;left:50%;height:100%;width:calc(var(--m) / max(0.5, var(--b) + (var(--a) - var(--b)) * var(--lyr-shb-p)) * 100%);max-width:none;transform:translateX(-50%);object-fit:cover;display:block}
.lyr-shb-plain{position:absolute;inset:0;background:linear-gradient(170deg,rgba(255,255,255,0),rgba(0,0,0,.14)),var(--lyr-shb-ink)}
.lyr-shb-you:after{content:"";position:absolute;inset:0;background:linear-gradient(to bottom,rgba(255,255,255,.34),rgba(255,255,255,0) 44%);pointer-events:none}
@supports (color:oklch(from red clamp(0,(.6 - l) * 1000,1) 0 0)){.lyr-shb-you:after{--lyr-shb-veil:oklch(from var(--lyr-shb-on) clamp(0,(.6 - l) * 1000,1) 0 0);background:linear-gradient(to bottom,rgb(from var(--lyr-shb-veil) r g b / .32),rgb(from var(--lyr-shb-veil) r g b / 0) 44%)}}
.lyr-shb-you .lyr-shb-lab{z-index:1}
.lyr-shb-you .lyr-shb-num,.lyr-shb-you .lyr-shb-name{color:var(--lyr-shb-on)}
.lyr-shb-you .lyr-shb-name{opacity:.86}
@container (max-width:118px){.lyr-shb-name{display:none}}
@container (max-width:64px){.lyr-shb-num{display:none}}
.lyr-shb-quiet .lyr-shb-s:not(.lyr-shb-you) .lyr-shb-lab{display:none}
.lyr-shb-bare .lyr-shb-lab{display:none}
.lyr-shb-bare .lyr-shb-you:after{display:none}
.lyr-shb-foot{flex:none;display:flex;align-items:baseline;gap:18px;padding-top:calc(14px * var(--lyr-shb-s));font-size:13px;line-height:1.35;letter-spacing:-.005em;color:var(--lyr-shb-muted);min-height:calc(14px * var(--lyr-shb-s) + 18px)}
.lyr-shb-res{color:var(--lyr-shb-ink);opacity:0;transform:translateY(4px);transition:opacity .5s ease,transform .5s ease}
.lyr-shb-open .lyr-shb-res,.lyr-shb[data-done] .lyr-shb-res{opacity:1;transform:none}
.lyr-shb-link{color:var(--lyr-shb-ink);text-decoration:none;border-bottom:1px solid currentColor;padding-bottom:2px;white-space:nowrap;transition:opacity .2s ease}
.lyr-shb-link:hover{opacity:.62}
@media (max-width:809px){.lyr-shb-foot{flex-direction:column;gap:3px;align-items:flex-start}.lyr-shb-res .lyr-shb-link{margin-left:10px}}
@media (min-width:810px){.lyr-shb-res .lyr-shb-link{margin-left:14px}}
@media (prefers-reduced-motion:reduce){.lyr-shb{--lyr-shb-p:1!important}.lyr-shb-res{opacity:1;transform:none;transition:none}.lyr-shb-pinned{height:auto}.lyr-shb-pinned>.lyr-shb-pin{position:relative}}
`

// How the rivals split what the client does not have: largest first, by a fixed weighting.
const WEIGHTS = (n: number) => Array.from({ length: n }, (_, i) => Math.pow(n - i, 1.25) + 0.35)

/** Whole numbers that add up to `total`, as close as possible to `parts` (largest remainder). */
function wholeShares(parts: number[], total: number) {
    const sum = parts.reduce((a, b) => a + b, 0) || 1
    const exact = parts.map((p) => (p / sum) * total)
    const out = exact.map(Math.floor)
    let left = Math.round(total - out.reduce((a, b) => a + b, 0))
    const order = exact.map((v, i) => [v - Math.floor(v), i]).sort((x, y) => y[0] - x[0])
    for (let k = 0; k < order.length && left > 0; k++, left--) out[order[k][1]]++
    return out
}

const clamp = (v: number, lo = 0, hi = 1) => Math.min(hi, Math.max(lo, v))
const ease = (t: number) => (t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2)

// The section to follow: the parent of the nearest sticky layer around the band (its own pin, or one you
// built), when that section is taller than the window.
function trackOf(el: HTMLElement): HTMLElement | null {
    for (let n = el.parentElement; n && n !== document.body; n = n.parentElement) {
        if (getComputedStyle(n).position === "sticky") return n.parentElement
    }
    return null
}

/**
 * @framerIntrinsicWidth 1120
 * @framerIntrinsicHeight 320
 *
 * @framerSupportedLayoutWidth any
 * @framerSupportedLayoutHeight any
 */
export default function ShareBand(props: ShareBandProps) {
    const {
        photo,
        client = "Our client",
        shareBefore = 9,
        shareAfter = 41,
        market = "Share of search, small business software",
        result = "From 9% to 41% in a little over a year.",
        link,
        linkLabel = "Read the case study",
        rivals = RIVALS,
        rivalsShown = 5,
        clientPlace = 4,
        labels = "All",
        motion = "Follows scroll",
        pin = false,
        pinLength = 90,
        caption = true,
        fontFamily = "Inter",
        scale = 1,
        imageWidth = "50vw",
        loadFirst = false,
        textColour = "#F2F0EA",
        mutedColour = "#8B8880",
        lineColour = "#262623",
        tintColour = "#1B1B19",
        onPhotoColour = "#0F0F0E",
        style,
    } = props
    const isStatic = useIsStaticRenderer()
    const root = useRef<HTMLDivElement>(null)

    const before = clamp(Number(shareBefore) || 0, 0, 100)
    const after = clamp(Number(shareAfter) || 0, 0, 100)
    const all = String(rivals || "")
        .split(",")
        .map((s) => s.trim())
        .filter(Boolean)
    const shown = Math.max(0, Math.min(all.length, Math.round(Number(rivalsShown) || all.length)))
    const names = all.slice(0, shown)
    // With fewer rivals shown, the last one shown takes the last name ("everyone else").
    if (shown && shown < all.length) names[shown - 1] = all[all.length - 1]
    const count = names.length
    const w = WEIGHTS(count)
    const rb = wholeShares(w, 100 - Math.round(before))
    const ra = wholeShares(w, 100 - Math.round(after))
    const place = Math.max(1, Math.min(count + 1, Math.round(Number(clientPlace) || 1)))
    const mode = String(motion || "").toLowerCase()
    const fixed = isStatic || mode.includes("open") ? 1 : mode.includes("closed") ? 0 : null
    const lab = String(labels || "").toLowerCase()
    const start = fixed === 1 ? 1 : 0
    const pinned = !!pin && !isStatic

    useEffect(() => {
        const el = root.current
        if (!el) return
        const weights = WEIGHTS(count)
        const rB = wholeShares(weights, 100 - Math.round(before))
        const rA = wholeShares(weights, 100 - Math.round(after))
        const paint = (p: number) => {
            el.style.setProperty("--lyr-shb-p", String(Math.round(p * 10000) / 10000))
            const you = Math.round(before + (after - before) * p)
            const others = wholeShares(
                weights.map((_, i) => rB[i] + (rA[i] - rB[i]) * p),
                100 - you
            )
            el.querySelectorAll<HTMLElement>("[data-lyr-shb-n]").forEach((node) => {
                const k = node.getAttribute("data-lyr-shb-n")
                const t = (k === "you" ? you : others[Number(k)]) + "%"
                if (node.textContent !== t) node.textContent = t
            })
            if (p > 0.985) el.setAttribute("data-done", "")
            else if (p < 0.9) el.removeAttribute("data-done")
        }
        if (fixed !== null) {
            paint(fixed)
            return
        }
        if (window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
            paint(1)
            return
        }
        const track = trackOf(el)
        let goal = 0,
            now = 0,
            raf = 0,
            last = 0
        const read = () => {
            const vh = window.innerHeight
            if (track) {
                const r = track.getBoundingClientRect()
                const travel = r.height - vh
                if (travel > 8) {
                    goal = clamp((-r.top / travel - 0.03) / 0.8)
                    return
                }
            }
            const r = el.getBoundingClientRect()
            const from = vh * 0.92
            let to = vh * 0.5 - r.height * 0.5
            const reach = r.top + window.scrollY - (document.documentElement.scrollHeight - vh)
            if (reach > to) to = reach + 8
            goal = from - to > 24 ? clamp((from - r.top) / (from - to)) : r.top < vh ? 1 : 0
        }
        const tick = (t: number) => {
            const dt = last ? Math.min(64, t - last) : 16
            last = t
            now += (goal - now) * (1 - Math.exp(-dt / 120))
            if (Math.abs(goal - now) < 0.0006) now = goal
            paint(ease(now))
            raf = now === goal ? 0 : requestAnimationFrame(tick)
            if (!raf) last = 0
        }
        const wake = () => {
            read()
            if (!raf) raf = requestAnimationFrame(tick)
        }
        wake()
        window.addEventListener("scroll", wake, { passive: true })
        window.addEventListener("resize", wake)
        return () => {
            window.removeEventListener("scroll", wake)
            window.removeEventListener("resize", wake)
            if (raf) cancelAnimationFrame(raf)
        }
    }, [fixed, before, after, count, pinned])

    const s = Number(scale) || 1
    const vars = {
        "--lyr-shb-ink": textColour,
        "--lyr-shb-muted": mutedColour,
        "--lyr-shb-line": lineColour,
        "--lyr-shb-tint": tintColour,
        "--lyr-shb-on": onPhotoColour,
        "--lyr-shb-s": s,
        "--lyr-shb-len": Math.max(0, Number(pinLength) || 0),
    } as CSSProperties
    const img = photo && photo.src ? photo : null
    const youB = Math.round(before),
        youA = Math.round(after)
    const slices: ReactNode[] = names.map((name, i) => (
        <div className="lyr-shb-s" key={"r" + i} style={{ "--b": rb[i], "--a": ra[i] } as CSSProperties}>
            <div className="lyr-shb-t" style={{ "--o": Math.max(0.34, 1 - i * 0.16) } as CSSProperties} />
            <div className="lyr-shb-lab">
                <div className="lyr-shb-num" data-lyr-shb-n={i}>
                    {(start ? ra[i] : rb[i]) + "%"}
                </div>
                <div className="lyr-shb-name">{name}</div>
            </div>
        </div>
    ))
    slices.splice(
        place - 1,
        0,
        <div className="lyr-shb-s lyr-shb-you" key="you" style={{ "--b": youB, "--a": youA, "--m": Math.max(youB, youA, 1) } as CSSProperties}>
            {img ? (
                <img loading={loadFirst ? "eager" : "lazy"} decoding="async" sizes={imageWidth || "50vw"} srcSet={img.srcSet} src={img.src} alt={img.alt || ""} style={{ objectPosition: `${img.positionX || "50%"} ${img.positionY || "50%"}` }} />
            ) : (
                <i className="lyr-shb-plain" />
            )}
            <div className="lyr-shb-lab">
                <div className="lyr-shb-num" data-lyr-shb-n="you">
                    {(start ? youA : youB) + "%"}
                </div>
                <div className="lyr-shb-name">{client}</div>
            </div>
        </div>
    )

    return (
        <div className={"lyr-shb-track" + (pinned ? " lyr-shb-pinned" : "")} style={{ ...style, ...vars, color: textColour, fontFamily: `"${String(fontFamily || "Inter").replace(/['"]/g, "")}", "Inter", system-ui, sans-serif` }}>
            <style dangerouslySetInnerHTML={{ __html: SHEET }} />
            <div className="lyr-shb-pin">
                <div ref={root} className={"lyr-shb" + (lab.includes("client") ? " lyr-shb-quiet" : lab.includes("none") ? " lyr-shb-bare" : "") + (fixed === 1 ? " lyr-shb-open" : "")} style={{ "--lyr-shb-p": start } as CSSProperties}>
                    <div className="lyr-shb-band">{slices}</div>
                    {caption ? (
                        <div className="lyr-shb-foot">
                            <span>{market}</span>
                            <span className="lyr-shb-res">
                                {result}
                                {link && linkLabel ? (
                                    <Link href={link}>
                                        <a className="lyr-shb-link">{linkLabel}</a>
                                    </Link>
                                ) : null}
                            </span>
                        </div>
                    ) : null}
                </div>
            </div>
        </div>
    )
}

addPropertyControls(ShareBand, {
    photo: { type: ControlType.ResponsiveImage, title: "Photo", description: "The client's slice of the band. Any photo works; the middle of it shows first." },
    client: { type: ControlType.String, title: "Client", defaultValue: "Our client" },
    shareBefore: { type: ControlType.Number, title: "Share before", min: 0, max: 100, step: 1, unit: "%", defaultValue: 9, description: "How wide the photo starts, as a share of the band." },
    shareAfter: { type: ControlType.Number, title: "Share after", min: 0, max: 100, step: 1, unit: "%", defaultValue: 41, description: "How wide the photo ends once the page has scrolled." },
    market: { type: ControlType.String, title: "Market", defaultValue: "Share of search, small business software", description: "The line under the band, on the left." },
    result: { type: ControlType.String, title: "Result", defaultValue: "From 9% to 41% in a little over a year.", description: "Appears under the band, on the right, when the photo has finished widening." },
    link: { type: ControlType.Link, title: "Link" },
    linkLabel: { type: ControlType.String, title: "Link label", defaultValue: "Read the case study" },
    rivals: { type: ControlType.String, title: "Rivals", displayTextArea: true, defaultValue: "The market leader, The big suite, The built-in tool, The free option, Everyone else", description: "Names of the other slices, separated by commas, largest first. Their sizes are worked out for you." },
    rivalsShown: { type: ControlType.Number, title: "Rivals shown", min: 0, max: 8, step: 1, displayStepper: true, defaultValue: 5, description: "Use fewer on a phone. The rest are folded into the last name." },
    clientPlace: { type: ControlType.Number, title: "Client place", min: 1, max: 9, step: 1, displayStepper: true, defaultValue: 4, description: "Where the photo sits in the row, counted from the left." },
    labels: { type: ControlType.Enum, title: "Labels", options: ["All", "Client only", "None"], optionTitles: ["All", "Client only", "None"], defaultValue: "All" },
    motion: { type: ControlType.Enum, title: "Motion", options: ["Follows scroll", "Open", "Closed"], optionTitles: ["Follows scroll", "Open", "Closed"], defaultValue: "Follows scroll", description: "Open and Closed hold the band still at the after or before share." },
    pin: { type: ControlType.Boolean, title: "Pin", defaultValue: false, description: "Holds the band in the window while the page scrolls on, then lets it go. Set the height to Fit: it becomes one window plus Pin length." },
    pinLength: { type: ControlType.Number, title: "Pin length", min: 30, max: 300, step: 10, unit: "vh", defaultValue: 90, hidden: (p: Partial<ShareBandProps>) => !p.pin, description: "How much scrolling the pinned band takes. Phones use a little less." },
    caption: { type: ControlType.Boolean, title: "Caption", defaultValue: true, enabledTitle: "Show", disabledTitle: "Hide" },
    fontFamily: { type: ControlType.String, title: "Font family", defaultValue: "Inter", description: "Type the family used by your text styles." },
    scale: { type: ControlType.Number, title: "Scale", min: 0.6, max: 1.4, step: 0.05, defaultValue: 1, description: "Size of the numbers and names. Use about 0.75 on a phone." },
    imageWidth: { type: ControlType.String, title: "Image width", defaultValue: "50vw", description: "How wide the photo is drawn at most, for choosing a file size (for example 50vw)." },
    loadFirst: { type: ControlType.Boolean, title: "Load first", defaultValue: false, description: "Turn on only for the band at the top of a page." },
    textColour: { type: ControlType.Color, title: "Text colour", defaultValue: "#F2F0EA" },
    mutedColour: { type: ControlType.Color, title: "Muted colour", defaultValue: "#8B8880" },
    lineColour: { type: ControlType.Color, title: "Line colour", defaultValue: "#262623" },
    tintColour: { type: ControlType.Color, title: "Tint colour", defaultValue: "#1B1B19" },
    onPhotoColour: { type: ControlType.Color, title: "On photo colour", defaultValue: "#0F0F0E", description: "The number and name on the photo. Dark suits pale photos, white suits dark ones; the soft shade behind them follows." },
})

In this preview

The photos are demo pictures shown on this site only; they are not in the files you copy. Set your own through the image props.