Layr Components

Components / Numbers and data

Skyline Column

A building photo as a column on a ground line whose height follows a number; set in a row, the columns rise one by one into a skyline.

Props
64
90
26%
430 px
230 px
620 px
1
50%
4 px
15 px
#0E0E0D
#6E6E6B

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

Press Replay to watch the columns rise.

In Framer, open Assets, add a new code file, paste this over everything in it and save. Skyline Column shows up in the Insert menu, and its controls appear in the right panel.
// Skyline Column · Layr Components
//
// One building photo drawn as a column standing on a ground line, with a number
// above it. The column's height follows the number, so a row of them reads as a
// skyline and a bar chart at once, and the columns rise in one by one on load
// or when they come into view.
// Paste into a new code file in Framer and place one per column in a horizontal
// stack (or a collection list), bottom aligned, with Position counting 1, 2, 3.
// With its height set to Fit it follows the window height; with a fixed or fill
// height it fills its frame. Without a photo the column is a plain tone.

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

interface Photo {
    src?: string
    srcSet?: string
    alt?: string
}

interface SkylineColumnProps {
    photo?: Photo
    homes: number
    fullAt: number
    shortest: number
    spaceKept: number
    areaMin: number
    areaMax: number
    position: number
    unit: string
    unitOn: string
    before: string
    rise: string
    focus: number
    corner: number
    fontFamily: string
    numberSize: number
    numberColor: string
    unitColor: string
    imageWidth: string
    loadFirst: boolean
    style?: CSSProperties
}

// Shown when no photo is set.
const EMPTY = "linear-gradient(180deg, #c9cdd0, #8c9399)"

const SHEET = `
.lyr-sky{position:relative;width:100%;height:clamp(var(--sky-min),calc(100vh - var(--sky-keep)),var(--sky-max));height:clamp(var(--sky-min),calc(100svh - var(--sky-keep)),var(--sky-max))}
.lyr-sky.lyr-sky-fill{height:100%}
.lyr-sky.lyr-sky-canvas{height:var(--sky-max)}
.lyr-sky-photo{position:absolute;left:0;right:0;bottom:0;height:calc((100% - var(--sky-top)) * var(--sky-p));overflow:hidden;border-radius:var(--sky-r) var(--sky-r) 0 0;background:rgba(127,127,127,.12)}
.lyr-sky-photo img,.lyr-sky-photo .lyr-sky-e{display:block;width:100%;height:100%;object-fit:cover;object-position:var(--sky-x) 50%}
.lyr-sky-num{position:absolute;left:0;right:0;bottom:calc((100% - var(--sky-top)) * var(--sky-p) + 8px);margin:0;white-space:nowrap;font-variant-numeric:tabular-nums;font-weight:500;letter-spacing:-0.01em;line-height:1.2}
.lyr-sky-num span{font-weight:400}
@keyframes lyr-sky-rise{from{clip-path:inset(100% 0 0 0)}to{clip-path:inset(0 0 0 0)}}
@keyframes lyr-sky-in{from{opacity:0}to{opacity:1}}
.lyr-sky-go .lyr-sky-photo{animation:lyr-sky-rise .9s cubic-bezier(.2,.7,.1,1) both;animation-delay:calc(var(--sky-i) * 60ms)}
.lyr-sky-go .lyr-sky-num{animation:lyr-sky-in .5s ease both;animation-delay:calc(var(--sky-i) * 60ms + .55s)}
.lyr-sky-wait .lyr-sky-photo{clip-path:inset(100% 0 0 0)}
.lyr-sky-wait .lyr-sky-num{opacity:0}
@media (prefers-reduced-motion:reduce){.lyr-sky-go .lyr-sky-photo,.lyr-sky-go .lyr-sky-num{animation:none}.lyr-sky-wait .lyr-sky-photo{clip-path:none}.lyr-sky-wait .lyr-sky-num{opacity:1}}
`

const has = (value: unknown, word: string) => String(value ?? "").toLowerCase().includes(word)

/**
 * @framerIntrinsicWidth 110
 * @framerIntrinsicHeight 480
 *
 * @framerSupportedLayoutWidth any-prefer-fixed
 * @framerSupportedLayoutHeight any
 */
export default function SkylineColumn(props: SkylineColumnProps) {
    const {
        photo,
        homes = 24,
        fullAt = 90,
        shortest = 26,
        spaceKept = 430,
        areaMin = 230,
        areaMax = 620,
        position = 1,
        unit = "homes",
        unitOn = "first",
        before = "",
        rise = "load",
        focus = 50,
        corner = 4,
        fontFamily = "Inter",
        numberSize = 15,
        numberColor = "#111417",
        unitColor = "#84898D",
        imageWidth = "10vw",
        loadFirst = false,
        style,
    } = props

    const isStatic = useIsStaticRenderer()
    const ref = useRef<HTMLDivElement>(null)

    const low = Math.min(Math.max(shortest, 5), 100) / 100
    const share = Math.min(Math.max(homes / Math.max(fullAt, 1), 0), 1)
    const part = Math.round((low + (1 - low) * share) * 1000) / 1000

    // Framer gives the component a height of 100% when its frame has a fixed or fill height.
    const fills = style?.height === "100%"
    const onLoad = !isStatic && has(rise, "load")
    const inView = !isStatic && has(rise, "view")
    const showUnit = unit.trim() !== "" && (has(unitOn, "every") || (has(unitOn, "first") && position <= 1))

    useEffect(() => {
        const el = ref.current
        if (!inView || !el || typeof IntersectionObserver === "undefined") return
        const box = el.getBoundingClientRect()
        const viewport = window.innerHeight || 0
        if (box.top < viewport && box.bottom > 0) return
        el.classList.add("lyr-sky-wait")
        const observer = new IntersectionObserver(
            (entries) => {
                if (!entries.some((entry) => entry.isIntersecting)) return
                el.classList.remove("lyr-sky-wait")
                el.classList.add("lyr-sky-go")
                observer.disconnect()
            },
            { threshold: 0.15 }
        )
        observer.observe(el)
        return () => observer.disconnect()
    }, [inView])

    const vars = {
        "--sky-p": part,
        "--sky-top": `${Math.round(numberSize * 1.2) + 10}px`,
        "--sky-keep": `${spaceKept}px`,
        "--sky-min": `${areaMin}px`,
        "--sky-max": `${Math.max(areaMax, areaMin)}px`,
        "--sky-i": Math.max(position - 1, 0),
        "--sky-r": `${corner}px`,
        "--sky-x": `${focus}%`,
    } as CSSProperties

    const className = ["lyr-sky", fills ? "lyr-sky-fill" : "", isStatic && !fills ? "lyr-sky-canvas" : "", onLoad ? "lyr-sky-go" : ""]
        .filter(Boolean)
        .join(" ")

    return (
        <div ref={ref} className={className} style={{ ...vars, fontFamily: `"${fontFamily}", "Inter", system-ui, sans-serif` }}>
            <style dangerouslySetInnerHTML={{ __html: SHEET }} />
            <p className="lyr-sky-num" style={{ fontSize: numberSize, color: numberColor }}>
                {before.trim() !== "" ? `${before.trim()} ` : ""}
                {Math.round(homes).toLocaleString("en-US")}
                {showUnit ? <span style={{ color: unitColor }}> {unit}</span> : null}
            </p>
            <div className="lyr-sky-photo">
                {photo?.src ? (
                    <img
                        srcSet={photo.srcSet}
                        sizes={photo.srcSet ? imageWidth : undefined}
                        src={photo.src}
                        alt={photo.alt ?? ""}
                        loading={loadFirst ? "eager" : "lazy"}
                        decoding="async"
                        draggable={false}
                    />
                ) : (
                    <div className="lyr-sky-e" style={{ background: EMPTY }} />
                )}
            </div>
        </div>
    )
}

addPropertyControls(SkylineColumn, {
    photo: { type: ControlType.ResponsiveImage, title: "Photo", description: "Any upright photo of the building." },
    homes: {
        type: ControlType.Number,
        title: "Homes",
        description: "Shown above the column. The column's height follows it.",
        defaultValue: 24,
        min: 0,
        step: 1,
        displayStepper: true,
    },
    fullAt: {
        type: ControlType.Number,
        title: "Full height at",
        description: "A building with this many homes reaches the top. Set it to your largest building.",
        defaultValue: 90,
        min: 1,
        step: 1,
        displayStepper: true,
    },
    shortest: {
        type: ControlType.Number,
        title: "Shortest",
        description: "How tall the smallest building is drawn, as a share of the full height.",
        defaultValue: 26,
        min: 5,
        max: 100,
        unit: "%",
    },
    spaceKept: {
        type: ControlType.Number,
        title: "Space kept",
        description: "Room left for the navigation, headline and captions. The skyline takes the rest of the window height.",
        defaultValue: 430,
        min: 0,
        max: 900,
        step: 10,
        unit: "px",
    },
    areaMin: { type: ControlType.Number, title: "Lowest skyline", defaultValue: 230, min: 80, max: 800, step: 10, unit: "px" },
    areaMax: { type: ControlType.Number, title: "Tallest skyline", defaultValue: 620, min: 120, max: 1200, step: 10, unit: "px" },
    position: {
        type: ControlType.Number,
        title: "Position",
        description: "Place in the row, starting at 1. Sets the order the columns rise in.",
        defaultValue: 1,
        min: 1,
        step: 1,
        displayStepper: true,
    },
    before: { type: ControlType.String, title: "Word before", description: "Optional word before the number, for example Day.", defaultValue: "" },
    unit: { type: ControlType.String, title: "Unit", description: "Word after the number, for example homes.", defaultValue: "homes" },
    unitOn: {
        type: ControlType.Enum,
        title: "Show unit",
        options: ["first", "every", "never"],
        optionTitles: ["On the first column", "On every column", "Never"],
        defaultValue: "first",
    },
    rise: {
        type: ControlType.Enum,
        title: "Rise",
        description: "How the columns appear.",
        options: ["load", "view", "off"],
        optionTitles: ["On page load", "When in view", "Off"],
        defaultValue: "load",
    },
    focus: {
        type: ControlType.Number,
        title: "Focus",
        description: "Which part of the photo shows in a narrow column, from left (0) to right (100).",
        defaultValue: 50,
        min: 0,
        max: 100,
        unit: "%",
    },
    corner: { type: ControlType.Number, title: "Corner", defaultValue: 4, min: 0, max: 40, unit: "px" },
    fontFamily: { type: ControlType.String, title: "Font family", description: "Name of a font used elsewhere on the site.", defaultValue: "Inter" },
    numberSize: { type: ControlType.Number, title: "Number size", defaultValue: 15, min: 10, max: 40, unit: "px" },
    numberColor: { type: ControlType.Color, title: "Number colour", defaultValue: "#111417" },
    unitColor: { type: ControlType.Color, title: "Unit colour", defaultValue: "#84898D" },
    imageWidth: {
        type: ControlType.String,
        title: "Image width",
        description: "Roughly how wide a column is on screen, as one length such as 10vw. Keeps downloads small.",
        defaultValue: "10vw",
    },
    loadFirst: { type: ControlType.Boolean, title: "Load first", description: "Turn on for columns visible when the page opens.", defaultValue: false },
})

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.