Layr Components

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
80 px
#0E0E0D
#6E6E6B
#E6E5E1
#FF4A1C
20 px

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.