Components / Cards and lists
Flight Strip
The section pins and a row of large photo cards slides sideways as you scroll, with a progress line and a counter such as Project 3 of 6.
Scroll insideFull screen
Props
#0E0E0D
#6E6E6B
#E6E5E1
#FF4A1C
Set in code: Items. 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. Flight Strip shows up in the Insert menu, and its controls appear in the right panel.
// Flight Strip · Layr Components
//
// The section pins and a row of large photo cards slides sideways as you scroll,
// as if flying along a street. Each photo drifts a little inside its frame, and a
// progress line with a counter ("Project 2 of 5") runs underneath. With reduced
// motion it becomes a plain row you swipe.
// Paste into a new code file in Framer. Place it in the page flow at full width
// with its height set to Fit; it adds the scroll the row needs and pins itself in
// the middle of the window. No ancestor may clip overflow. A card without a photo
// shows a plain tone.
import { addPropertyControls, ControlType, useIsStaticRenderer } from "framer"
import { useEffect, useRef, useState, startTransition, type CSSProperties, type ReactNode } from "react"
type Img = { src: string; srcSet?: string; alt?: string }
type Item = { image?: Img; title?: string; note?: string; figure?: string; link?: string }
interface FlightStripProps {
items: Item[]
counterWord: string
topGap: number
sizesHint: string
ink: string
muted: string
line: string
accent: string
fontFamily: string
radius: number
style?: CSSProperties
}
const ITEMS: Item[] = [
{ title: "Harbour House", note: "A full renovation, finished in spring", figure: "240 m²" },
{ title: "Hill Road", note: "A new build on a sloping site", figure: "310 m²" },
{ title: "Mill Lane", note: "An extension and a new roof", figure: "180 m²" },
{ title: "Orchard Row", note: "Four terraced houses, one at a time", figure: "520 m²" },
{ title: "Station Yard", note: "A workshop turned into a home", figure: "200 m²" },
]
// Shown when a card has no photo yet.
const EMPTY = ["linear-gradient(160deg, #c9c4ba, #8f897e)", "linear-gradient(160deg, #9aa5a8, #5d686b)", "linear-gradient(160deg, #b8ab98, #776a58)", "linear-gradient(160deg, #a7a39b, #66625b)"]
const SHEET = `
.lyr-fs{--fs-ratio:1.47;--fs-card:clamp(240px,min(52vw,(100vh - 330px)*1.47),860px);--fs-pad:48px;--fs-gap:24px;position:relative;width:100%;color:var(--fs-ink);-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}
.lyr-fs *{box-sizing:border-box}
.lyr-fs-pin{position:sticky;top:var(--fs-top);top:max(var(--fs-top),calc((100vh + var(--fs-top) - var(--fs-pinh)) / 2));top:max(var(--fs-top),calc((100dvh + var(--fs-top) - var(--fs-pinh)) / 2));width:100%;overflow:hidden;display:flex;flex-direction:column;gap:28px}
.lyr-fs-run{height:var(--fs-dist)}
.lyr-fs-track{display:flex;gap:var(--fs-gap);padding:0 var(--fs-pad);width:max-content;will-change:transform}
.lyr-fs-card{flex:none;width:var(--fs-card);display:block;color:inherit;text-decoration:none;transition:opacity .2s ease}
a.lyr-fs-card:hover{opacity:.68}
.lyr-fs-photo{position:relative;width:100%;aspect-ratio:var(--fs-ratio);border-radius:var(--fs-radius);overflow:hidden;background:var(--fs-line)}
.lyr-fs-photo img,.lyr-fs-photo .lyr-fs-e{position:absolute;left:-7%;top:0;width:114%;max-width:none;height:100%;object-fit:cover;display:block;will-change:transform;user-select:none;-webkit-user-drag:none}
.lyr-fs-cap{display:flex;justify-content:space-between;align-items:baseline;gap:16px;padding-top:16px}
.lyr-fs-cap h3{margin:0;font-size:22px;line-height:1.2;font-weight:500;letter-spacing:-0.027em}
.lyr-fs-cap span{flex:none;font-size:14px;line-height:1.4;font-weight:500;letter-spacing:-0.007em;color:var(--fs-mute)}
.lyr-fs-note{margin:6px 0 0;font-size:17px;line-height:1.5;letter-spacing:-0.012em;color:var(--fs-mute)}
.lyr-fs-foot{display:flex;align-items:center;gap:20px;padding:0 var(--fs-pad)}
.lyr-fs-bar{position:relative;flex:1;height:3px;border-radius:2px;background:var(--fs-line);overflow:hidden}
.lyr-fs-bar b{position:absolute;left:0;top:0;width:100%;height:100%;background:var(--fs-accent);transform-origin:0 50%;transform:scaleX(0)}
.lyr-fs-count{flex:none;font-size:14px;line-height:1.4;font-weight:500;letter-spacing:-0.007em;color:var(--fs-mute);font-variant-numeric:tabular-nums}
.lyr-fs[data-flat="1"] .lyr-fs-run{display:none}
.lyr-fs[data-flat="1"] .lyr-fs-pin{position:relative;top:auto;overflow-x:auto;overflow-y:hidden;padding-bottom:4px}
.lyr-fs[data-flat="1"] .lyr-fs-foot{display:none}
@media (max-width:1199px){.lyr-fs{--fs-card:clamp(240px,min(64vw,(100vh - 330px)*1.47),860px);--fs-pad:32px;--fs-gap:20px}}
@media (max-width:809px){
.lyr-fs{--fs-ratio:.86;--fs-card:clamp(230px,min(80vw,(100vh - 320px)*.86),520px);--fs-pad:20px;--fs-gap:14px}
.lyr-fs-pin{gap:22px}
.lyr-fs-cap h3{font-size:19px}
.lyr-fs-note{font-size:16px}
}
`
/**
* @framerIntrinsicWidth 1200
* @framerIntrinsicHeight 620
*
* @framerSupportedLayoutWidth fixed
* @framerSupportedLayoutHeight auto
*/
export default function FlightStrip(props: FlightStripProps) {
const {
items = ITEMS,
counterWord = "Project",
topGap = 80,
sizesHint = "52vw",
ink = "#111418",
muted = "#5B6066",
line = "rgba(17,20,24,0.13)",
accent = "#2457F5",
fontFamily = "Inter",
radius = 20,
} = props
const isStatic = useIsStaticRenderer()
const list = (items || []).filter((it) => it && (it.title || it.note || it.image?.src))
const n = list.length
const rootRef = useRef<HTMLDivElement>(null)
const pinRef = useRef<HTMLDivElement>(null)
const trackRef = useRef<HTMLDivElement>(null)
const barRef = useRef<HTMLElement>(null)
const countRef = useRef<HTMLSpanElement>(null)
const imgRefs = useRef<(HTMLElement | null)[]>([])
const [dist, setDist] = useState(-1)
const [pinH, setPinH] = useState(-1)
const [reduce, setReduce] = useState(false)
const distRef = useRef(0)
useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches)
}, [])
const flat = isStatic || reduce
// How far the row has to travel: its own width less the visible width.
useEffect(() => {
const pin = pinRef.current
const track = trackRef.current
if (!pin || !track || typeof ResizeObserver === "undefined") return
const measure = () => {
const d = Math.max(0, Math.round(track.scrollWidth - pin.clientWidth))
const h = Math.round(pin.offsetHeight)
distRef.current = d
startTransition(() => {
setDist((v) => (v === d ? v : d))
setPinH((v) => (v === h ? v : h))
})
}
// The observer reports both boxes once as soon as it starts, which takes the first measure.
const ro = new ResizeObserver(measure)
ro.observe(pin)
ro.observe(track)
return () => ro.disconnect()
}, [n])
useEffect(() => {
if (flat || typeof window === "undefined") return
const root = rootRef.current
if (!root || typeof IntersectionObserver === "undefined") return
let raf = 0
const paint = () => {
raf = 0
const pin = pinRef.current
const track = trackRef.current
if (!pin || !track) return
const r = root.getBoundingClientRect()
const total = Math.max(1, r.height - pin.offsetHeight)
// The row holds still in the middle of the window, below the menu.
const hold = Math.max(topGap, (window.innerHeight + topGap - pin.offsetHeight) / 2)
const p = Math.max(0, Math.min(1, (hold - r.top) / total))
const d = distRef.current
track.style.transform = `translate3d(${(-p * d).toFixed(1)}px,0,0)`
if (barRef.current) barRef.current.style.transform = `scaleX(${Math.max(0.04, p).toFixed(4)})`
const vw = pin.clientWidth || 1
for (let i = 0; i < n; i++) {
const im = imgRefs.current[i]
if (!im || !im.parentElement) continue
const b = im.parentElement.getBoundingClientRect()
const off = (b.left + b.width / 2 - vw / 2) / vw
// The photo drifts a little inside its frame, against the direction of travel.
im.style.transform = `translate3d(${(Math.max(-1, Math.min(1, off)) * -5).toFixed(2)}%,0,0)`
}
if (countRef.current) countRef.current.textContent = `${counterWord} ${Math.min(n, Math.round(p * (n - 1)) + 1)} of ${n}`
}
const onScroll = () => {
if (!raf) raf = window.requestAnimationFrame(paint)
}
const io = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
window.addEventListener("scroll", onScroll, { passive: true })
window.addEventListener("resize", onScroll)
onScroll()
} else {
window.removeEventListener("scroll", onScroll)
window.removeEventListener("resize", onScroll)
}
})
io.observe(root)
return () => {
io.disconnect()
window.removeEventListener("scroll", onScroll)
window.removeEventListener("resize", onScroll)
if (raf) window.cancelAnimationFrame(raf)
}
}, [flat, n, topGap, counterWord, dist, pinH])
const vars = {
"--fs-ink": ink,
"--fs-mute": muted,
"--fs-line": line,
"--fs-accent": accent,
"--fs-radius": `${radius}px`,
"--fs-top": `${topGap}px`,
// Before the row is measured, estimate its height from the card width.
"--fs-pinh": pinH >= 0 ? `${pinH}px` : "calc(var(--fs-card) / var(--fs-ratio) + 150px)",
// Before the row is measured, estimate its travel from the card width.
"--fs-dist": dist >= 0 ? `${dist}px` : `max(0px, calc(${n} * var(--fs-card) + ${Math.max(0, n - 1)} * var(--fs-gap) + 2 * var(--fs-pad) - 100vw))`,
} as CSSProperties
const font = `"${fontFamily}", "Inter", system-ui, sans-serif`
return (
<div ref={rootRef} className="lyr-fs" data-flat={flat ? "1" : "0"} style={{ ...props.style, ...vars, fontFamily: font }}>
<style dangerouslySetInnerHTML={{ __html: SHEET }} />
<div className="lyr-fs-pin" ref={pinRef}>
<div className="lyr-fs-track" ref={trackRef}>
{list.map((it, i) => {
const keep = (el: HTMLElement | null) => {
imgRefs.current[i] = el
}
const inner: ReactNode = (
<>
<div className="lyr-fs-photo">
{it.image?.src ? (
<img
ref={keep}
srcSet={it.image.srcSet}
sizes={sizesHint || "52vw"}
src={it.image.src}
alt={it.image.alt || it.title || ""}
loading="lazy"
decoding="async"
draggable={false}
/>
) : (
<div ref={keep} className="lyr-fs-e" style={{ background: EMPTY[i % EMPTY.length] }} />
)}
</div>
<div className="lyr-fs-cap">
<h3>{it.title}</h3>
{it.figure ? <span>{it.figure}</span> : null}
</div>
{it.note ? <p className="lyr-fs-note">{it.note}</p> : null}
</>
)
return it.link ? (
<a className="lyr-fs-card" key={i} href={it.link}>
{inner}
</a>
) : (
<div className="lyr-fs-card" key={i}>
{inner}
</div>
)
})}
</div>
<div className="lyr-fs-foot">
<span className="lyr-fs-bar">
<b ref={barRef} />
</span>
<span className="lyr-fs-count" ref={countRef}>
{counterWord} 1 of {n}
</span>
</div>
</div>
<div className="lyr-fs-run" aria-hidden="true" />
</div>
)
}
addPropertyControls(FlightStrip, {
items: {
type: ControlType.Array,
title: "Items",
maxCount: 16,
control: {
type: ControlType.Object,
controls: {
image: { type: ControlType.ResponsiveImage, title: "Photo" },
title: { type: ControlType.String, title: "Title", defaultValue: "" },
note: { type: ControlType.String, title: "Text", defaultValue: "" },
figure: { type: ControlType.String, title: "Figure", defaultValue: "", description: "A short figure beside the title, such as a size or a year." },
link: { type: ControlType.Link, title: "Link" },
},
},
defaultValue: ITEMS,
description: "One card per item, in order from left to right.",
},
counterWord: { type: ControlType.String, title: "Counter word", defaultValue: "Project", description: "Used in the counter, for example Project 2 of 5." },
topGap: {
type: ControlType.Number,
title: "Top gap",
defaultValue: 80,
min: 0,
max: 200,
step: 1,
unit: "px",
description: "Space kept above the strip while it is pinned. Use the height of the navigation plus a little.",
},
sizesHint: { type: ControlType.String, title: "Image width", defaultValue: "52vw", description: "How wide one photo is on screen, as one value such as 52vw." },
ink: { type: ControlType.Color, title: "Text", defaultValue: "#111418" },
muted: { type: ControlType.Color, title: "Muted text", defaultValue: "#5B6066" },
line: { type: ControlType.Color, title: "Lines", defaultValue: "rgba(17,20,24,0.13)" },
accent: { type: ControlType.Color, title: "Accent", defaultValue: "#2457F5" },
fontFamily: { type: ControlType.String, title: "Font family", defaultValue: "Inter", description: "Type the family name used by the site's text styles." },
radius: { type: ControlType.Number, title: "Corner radius", defaultValue: 20, min: 0, max: 48, step: 1, unit: "px" },
})
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.