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