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