Layr Components

Components / Images

Pipe Run

A photo seen through a pipe-shaped window (S-curve, U-bend or round-ended length) that draws in, drifts as you scroll and can swell open.

Scroll insideFull screen
Props
32%
61%
124%
100%
1
0%
90 vh
0 px
16 px
28 px
45%
10%
10%
60%
60 px
50%
50%

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. Pipe Run shows up in the Insert menu, and its controls appear in the right panel.
// Pipe Run · Layr Components
//
// A photo seen through a window shaped like a run of pipe: a flowing S-curve, a
// U-bend, or a straight length with round ends. The pipe draws itself in, the
// photo slides along inside it as the page scrolls, and the S-curve can swell
// open into the whole photo, either as it passes or while it is pinned in the
// window. The shape is pure CSS in container units, so it is in the server HTML
// and follows any frame size.
// Paste into a new code file in Framer. It fills its frame. With Opens on Pinned
// (S-curve only), set its height to Fit: it becomes a section one window tall
// plus the pin length, and the pipe holds in the window while the photo opens.
// Without a photo the pipe shows 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 PipeRunProps {
    photo?: Photo
    shape: string
    mirror: boolean
    bore: number
    bend: number
    sweep: number
    returnRun: number
    ends: string
    listPosition: number
    stopsShort: number
    opens: string
    pinLength: number
    topGap: number
    openGap: number
    openRadius: number
    shade: number
    spaceAbove: number
    spaceBelow: number
    tallest: number
    flow: number
    runsIn: string
    focusX: number
    focusY: number
    imageWidth: string
    loadFirst: boolean
    altText: string
    style?: CSSProperties
}

// Shown when no photo is set.
const EMPTY = "linear-gradient(160deg, #d3cabd 0%, #a39a8c 55%, #6f685e 100%)"

const SHEET = `
.lyr-pipe{position:relative;width:100%;height:100%}
.lyr-pipe[data-pin="1"]{height:calc(100vh - var(--pipe-top) + var(--pipe-len));height:calc(100svh - var(--pipe-top) + var(--pipe-len))}
.lyr-pipe-stage{position:relative;width:100%;height:100%;container-type:size}
.lyr-pipe[data-pin="1"] .lyr-pipe-stage{position:sticky;top:var(--pipe-top);height:calc(100vh - var(--pipe-top));height:calc(100svh - var(--pipe-top))}
.lyr-pipe-pad{position:absolute;left:0;right:0;display:flex;align-items:center}
.lyr-pipe-box{position:relative;width:100%;height:min(100%,var(--pipe-tallest));container-type:size}
.lyr-pipe-wipe{position:absolute;inset:0;overflow:hidden}
.lyr-pipe-shade{position:absolute;left:0;right:0;bottom:0;height:62%;background:linear-gradient(0deg,#000,rgba(0,0,0,0));opacity:0;pointer-events:none}
.lyr-pipe-clip{position:absolute;inset:0;overflow:hidden}
.lyr-pipe-img{position:absolute;top:0;height:100%;max-width:none;object-fit:cover;display:block;will-change:transform}
@keyframes lyr-pipe-in-l{from{clip-path:inset(0 0 0 100%)}to{clip-path:inset(0 0 0 0)}}
@keyframes lyr-pipe-in-r{from{clip-path:inset(0 100% 0 0)}to{clip-path:inset(0 0 0 0)}}
.lyr-pipe-load.lyr-pipe-l{animation:lyr-pipe-in-l .9s cubic-bezier(.22,.7,.2,1) both}
.lyr-pipe-load.lyr-pipe-r{animation:lyr-pipe-in-r .9s cubic-bezier(.22,.7,.2,1) both}
.lyr-pipe-wait{clip-path:inset(0 0 0 100%)}
.lyr-pipe-wait.lyr-pipe-r{clip-path:inset(0 100% 0 0)}
.lyr-pipe-go.lyr-pipe-l{animation:lyr-pipe-in-l 1s cubic-bezier(.22,.7,.2,1) both}
.lyr-pipe-go.lyr-pipe-r{animation:lyr-pipe-in-r 1s cubic-bezier(.22,.7,.2,1) both}
@media (prefers-reduced-motion:reduce){
.lyr-pipe-load,.lyr-pipe-go{animation:none!important}.lyr-pipe-wait{clip-path:none!important}
.lyr-pipe[data-pin="1"]{height:calc(100vh - var(--pipe-top));height:calc(100svh - var(--pipe-top))}
}
@supports not (width:1cqw){.lyr-pipe-box{height:100%}.lyr-pipe-clip{clip-path:none!important;border-radius:28px}}
`

const r2 = (n: number) => Math.round(n * 100) / 100
const clamp01 = (n: number) => Math.max(0, Math.min(1, n))

// One polygon corner. x = a share of the frame width plus a share of the pipe height,
// y = a share of the pipe height. Everything scales with the frame, nothing is measured.
function corner(xw: number, xh: number, yh: number, mirror: boolean) {
    const w = mirror ? 100 - xw : xw
    const h = mirror ? -xh : xh
    const x = h === 0 ? `${r2(w)}cqw` : `calc(${r2(w)}cqw + ${r2(h)}cqh)`
    return `${x} ${r2(yh)}cqh`
}

// Points and normals along the S-curve's centre line, and its tightest radius.
function traceS(tau: number, sweep: number) {
    const k = 0.54
    const y0 = tau / 2
    const y1 = 1 - tau / 2
    const L = sweep * (1 - tau)
    const P = [
        [L / 2, y0],
        [L / 2 - k * L, y0],
        [-L / 2 + k * L, y1],
        [-L / 2, y1],
    ]
    const pts: { x: number; y: number; nx: number; ny: number }[] = []
    let minR = Infinity
    const N = 56
    for (let i = 0; i <= N; i++) {
        const t = i / N
        const m = 1 - t
        const x = m * m * m * P[0][0] + 3 * m * m * t * P[1][0] + 3 * m * t * t * P[2][0] + t * t * t * P[3][0]
        const y = m * m * m * P[0][1] + 3 * m * m * t * P[1][1] + 3 * m * t * t * P[2][1] + t * t * t * P[3][1]
        const dx = 3 * m * m * (P[1][0] - P[0][0]) + 6 * m * t * (P[2][0] - P[1][0]) + 3 * t * t * (P[3][0] - P[2][0])
        const dy = 3 * m * m * (P[1][1] - P[0][1]) + 6 * m * t * (P[2][1] - P[1][1]) + 3 * t * t * (P[3][1] - P[2][1])
        const ddx = 6 * m * (P[2][0] - 2 * P[1][0] + P[0][0]) + 6 * t * (P[3][0] - 2 * P[2][0] + P[1][0])
        const ddy = 6 * m * (P[2][1] - 2 * P[1][1] + P[0][1]) + 6 * t * (P[3][1] - 2 * P[2][1] + P[1][1])
        const len = Math.hypot(dx, dy) || 1
        const cross = Math.abs(dx * ddy - dy * ddx)
        if (cross > 1e-9) minR = Math.min(minR, (len * len * len) / cross)
        pts.push({ x, y, nx: -dy / len, ny: dx / len })
    }
    return { pts, minR }
}

// The S-curve: a constant-width band along one cubic curve, from the top right
// run to the bottom left run. At a thickness of 100% it is the whole frame.
function sCurve(tau: number, bend: number, sweepIn: number, mirror: boolean) {
    if (tau >= 0.995) return "polygon(0 0,100cqw 0,100cqw 100cqh,0 100cqh)"
    // The inside of a bend must stay wider than half the pipe, or the band folds over
    // itself. Find the shortest curve that allows it, so the shape changes smoothly.
    const need = tau / 2 + 0.06
    let res = traceS(tau, sweepIn)
    if (res.minR < need) {
        let lo = sweepIn
        let hi = sweepIn
        for (let i = 0; i < 16 && res.minR < need; i++) {
            lo = hi
            hi *= 1.7
            res = traceS(tau, hi)
        }
        for (let i = 0; i < 16; i++) {
            const mid = (lo + hi) / 2
            const r = traceS(tau, mid)
            if (r.minR >= need) {
                hi = mid
                res = r
            } else lo = mid
        }
    }
    const pts = res.pts
    const h = tau / 2
    const out: string[] = []
    out.push(corner(100, 0, 0, mirror))
    // at t = 0 the normal points up, so this side is the top edge of the upper run
    for (const p of pts) out.push(corner(bend, (p.x + p.nx * h) * 100, (p.y + p.ny * h) * 100, mirror))
    out.push(corner(0, 0, (1 - tau) * 100, mirror))
    out.push(corner(0, 0, 100, mirror))
    for (let i = pts.length - 1; i >= 0; i--) {
        const p = pts[i]
        out.push(corner(bend, (p.x - p.nx * h) * 100, (p.y - p.ny * h) * 100, mirror))
    }
    out.push(corner(100, 0, tau * 100, mirror))
    return `polygon(${out.join(",")})`
}

// The U-bend: two runs from the left edge joined by a half-circle at the right.
function uBend(tauIn: number, bend: number, returnRun: number, mirror: boolean) {
    const tau = Math.min(tauIn, 0.46)
    const R = 0.5
    const r = 0.5 - tau
    const out: string[] = []
    const N = 40
    out.push(corner(0, 0, 0, mirror))
    for (let i = 0; i <= N; i++) {
        const a = -Math.PI / 2 + (Math.PI * i) / N
        out.push(corner(bend, (-0.5 + R * Math.cos(a)) * 100, (0.5 + R * Math.sin(a)) * 100, mirror))
    }
    if (returnRun >= 99.5) {
        out.push(corner(0, 0, 100, mirror))
        out.push(corner(0, 0, (1 - tau) * 100, mirror))
    } else {
        // the lower run stops short and ends round
        const endW = bend * (1 - returnRun / 100)
        const M = 18
        for (let i = 0; i <= M; i++) {
            const a = Math.PI / 2 + (Math.PI * i) / M
            out.push(corner(endW, (tau / 2 + (tau / 2) * Math.cos(a)) * 100, (1 - tau / 2 + (tau / 2) * Math.sin(a)) * 100, mirror))
        }
    }
    for (let i = N; i >= 0; i--) {
        const a = -Math.PI / 2 + (Math.PI * i) / N
        out.push(corner(bend, (-0.5 + r * Math.cos(a)) * 100, (0.5 + r * Math.sin(a)) * 100, mirror))
    }
    out.push(corner(0, 0, tau * 100, mirror))
    return `polygon(${out.join(",")})`
}

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

/**
 * @framerIntrinsicWidth 1200
 * @framerIntrinsicHeight 520
 *
 * @framerSupportedLayoutWidth fixed
 * @framerSupportedLayoutHeight any
 */
export default function PipeRun(props: PipeRunProps) {
    const {
        photo,
        shape = "S-curve",
        mirror = false,
        bore = 32,
        bend = 61,
        sweep = 124,
        returnRun = 100,
        ends = "Both round",
        listPosition = 1,
        stopsShort = 0,
        opens = "Off",
        pinLength = 90,
        topGap = 0,
        openGap = 16,
        openRadius = 28,
        shade = 45,
        spaceAbove = 0,
        spaceBelow = 0,
        tallest = 60,
        flow = 60,
        runsIn = "On load",
        focusX = 50,
        focusY = 50,
        imageWidth = "100vw",
        loadFirst = false,
        altText = "",
    } = props

    const isStatic = useIsStaticRenderer()
    const rootRef = useRef<HTMLDivElement>(null)
    const stageRef = useRef<HTMLDivElement>(null)
    const wipeRef = useRef<HTMLDivElement>(null)
    const imgRef = useRef<HTMLElement | null>(null)
    const padRef = useRef<HTMLDivElement>(null)
    const clipRef = useRef<HTMLDivElement>(null)
    const shadeRef = useRef<HTMLDivElement>(null)

    const isU = has(shape, "u-bend") || has(shape, "u bend")
    const isLength = has(shape, "length")
    const isWhole = has(shape, "whole")
    const tau = Math.max(0.1, Math.min(0.72, bore / 100))

    let clip: CSSProperties = {}
    // a Length can run off one edge; "Alternate" switches sides down a list
    const odd = Math.round(listPosition) % 2 === 1
    const offLeft = isLength && (has(ends, "off left") || (has(ends, "alternate") && odd))
    const offRight = isLength && (has(ends, "off right") || (has(ends, "alternate") && !odd))
    if (isWhole) {
        clip = {}
    } else if (isLength) {
        const big = "100000px"
        const left = offLeft ? "0" : big
        const right = offRight ? "0" : big
        // the round end can stop short of the frame's edge, like lengths on a rack
        const short = `${Math.max(0, Math.min(60, stopsShort))}%`
        clip = {
            borderRadius: `${left} ${right} ${right} ${left}`,
            left: offRight && !offLeft ? short : 0,
            right: offLeft && !offRight ? short : 0,
        }
    } else if (isU) {
        clip = { clipPath: uBend(tau, bend, returnRun, mirror) }
    } else {
        clip = { clipPath: sCurve(tau, bend, Math.max(0.9, sweep / 100), mirror) }
    }

    const onLoad = has(runsIn, "load")
    const inView = has(runsIn, "view")
    const fromLeft = isLength ? offLeft : mirror
    const side = fromLeft ? "lyr-pipe-r" : "lyr-pipe-l"
    const travel = Math.max(0, flow)

    // Opens: the S-curve swells into the whole photo as the page scrolls. "Pinned" makes
    // the component a tall section that holds the pipe in the window while it opens;
    // "As it passes" follows the frame's own travel up the window.
    const opensWith = isU || isLength || isWhole ? "off" : has(opens, "pin") || has(opens, "scene") ? "pin" : has(opens, "passes") ? "self" : "off"
    const pinned = !isStatic && opensWith === "pin"
    const sweepRatio = Math.max(0.9, sweep / 100)
    const hasPhoto = Boolean(photo?.src)

    // "In view": hide only once the script is running and the pipe is still below the window.
    useEffect(() => {
        if (isStatic || !inView || typeof window === "undefined") return
        const el = wipeRef.current
        const stage = stageRef.current
        if (!el || !stage || typeof IntersectionObserver === "undefined") return
        if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return
        if (stage.getBoundingClientRect().top < window.innerHeight * 0.9) return
        el.classList.add("lyr-pipe-wait")
        const io = new IntersectionObserver(
            (entries) => {
                if (entries.some((e) => e.isIntersecting)) {
                    el.classList.remove("lyr-pipe-wait")
                    el.classList.add("lyr-pipe-go")
                    io.disconnect()
                }
            },
            { threshold: 0.2 }
        )
        io.observe(stage)
        return () => {
            io.disconnect()
            el.classList.remove("lyr-pipe-wait")
        }
    }, [isStatic, inView])

    // Flow: the picture slides along the pipe while the frame crosses the window.
    useEffect(() => {
        if (isStatic || travel === 0 || typeof window === "undefined") return
        const stage = stageRef.current
        const img = imgRef.current
        if (!stage || !img) return
        if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return
        let frame = 0
        let visible = false
        let last = NaN
        const dir = fromLeft ? -1 : 1
        const paint = () => {
            frame = 0
            const rect = stage.getBoundingClientRect()
            const vh = window.innerHeight || 1
            const p = Math.max(0, Math.min(1, (vh - rect.top) / (vh + rect.height)))
            const x = Math.round((0.5 - p) * 2 * travel * dir * 10) / 10
            if (x !== last) {
                last = x
                img.style.transform = `translate3d(${x}px,0,0)`
            }
        }
        const onScroll = () => {
            if (visible && !frame) frame = requestAnimationFrame(paint)
        }
        const io = new IntersectionObserver((entries) => {
            visible = entries.some((e) => e.isIntersecting)
            if (visible) onScroll()
        })
        io.observe(stage)
        window.addEventListener("scroll", onScroll, { passive: true })
        window.addEventListener("resize", onScroll)
        paint()
        return () => {
            io.disconnect()
            window.removeEventListener("scroll", onScroll)
            window.removeEventListener("resize", onScroll)
            if (frame) cancelAnimationFrame(frame)
        }
    }, [isStatic, travel, fromLeft, hasPhoto])

    useEffect(() => {
        if (isStatic || opensWith === "off" || typeof window === "undefined") return
        const root = rootRef.current
        const stage = stageRef.current
        const pad = padRef.current
        const clipEl = clipRef.current
        const wipe = wipeRef.current
        const shadeEl = shadeRef.current
        if (!root || !stage || !pad || !clipEl || !wipe) return
        if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return
        if (!window.CSS || typeof window.CSS.supports !== "function" || !window.CSS.supports("width", "1cqw")) return
        let frame = 0
        let cur = 0
        let lastT = 0
        let drawn = -1
        const ease = (x: number) => x * x * (3 - 2 * x)
        const target = () => {
            const vh = window.innerHeight || 1
            if (opensWith === "pin") {
                const r = root.getBoundingClientRect()
                const distance = r.height - stage.offsetHeight
                // the photo is fully open at 72% of the pin; the rest of it holds the photo open
                if (distance > 80) return clamp01((topGap - r.top) / distance / 0.72)
            }
            const rect = stage.getBoundingClientRect()
            const natural = rect.top + window.scrollY
            const start = Math.min(natural, vh * 0.6)
            return clamp01((start - rect.top) / (vh * 0.45))
        }
        const draw = (e: number) => {
            if (Math.abs(e - drawn) < 0.0004) return
            drawn = e
            const t = tau + (1 - tau) * e
            pad.style.top = `calc(${r2(Math.max(0, spaceAbove) * (1 - e))}% + ${r2(openGap * e)}px)`
            pad.style.bottom = `calc(${r2(Math.max(0, spaceBelow) * (1 - e))}% + ${r2(openGap * e)}px)`
            pad.style.left = `${r2(openGap * e)}px`
            pad.style.right = `${r2(openGap * e)}px`
            pad.style.setProperty("--pipe-tallest", `${r2(Math.max(10, tallest) + (600 - Math.max(10, tallest)) * e)}cqw`)
            wipe.style.borderRadius = `${r2(openRadius * e)}px`
            clipEl.style.clipPath = sCurve(t, bend, sweepRatio, mirror)
            if (shadeEl) shadeEl.style.opacity = String(r2(clamp01((e - 0.3) / 0.45) * (shade / 100)))
        }
        const tick = (now: number) => {
            frame = 0
            const goal = target()
            const dt = Math.min(64, Math.max(1, now - lastT))
            lastT = now
            cur += (goal - cur) * (1 - Math.exp(-dt / 110))
            if (Math.abs(goal - cur) < 0.0015) cur = goal
            draw(ease(cur))
            if (cur !== goal) frame = requestAnimationFrame(tick)
        }
        const kick = () => {
            if (frame) return
            lastT = performance.now() - 16
            frame = requestAnimationFrame(tick)
        }
        window.addEventListener("scroll", kick, { passive: true })
        window.addEventListener("resize", kick)
        cur = target()
        draw(ease(cur))
        return () => {
            window.removeEventListener("scroll", kick)
            window.removeEventListener("resize", kick)
            if (frame) cancelAnimationFrame(frame)
            pad.style.top = ""
            pad.style.bottom = ""
            pad.style.left = ""
            pad.style.right = ""
            pad.style.removeProperty("--pipe-tallest")
            wipe.style.borderRadius = ""
            clipEl.style.clipPath = ""
            if (shadeEl) shadeEl.style.opacity = ""
        }
    }, [isStatic, opensWith, tau, bend, sweepRatio, mirror, spaceAbove, spaceBelow, tallest, openGap, openRadius, shade, topGap])

    const wipeClass = ["lyr-pipe-wipe", side, !isStatic && onLoad ? "lyr-pipe-load" : ""].filter(Boolean).join(" ")
    const keep = (el: HTMLElement | null) => {
        imgRef.current = el
    }
    const imgStyle: CSSProperties = { left: -travel, width: `calc(100% + ${travel * 2}px)`, objectPosition: `${focusX}% ${focusY}%` }
    const rootStyle = {
        ...props.style,
        position: "relative",
        "--pipe-top": `${Math.max(0, topGap)}px`,
        "--pipe-len": `${Math.max(0, pinLength)}vh`,
        ...(pinned ? { height: undefined } : null),
    } as CSSProperties

    return (
        <div ref={rootRef} className="lyr-pipe" data-pin={pinned ? "1" : "0"} style={rootStyle}>
            <style dangerouslySetInnerHTML={{ __html: SHEET }} />
            <div ref={stageRef} className="lyr-pipe-stage">
                <div
                    ref={padRef}
                    className="lyr-pipe-pad"
                    style={
                        {
                            top: `${Math.max(0, spaceAbove)}%`,
                            bottom: `${Math.max(0, spaceBelow)}%`,
                            "--pipe-tallest": `${Math.max(10, tallest)}cqw`,
                        } as CSSProperties
                    }
                >
                    <div className="lyr-pipe-box">
                        <div ref={wipeRef} className={wipeClass}>
                            <div ref={clipRef} className="lyr-pipe-clip" style={clip}>
                                {photo?.src ? (
                                    <img
                                        ref={keep}
                                        className="lyr-pipe-img"
                                        srcSet={photo.srcSet}
                                        sizes={photo.srcSet ? imageWidth || "100vw" : undefined}
                                        src={photo.src}
                                        alt={altText || photo.alt || ""}
                                        loading={loadFirst ? "eager" : "lazy"}
                                        decoding="async"
                                        draggable={false}
                                        style={imgStyle}
                                    />
                                ) : (
                                    <div ref={keep} className="lyr-pipe-img" role={altText ? "img" : undefined} aria-label={altText || undefined} style={{ ...imgStyle, background: EMPTY }} />
                                )}
                            </div>
                            {opensWith !== "off" && <div ref={shadeRef} className="lyr-pipe-shade" />}
                        </div>
                    </div>
                </div>
            </div>
        </div>
    )
}

addPropertyControls(PipeRun, {
    photo: {
        type: ControlType.ResponsiveImage,
        title: "Photo",
        description: "Any photo works. The pipe is only a window onto it.",
    },
    shape: {
        type: ControlType.Enum,
        title: "Shape",
        options: ["S-curve", "U-bend", "Length", "Whole photo"],
        optionTitles: ["S-curve", "U-bend", "Length", "Whole photo"],
        defaultValue: "S-curve",
    },
    mirror: {
        type: ControlType.Boolean,
        title: "Mirror",
        description: "Flips the pipe left to right.",
        defaultValue: false,
        hidden: (props: Partial<PipeRunProps>) => has(props.shape, "length") || has(props.shape, "whole"),
    },
    bore: {
        type: ControlType.Number,
        title: "Thickness",
        description: "How thick the pipe is, as a share of its height.",
        defaultValue: 32,
        min: 12,
        max: 70,
        step: 1,
        unit: "%",
        hidden: (props: Partial<PipeRunProps>) => has(props.shape, "length") || has(props.shape, "whole"),
    },
    bend: {
        type: ControlType.Number,
        title: "Bend position",
        description: "Where the bend sits across the frame.",
        defaultValue: 61,
        min: 0,
        max: 100,
        step: 1,
        unit: "%",
        hidden: (props: Partial<PipeRunProps>) => has(props.shape, "length") || has(props.shape, "whole"),
    },
    sweep: {
        type: ControlType.Number,
        title: "Sweep",
        description: "How long the S-curve is compared with its drop. Higher is gentler.",
        defaultValue: 124,
        min: 90,
        max: 240,
        step: 2,
        unit: "%",
        hidden: (props: Partial<PipeRunProps>) => !has(props.shape, "s-curve"),
    },
    returnRun: {
        type: ControlType.Number,
        title: "Return run",
        description: "How far the lower run comes back. 100 runs it off the edge.",
        defaultValue: 100,
        min: 20,
        max: 100,
        step: 1,
        unit: "%",
        hidden: (props: Partial<PipeRunProps>) => !has(props.shape, "u-bend"),
    },
    ends: {
        type: ControlType.Enum,
        title: "Ends",
        options: ["Both round", "Runs off left", "Runs off right", "Alternate"],
        optionTitles: ["Both round", "Runs off left", "Runs off right", "Alternate"],
        defaultValue: "Both round",
        hidden: (props: Partial<PipeRunProps>) => !has(props.shape, "length"),
    },
    listPosition: {
        type: ControlType.Number,
        title: "Position in list",
        description: "With Ends on Alternate, odd lengths run off the left edge and even ones off the right. In a CMS list, connect this to the item's number.",
        defaultValue: 1,
        min: 1,
        max: 99,
        step: 1,
        displayStepper: true,
        hidden: (props: Partial<PipeRunProps>) => !has(props.shape, "length") || !has(props.ends, "alternate"),
    },
    stopsShort: {
        type: ControlType.Number,
        title: "Stops short",
        description: "When one end runs off the edge, the round end stops this far from the other edge.",
        defaultValue: 0,
        min: 0,
        max: 60,
        step: 1,
        unit: "%",
        hidden: (props: Partial<PipeRunProps>) => !has(props.shape, "length") || has(props.ends, "both"),
    },
    opens: {
        type: ControlType.Enum,
        title: "Opens",
        description: "The S-curve swells into the whole photo as the page scrolls. Pinned holds it in the window while it opens: set the height to Fit.",
        options: ["Off", "As it passes", "Pinned"],
        optionTitles: ["Off", "As it passes", "Pinned"],
        defaultValue: "Off",
        hidden: (props: Partial<PipeRunProps>) => !has(props.shape, "s-curve"),
    },
    pinLength: {
        type: ControlType.Number,
        title: "Pin length",
        description: "How much scrolling the pinned opening takes. The photo is fully open at about three quarters of it.",
        defaultValue: 90,
        min: 30,
        max: 300,
        step: 10,
        unit: "vh",
        hidden: (props: Partial<PipeRunProps>) => !has(props.shape, "s-curve") || !has(props.opens, "pin"),
    },
    topGap: {
        type: ControlType.Number,
        title: "Top gap",
        description: "Space kept above the pinned pipe. Use the height of a fixed navigation.",
        defaultValue: 0,
        min: 0,
        max: 200,
        step: 1,
        unit: "px",
        hidden: (props: Partial<PipeRunProps>) => !has(props.shape, "s-curve") || !has(props.opens, "pin"),
    },
    openGap: {
        type: ControlType.Number,
        title: "Open gap",
        description: "The gap left around the photo when it is fully open.",
        defaultValue: 16,
        min: 0,
        max: 64,
        step: 1,
        unit: "px",
        hidden: (props: Partial<PipeRunProps>) => !has(props.shape, "s-curve") || has(props.opens, "off"),
    },
    openRadius: {
        type: ControlType.Number,
        title: "Open corners",
        description: "Corner radius of the photo when it is fully open.",
        defaultValue: 28,
        min: 0,
        max: 80,
        step: 1,
        unit: "px",
        hidden: (props: Partial<PipeRunProps>) => !has(props.shape, "s-curve") || has(props.opens, "off"),
    },
    shade: {
        type: ControlType.Number,
        title: "Shade",
        description: "How dark the bottom of the open photo gets, so white text reads on it.",
        defaultValue: 45,
        min: 0,
        max: 90,
        step: 5,
        unit: "%",
        hidden: (props: Partial<PipeRunProps>) => !has(props.shape, "s-curve") || has(props.opens, "off"),
    },
    spaceAbove: {
        type: ControlType.Number,
        title: "Space above",
        description: "Keeps this share of the frame clear above the pipe.",
        defaultValue: 0,
        min: 0,
        max: 60,
        step: 1,
        unit: "%",
    },
    spaceBelow: {
        type: ControlType.Number,
        title: "Space below",
        description: "Keeps this share of the frame clear below the pipe.",
        defaultValue: 0,
        min: 0,
        max: 60,
        step: 1,
        unit: "%",
    },
    tallest: {
        type: ControlType.Number,
        title: "Tallest",
        description: "Caps the pipe's height at this share of the frame's width, so it stays in proportion in a narrow window.",
        defaultValue: 60,
        min: 10,
        max: 200,
        step: 1,
        unit: "%",
    },
    flow: {
        type: ControlType.Number,
        title: "Flow",
        description: "How far the picture travels through the pipe as the page scrolls. 0 turns it off.",
        defaultValue: 60,
        min: 0,
        max: 240,
        step: 5,
        unit: "px",
    },
    runsIn: {
        type: ControlType.Enum,
        title: "Runs in",
        description: "The pipe draws itself in under a second.",
        options: ["On load", "In view", "Off"],
        optionTitles: ["On load", "In view", "Off"],
        defaultValue: "On load",
    },
    focusX: {
        type: ControlType.Number,
        title: "Focus across",
        description: "Which part of the photo stays in the pipe. 0 is the left edge.",
        defaultValue: 50,
        min: 0,
        max: 100,
        step: 1,
        unit: "%",
    },
    focusY: {
        type: ControlType.Number,
        title: "Focus down",
        description: "0 is the top of the photo.",
        defaultValue: 50,
        min: 0,
        max: 100,
        step: 1,
        unit: "%",
    },
    imageWidth: {
        type: ControlType.String,
        title: "Image width",
        description: "How wide the photo shows, so the right file size loads: 100vw, 50vw.",
        defaultValue: "100vw",
    },
    loadFirst: {
        type: ControlType.Boolean,
        title: "Load first",
        description: "Turn on for the one photo at the top of a page.",
        defaultValue: false,
    },
    altText: {
        type: ControlType.String,
        title: "Alt text",
        description: "Describes the photo for screen readers.",
        defaultValue: "",
    },
})

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.