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
#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.