Components / Numbers and data
Photo Column
One bar of a bar chart made of a photo, as tall as its value; columns side by side rise one after another as they scroll into view.
Scroll insideFull screen
Props
#0E0E0D
#6E6E6B
#F5F4F1
Set in code: Photo. 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. Photo Column shows up in the Insert menu, and its controls appear in the right panel.
// Photo Column · Layr Components
//
// One bar of a bar chart, made of a photo: as tall as its value against "Full
// height at", with the number and a caption on top. It rises as it scrolls into
// view; side-by-side columns with Place 1, 2, 3… rise one after another.
// Paste into a new code file in Framer. Put one in each item of a horizontal stack
// or collection list, all the same height. Without a photo it shows a plain tone.
import { addPropertyControls, ControlType, useIsStaticRenderer } from "framer"
import { useEffect, useRef, type CSSProperties } from "react"
type Img = { src: string; srcSet?: string; alt?: string; positionX?: string; positionY?: string }
interface PhotoColumnProps {
photo?: Img
amount: number
fullAt: number
before: string
unit: string
measure: string
client: string
place: number
labelRoom: number
fontFamily: string
scale: number
imageWidth: string
textColour: string
mutedColour: string
tintColour: string
style?: CSSProperties
}
const SHEET = `
.lyr-pcol{position:relative;display:flex;flex-direction:column;justify-content:flex-end;width:100%;height:100%;--lyr-pcol-p:1;-webkit-font-smoothing:antialiased}
.lyr-pcol-lab{flex:none;padding-bottom:calc(14px * var(--lyr-pcol-s))}
.lyr-pcol-num{font-size:calc(34px * var(--lyr-pcol-s));line-height:1;font-weight:500;letter-spacing:-.035em;font-variant-numeric:tabular-nums;white-space:nowrap}
.lyr-pcol-what{margin-top:calc(7px * var(--lyr-pcol-s));font-size:13px;line-height:1.3;letter-spacing:-.005em;color:var(--lyr-pcol-muted)}
.lyr-pcol-box{position:relative;flex:none;width:100%;height:calc((100% - var(--lyr-pcol-room)) * var(--lyr-pcol-h) * var(--lyr-pcol-p));overflow:hidden;background:var(--lyr-pcol-tint)}
.lyr-pcol-box img,.lyr-pcol-empty{position:absolute;left:0;bottom:0;width:100%;height:calc(100% / var(--lyr-pcol-p));object-fit:cover;display:block}
.lyr-pcol-empty{background:linear-gradient(175deg,rgba(255,255,255,.1),rgba(255,255,255,0) 70%)}
@media (prefers-reduced-motion:reduce){.lyr-pcol{--lyr-pcol-p:1!important}}
`
const clamp = (v: number, lo = 0, hi = 1) => Math.min(hi, Math.max(lo, v))
const ease = (t: number) => 1 - Math.pow(1 - t, 3)
// How short a column is before it rises, as a share of its full height.
const LOW = 0.16
/**
* @framerIntrinsicWidth 200
* @framerIntrinsicHeight 460
*
* @framerSupportedLayoutWidth any
* @framerSupportedLayoutHeight any
*/
export default function PhotoColumn(props: PhotoColumnProps) {
const {
photo,
amount = 138,
fullAt = 220,
before = "+",
unit = "%",
measure = "New sign-ups",
client = "Spring campaign",
place = 1,
labelRoom = 96,
fontFamily = "Inter",
scale = 1,
imageWidth = "20vw",
textColour = "#F2F0EA",
mutedColour = "#8B8880",
tintColour = "#1B1B19",
style,
} = props
const isStatic = useIsStaticRenderer()
const root = useRef<HTMLDivElement>(null)
const v = Number(amount) || 0
const h = clamp(v / Math.max(1, Number(fullAt) || 100), 0.04, 1)
const k = Math.max(1, Math.round(Number(place) || 1))
const pre = String(before ?? "")
const post = String(unit ?? "").trim()
const what = String(measure ?? "").trim()
const who = String(client ?? "").trim()
useEffect(() => {
const el = root.current
if (!el) return
if (isStatic || (window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches)) {
el.style.setProperty("--lyr-pcol-p", "1")
return
}
let goal = 0,
now = 0,
raf = 0,
last = 0
const read = () => {
const vh = window.innerHeight
const r = el.getBoundingClientRect()
const from = vh * 0.96
let to = vh * 0.84 - r.height
// Near the end of the page the column may never reach the line: finish where the page ends.
const reach = r.top + window.scrollY - (document.documentElement.scrollHeight - vh)
if (reach > to) to = reach + 8
const g = from - to > 24 ? (from - r.top) / (from - to) : r.top < vh ? 1 : 0
goal = clamp(g * 1.3 - (k - 1) * 0.05)
}
const tick = (t: number) => {
const dt = last ? Math.min(64, t - last) : 16
last = t
now += (goal - now) * (1 - Math.exp(-dt / 140))
if (Math.abs(goal - now) < 0.0006) now = goal
el.style.setProperty("--lyr-pcol-p", String(Math.round((LOW + (1 - LOW) * ease(now)) * 10000) / 10000))
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)
}
}, [isStatic, k])
const s = Number(scale) || 1
const vars = {
"--lyr-pcol-muted": mutedColour,
"--lyr-pcol-tint": tintColour,
"--lyr-pcol-s": s,
"--lyr-pcol-room": Math.max(40, Number(labelRoom) || 96) + "px",
"--lyr-pcol-h": h,
} as CSSProperties
const img = photo && photo.src ? photo : null
return (
<div ref={root} className="lyr-pcol" style={{ ...style, ...vars, color: textColour, fontFamily: `"${String(fontFamily || "Inter").replace(/['"]/g, "")}", "Inter", system-ui, sans-serif` }}>
<style dangerouslySetInnerHTML={{ __html: SHEET }} />
<div className="lyr-pcol-lab">
<div className="lyr-pcol-num">{pre + Math.round(v).toLocaleString("en-GB") + post}</div>
<div className="lyr-pcol-what">
{what}
{what && who ? <br /> : null}
{who}
</div>
</div>
<div className="lyr-pcol-box">
{img ? (
<img loading="lazy" decoding="async" sizes={imageWidth || "20vw"} srcSet={img.srcSet} src={img.src} alt={img.alt || ""} style={{ objectPosition: `${img.positionX || "50%"} ${img.positionY || "50%"}` }} />
) : (
<i className="lyr-pcol-empty" />
)}
</div>
</div>
)
}
addPropertyControls(PhotoColumn, {
photo: { type: ControlType.ResponsiveImage, title: "Photo" },
amount: { type: ControlType.Number, title: "Value", min: 0, max: 10000, step: 1, defaultValue: 138, description: "The number this column stands for. Taller means more." },
fullAt: { type: ControlType.Number, title: "Full height at", min: 1, max: 10000, step: 1, defaultValue: 220, description: "The value that fills the whole height. Set it a little above your largest value." },
before: { type: ControlType.String, title: "Before value", defaultValue: "+", description: "Printed in front of the number." },
unit: { type: ControlType.String, title: "After value", defaultValue: "%", description: "Printed after the number." },
measure: { type: ControlType.String, title: "Measure", defaultValue: "New sign-ups" },
client: { type: ControlType.String, title: "Client", defaultValue: "Spring campaign" },
place: { type: ControlType.Number, title: "Place", min: 1, max: 24, step: 1, defaultValue: 1, description: "Bind to the list's item index, so the columns rise one after another." },
labelRoom: { type: ControlType.Number, title: "Label room", min: 40, max: 200, step: 4, unit: "px", defaultValue: 96, description: "Height kept free above the tallest column for its number and caption." },
fontFamily: { type: ControlType.String, title: "Font family", defaultValue: "Inter", description: "Use the family name of your text styles." },
scale: { type: ControlType.Number, title: "Scale", min: 0.6, max: 1.4, step: 0.05, defaultValue: 1 },
imageWidth: { type: ControlType.String, title: "Image width", defaultValue: "20vw", description: "How wide the photo is drawn at most, for choosing a file size." },
textColour: { type: ControlType.Color, title: "Text colour", defaultValue: "#F2F0EA" },
mutedColour: { type: ControlType.Color, title: "Muted colour", defaultValue: "#8B8880" },
tintColour: { type: ControlType.Color, title: "Tint colour", defaultValue: "#1B1B19" },
})
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.