Layr Components

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
138
220
1
96 px
1
#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.