Components / Scroll scene
Knife Pull
Works stacked in one frame; scrolling pulls the top picture away in slanted knife strokes to show the next, with a list of titles beside.
Scroll insideFull screen
Props
#0E0E0D
#6E6E6B
#A3A3A0
#E6E5E1
Set in code: Works, Text link. The preview fills these in with demo content.
Scroll inside the preview, or click a title in the list.
In Framer, open Assets, add a new code file, paste this over everything in it and save. Knife Pull shows up in the Insert menu, and its controls appear in the right panel.
// Knife Pull · Layr Components
//
// A pinned section of works stacked in one frame, with a list of their titles
// beside it. Scrolling pulls the top picture away in a few slanted knife
// strokes to show the one beneath, and the list follows; a title in the list
// scrolls to its picture.
// Paste into a new code file in Framer. Place it in the page flow at full width;
// it is taller than the window and pins itself while the pictures are pulled, so
// no ancestor may clip overflow. A work without a picture shows a plain tone.
import { addPropertyControls, ControlType, useIsStaticRenderer } from "framer"
import { useEffect, useRef, useState, type CSSProperties } from "react"
type Picture = { src: string; srcSet?: string; alt?: string }
type Work = { image?: Picture; title?: string; year?: string; medium?: string; workHeight?: number; workWidth?: number; link?: string }
interface KnifePullProps {
items: Work[]
headline: string
headlineQuiet: string
text: string
textLink?: string
textLinkLabel: string
headingTag: string
linkLabel: string
unit: string
shape: number
pulls: number
scrollPer: number
topSpace: number
sideSpace: number
fontFamily: string
ink: string
mute: string
quietTone: string
line: string
imageSizes: string
style?: CSSProperties
}
const WORKS: Work[] = [
{ title: "Evening hills", year: "2026", medium: "Oil on linen", workHeight: 120, workWidth: 90 },
{ title: "Low water", year: "2026", medium: "Oil on board", workHeight: 100, workWidth: 75 },
{ title: "Dry season", year: "2025", medium: "Oil on linen", workHeight: 140, workWidth: 105 },
{ title: "Grey portrait", year: "2024", medium: "Oil on canvas", workHeight: 80, workWidth: 60 },
]
// Shown when a work has no picture yet.
const EMPTY = ["linear-gradient(165deg, #cfc8bb 0%, #9d9586 55%, #6f685d 100%)", "linear-gradient(165deg, #c4c9c6 0%, #8e9894 55%, #5f6a67 100%)", "linear-gradient(165deg, #d5c7bb 0%, #a39082 55%, #75655a 100%)", "linear-gradient(165deg, #c9c3cc 0%, #918a96 55%, #625b66 100%)"]
// The section is as tall as the window plus a stretch of scrolling for each pull.
// Its pinned frame holds the text on the left and the stage on the right (above it
// on narrow screens). In the stage every picture sits in the same place, the
// first on top.
const CSS = `
.lyr-kp{position:relative;width:100%;height:calc(100vh + var(--kp-len));height:calc(100svh + var(--kp-len));color:var(--kp-ink);-webkit-font-smoothing:antialiased}
.lyr-kp *{box-sizing:border-box}
.lyr-kp-pin{position:sticky;top:0;height:100vh;height:100svh;overflow:hidden;container-type:size}
.lyr-kp-in{position:absolute;inset:0;display:flex;gap:clamp(24px,3.4cqw,40px);padding:var(--kp-top) min(var(--kp-side),5cqw) 0}
.lyr-kp-text{flex:0 0 clamp(250px,27.5cqw,360px);min-width:0;padding-top:40px}
.lyr-kp-h{margin:0;font-weight:400;font-size:clamp(26px,min(2.7cqw,5.4cqh),42px);line-height:1.06;letter-spacing:-0.035em;text-wrap:balance}
.lyr-kp-h span{color:var(--kp-quiet)}
.lyr-kp-p{margin:clamp(14px,2.4cqh,24px) 0 0;font-size:15px;line-height:1.5;letter-spacing:-0.01em;color:var(--kp-mute);max-width:30em}
.lyr-kp-more{display:inline-block;margin-top:clamp(14px,2.4cqh,24px);color:var(--kp-ink);text-decoration:none;border-bottom:1px solid currentColor;padding-bottom:2px;font-size:15px;letter-spacing:-0.01em;transition:opacity .2s}
.lyr-kp-more:hover{opacity:.62}
.lyr-kp-stage{position:relative;flex:1 1 0px;min-width:0;min-height:0;container-type:size;isolation:isolate}
@container (max-width:999px){
.lyr-kp-in{flex-direction:column;gap:0;padding-top:calc(var(--kp-top) + clamp(12px,3cqh,32px))}
.lyr-kp-text{flex:0 0 auto;padding:0 0 clamp(16px,3cqh,28px)}
.lyr-kp-h{font-size:clamp(24px,min(6cqw,4.2cqh),40px)}
.lyr-kp-p{font-size:14px;margin-top:10px}
.lyr-kp-more{margin-top:10px;font-size:14px}
}
.lyr-kp-box{position:absolute;inset:0;--kp-hc:clamp(220px,min(68cqh,calc(96cqw / var(--kp-shape))),660px);--kp-cw:calc(var(--kp-hc) * var(--kp-shape));--kp-ct:max(0px,calc(45% - var(--kp-hc) / 2 - 20px))}
.lyr-kp-cv{position:absolute;left:0;top:var(--kp-ct);width:var(--kp-cw);height:var(--kp-hc);display:block;color:inherit;text-decoration:none}
.lyr-kp-shadow{position:absolute;inset:0;box-shadow:0 30px 50px -28px rgba(30,25,10,.38),0 2px 5px rgba(30,25,10,.12)}
.lyr-kp-band{position:absolute;left:0;width:100%;overflow:hidden;will-change:clip-path}
.lyr-kp-band img,.lyr-kp-band .lyr-kp-empty{position:absolute;left:0;width:100%;height:var(--kp-hc);object-fit:cover;display:block;max-width:none;user-select:none;-webkit-user-drag:none}
.lyr-kp-ridge{position:absolute;left:0;width:100%;background:rgba(255,255,255,.6);pointer-events:none;visibility:hidden}
.lyr-kp-cap{position:absolute;left:0;top:calc(var(--kp-ct) + var(--kp-hc) + clamp(14px,3cqh,26px));font-size:15px;line-height:1.5;letter-spacing:-0.01em;color:var(--kp-mute);white-space:nowrap}
.lyr-kp-cap b{display:block;font-weight:400;color:var(--kp-ink);font-size:19px;letter-spacing:-0.02em;line-height:1.25;margin-bottom:4px}
.lyr-kp-cap a{color:var(--kp-ink);text-decoration:none;border-bottom:1px solid currentColor;padding-bottom:2px;margin-left:14px;transition:opacity .2s}
.lyr-kp-cap a:hover{opacity:.62}
.lyr-kp-row{position:absolute;left:calc(var(--kp-cw) + clamp(32px,7cqw,104px));right:0;height:57px;display:flex;justify-content:space-between;align-items:center;gap:16px;border-bottom:1px solid var(--kp-line);font-size:21px;letter-spacing:-0.02em;line-height:1.2;background:none;border-left:0;border-right:0;border-top:0;padding:0;margin:0;color:var(--kp-mute);cursor:pointer;text-align:left;font-family:inherit;transition:color .3s}
.lyr-kp-row.first{border-top:1px solid var(--kp-line);height:58px}
.lyr-kp-row span{font-size:14px;letter-spacing:-0.005em;white-space:nowrap}
.lyr-kp-row em{font-style:normal;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.lyr-kp-row:focus-visible{outline:1px solid var(--kp-ink);outline-offset:3px}
.lyr-kp-count{display:none;position:absolute;right:0;top:calc(var(--kp-ct) + var(--kp-hc) + clamp(14px,3cqh,26px));font-size:13.5px;color:var(--kp-mute);font-variant-numeric:tabular-nums}
@container (max-width:719px){.lyr-kp-row{display:none}.lyr-kp-count{display:block}.lyr-kp-cap{font-size:13.5px;white-space:normal;right:64px}.lyr-kp-cap b{font-size:17px}}
.lyr-kp-static.lyr-kp{height:800px}
.lyr-kp-static .lyr-kp-pin{position:relative;height:100%}
`
const clamp01 = (v: number) => Math.min(1, Math.max(0, v))
const smooth = (v: number) => {
const t = clamp01(v)
return t * t * (3 - 2 * t)
}
const SLANT = 7 // how far the knife's edge leans, as a share of the picture's width
/**
* @framerIntrinsicWidth 1200
* @framerIntrinsicHeight 800
*
* @framerSupportedLayoutWidth fixed
* @framerSupportedLayoutHeight auto
*/
export default function KnifePull(props: KnifePullProps) {
const {
items = WORKS,
headline = "Work on the walls.",
headlineQuiet = "Each one lifts away to show the next.",
text = "A few pieces stacked in one frame. Keep scrolling and the one on top is scraped back in a few strokes.",
textLink,
textLinkLabel = "See all work",
headingTag = "h2",
linkLabel = "View",
unit = "cm",
shape = 75,
pulls = 3,
scrollPer = 75,
topSpace = 72,
sideSpace = 48,
fontFamily = "Inter",
ink = "#14130F",
mute = "#77746B",
quietTone = "#A3A097",
line = "rgba(20,19,15,0.14)",
imageSizes = "40vw",
style,
} = props
const isStatic = useIsStaticRenderer()
const list = Array.isArray(items) ? items : []
const n = Math.max(1, list.length)
const bands = Math.max(1, Math.min(6, Math.round(pulls) || 3))
const rootRef = useRef<HTMLDivElement>(null)
const [near, setNear] = useState(false)
useEffect(() => {
const root = rootRef.current
if (!root || isStatic) return
const reduce = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches
const works = Array.from({ length: n }, (_, i) => {
const cv = root.querySelector<HTMLElement>(`.lyr-kp-cv[data-i="${i}"]`)
return {
cv,
bands: cv ? Array.from(cv.querySelectorAll<HTMLElement>(".lyr-kp-band")) : [],
ridges: cv ? Array.from(cv.querySelectorAll<HTMLElement>(".lyr-kp-ridge")) : [],
words: Array.from(root.querySelectorAll<HTMLElement>(`.lyr-kp-cap[data-i="${i}"],.lyr-kp-count[data-i="${i}"]`)),
row: root.querySelector<HTMLElement>(`.lyr-kp-row[data-i="${i}"]`),
}
})
const paint = (p: number) => {
const s = clamp01(p) * (n - 1)
const a = Math.min(n - 1, Math.floor(s))
// Each picture rests whole for a while; the pull happens in the middle of a step.
let t = n > 1 ? clamp01((s - a - 0.2) / 0.6) : 0
if (reduce) t = t < 0.5 ? 0 : 1
works.forEach((w, index) => {
const flip = index % 2 === 1
const gone = index < a ? 1 : index === a ? t : 0
if (w.cv) {
w.cv.style.visibility = gone >= 1 || index > a + 1 ? "hidden" : "visible"
w.cv.style.pointerEvents = (index === a && t < 0.5) || (index === a + 1 && t >= 0.5) ? "auto" : "none"
}
const count = w.bands.length
for (let k = 0; k < count; k++) {
const el = w.bands[k]
const ridge = w.ridges[k]
const lag = count > 1 ? 0.5 / (count - 1) : 0
const local = smooth((gone - k * lag * 0.6) / (1 - lag * 0.6 * (count - 1)))
const x = local * (100 + SLANT) - SLANT
const a1 = x + SLANT
el.style.clipPath = flip ? `polygon(0 0, ${100 - a1}% 0, ${100 - x}% 100%, 0 100%)` : `polygon(${a1}% 0, 100% 0, 100% 100%, ${x}% 100%)`
if (ridge) {
const on = local > 0.004 && local < 0.996
ridge.style.visibility = on ? "visible" : "hidden"
if (on) ridge.style.clipPath = flip ? `polygon(${100 - a1 - 0.5}% 0, ${100 - a1 + 0.7}% 0, ${100 - x + 0.7}% 100%, ${100 - x - 0.5}% 100%)` : `polygon(${a1 - 0.7}% 0, ${a1 + 0.5}% 0, ${x + 0.5}% 100%, ${x - 0.7}% 100%)`
}
}
// Words: the open picture's caption, then the next one's once the pull is half way.
const mine = (index === a && t < 0.5) || (index === a + 1 && t >= 0.5)
const words = index === a ? 1 - smooth(t * 2.4) : index === a + 1 ? smooth(t * 2.4 - 1.4) : 0
for (const el of w.words) {
el.style.opacity = String(words)
el.style.visibility = words > 0.004 ? "visible" : "hidden"
}
if (w.row) w.row.style.color = mine ? "var(--kp-ink)" : index < a || (index === a && t >= 0.5) ? "var(--kp-quiet)" : "var(--kp-mute)"
})
}
// How far the section has been scrolled, eased so a mouse wheel's steps
// come out as one smooth movement.
let goal = 0
let shown = 0
let raf = 0
let last = 0
const tick = (now: number) => {
const dt = Math.min(64, now - last)
last = now
const gap = goal - shown
if (Math.abs(gap) < 0.0004) {
shown = goal
raf = 0
} else {
shown += gap * (1 - Math.exp(-dt / 110))
raf = requestAnimationFrame(tick)
}
paint(shown)
}
const measure = () => {
const rect = root.getBoundingClientRect()
const range = rect.height - window.innerHeight
goal = range > 1 ? clamp01(-rect.top / range) : 0
if (reduce) {
shown = goal
paint(shown)
} else if (!raf) {
last = performance.now()
raf = requestAnimationFrame(tick)
}
}
measure()
shown = goal
paint(shown)
window.addEventListener("scroll", measure, { passive: true })
window.addEventListener("resize", measure)
// Load the pictures under the first only when the section comes near.
const io = new IntersectionObserver((entries) => entries.some((e) => e.isIntersecting) && setNear(true), { rootMargin: "100% 0px" })
io.observe(root)
return () => {
window.removeEventListener("scroll", measure)
window.removeEventListener("resize", measure)
if (raf) cancelAnimationFrame(raf)
io.disconnect()
}
}, [isStatic, n, bands])
// A title in the list scrolls the page to its picture.
const goTo = (index: number) => {
const root = rootRef.current
if (!root || n < 2) return
const top = root.getBoundingClientRect().top + window.scrollY
const range = root.offsetHeight - window.innerHeight
window.scrollTo({ top: top + range * (index / (n - 1)), behavior: "smooth" })
}
const rootStyle = {
...style,
fontFamily: `"${fontFamily}", "Inter", system-ui, sans-serif`,
"--kp-len": `${(n - 1) * Math.max(20, Number(scrollPer) || 75)}vh`,
"--kp-top": `${topSpace}px`,
"--kp-side": `${sideSpace}px`,
"--kp-ink": ink,
"--kp-mute": mute,
"--kp-quiet": quietTone,
"--kp-line": line,
"--kp-shape": String(Math.min(2, Math.max(0.4, shape / 100))),
} as CSSProperties
const tag = String(headingTag).toLowerCase()
const Heading = tag.includes("h3") ? "h3" : tag.includes("p") ? "p" : "h2"
const hasText = !!(headline.trim() || headlineQuiet.trim() || text.trim())
// Band k covers this slice of the picture's height.
const edges = Array.from({ length: bands + 1 }, (_, k) => (k === 0 ? 0 : k === bands ? 100 : Math.round((100 / bands) * k + (k % 2 ? -2 : 2))))
return (
<div ref={rootRef} className={isStatic ? "lyr-kp lyr-kp-static" : "lyr-kp"} style={rootStyle}>
<style dangerouslySetInnerHTML={{ __html: CSS }} />
<div className="lyr-kp-pin">
<div className="lyr-kp-in">
{hasText && (
<div className="lyr-kp-text">
{(headline.trim() || headlineQuiet.trim()) && (
<Heading className="lyr-kp-h">
{headline} <span>{headlineQuiet}</span>
</Heading>
)}
{text.trim() && <p className="lyr-kp-p">{text}</p>}
{textLink && textLinkLabel.trim() && (
<a className="lyr-kp-more" href={textLink}>
{textLinkLabel}
</a>
)}
</div>
)}
<div className="lyr-kp-stage">
<div className="lyr-kp-box">
<div className="lyr-kp-cv" style={{ pointerEvents: "none", zIndex: 0 }}>
<div className="lyr-kp-shadow" />
</div>
{list.map((raw, index) => {
const work = raw || {}
const title = String(work.title ?? "")
const src = work.image?.src || ""
const show = near || isStatic || index === 0
const Canvas = work.link ? "a" : "div"
return (
<Canvas
key={index}
data-i={index}
className="lyr-kp-cv"
href={work.link || undefined}
aria-label={work.link ? title : undefined}
style={{ zIndex: n - index, visibility: isStatic || index <= 1 ? "visible" : "hidden", pointerEvents: index === 0 ? "auto" : "none" }}
>
{edges.slice(0, bands).map((edge, k) => (
<div key={k} className="lyr-kp-band" style={{ top: `${edge}%`, height: k < bands - 1 ? `calc(${edges[k + 1] - edge}% + 1px)` : `${edges[k + 1] - edge}%` }}>
{src ? (
show && <img srcSet={work.image?.srcSet} sizes={imageSizes} src={src} alt={k === 0 ? work.image?.alt || title : ""} decoding="async" style={{ top: `calc(var(--kp-hc) * ${-edge / 100})` }} />
) : (
<div className="lyr-kp-empty" style={{ top: `calc(var(--kp-hc) * ${-edge / 100})`, background: EMPTY[index % EMPTY.length] }} />
)}
</div>
))}
{edges.slice(0, bands).map((edge, k) => (
<div key={`r${k}`} className="lyr-kp-ridge" style={{ top: `${edge}%`, height: `${edges[k + 1] - edge}%` }} />
))}
</Canvas>
)
})}
{list.map((raw, index) => {
const work = raw || {}
const h = Number(work.workHeight) || 0
const w = Number(work.workWidth) || 0
const size = h > 0 && w > 0 ? `${h} × ${w} ${unit}` : ""
const parts = [work.year, work.medium, size].filter(Boolean) as string[]
const onTop = index === 0
return (
<div key={index} data-i={index} className="lyr-kp-cap" style={{ opacity: onTop ? 1 : 0, visibility: onTop ? "visible" : "hidden", zIndex: n + 1 }}>
<b>{work.title}</b>
{parts.map((part, i) => (
<span key={i} style={{ whiteSpace: "nowrap" }}>
{i > 0 ? " · " : ""}
{part}
</span>
))}
{work.link && linkLabel.trim() && <a href={work.link}>{linkLabel}</a>}
</div>
)
})}
{list.map((raw, index) => (
<span key={index} data-i={index} className="lyr-kp-count" style={{ opacity: index === 0 ? 1 : 0, zIndex: n + 1 }}>
{index + 1} of {n}
</span>
))}
{list.map((raw, index) => {
const work = raw || {}
const h = Number(work.workHeight) || 0
const w = Number(work.workWidth) || 0
return (
<button
key={index}
data-i={index}
type="button"
className={index === 0 ? "lyr-kp-row first" : "lyr-kp-row"}
onClick={() => goTo(index)}
style={{ top: `calc(var(--kp-ct) + ${index * 57}px)`, color: index === 0 ? "var(--kp-ink)" : "var(--kp-mute)" }}
>
<em>{work.title}</em>
<span>{h > 0 && w > 0 ? `${h} × ${w} ${unit}` : ""}</span>
</button>
)
})}
</div>
</div>
</div>
</div>
</div>
)
}
addPropertyControls(KnifePull, {
items: {
type: ControlType.Array,
title: "Works",
maxCount: 8,
control: {
type: ControlType.Object,
controls: {
image: { type: ControlType.ResponsiveImage, title: "Image" },
title: { type: ControlType.String, title: "Title", defaultValue: "" },
year: { type: ControlType.String, title: "Year", defaultValue: "" },
medium: { type: ControlType.String, title: "Medium", defaultValue: "" },
workHeight: { type: ControlType.Number, title: "Height of work", defaultValue: 0, min: 0, step: 0.1, displayStepper: true },
workWidth: { type: ControlType.Number, title: "Width of work", defaultValue: 0, min: 0, step: 0.1, displayStepper: true },
link: { type: ControlType.Link, title: "Link" },
},
},
defaultValue: WORKS,
description: "The first lies on top and is shown first.",
},
headline: { type: ControlType.String, title: "Headline", defaultValue: "Work on the walls.", displayTextArea: true },
headlineQuiet: { type: ControlType.String, title: "Headline, quiet part", defaultValue: "Each one lifts away to show the next.", displayTextArea: true },
text: { type: ControlType.String, title: "Text", defaultValue: "A few pieces stacked in one frame. Keep scrolling and the one on top is scraped back in a few strokes.", displayTextArea: true, description: "Leave the headline and text empty to give the stage the full width." },
textLink: { type: ControlType.Link, title: "Text link" },
textLinkLabel: { type: ControlType.String, title: "Text link label", defaultValue: "See all work" },
headingTag: { type: ControlType.Enum, title: "Headline tag", options: ["h2", "h3", "p"], optionTitles: ["H2", "H3", "P"], defaultValue: "h2" },
linkLabel: { type: ControlType.String, title: "Link label", defaultValue: "View", description: "Shown after the caption of a work that has a link." },
unit: { type: ControlType.String, title: "Unit", defaultValue: "cm" },
shape: { type: ControlType.Number, title: "Picture shape", defaultValue: 75, min: 40, max: 200, step: 1, unit: "%", description: "Width as a share of height. Every picture is cropped to this one shape so they sit exactly over each other." },
pulls: { type: ControlType.Number, title: "Knife pulls", defaultValue: 3, min: 1, max: 6, step: 1, displayStepper: true, description: "How many strokes it takes to pull a picture away." },
scrollPer: { type: ControlType.Number, title: "Scroll per work", defaultValue: 75, min: 30, max: 200, step: 5, unit: "vh", description: "How much scrolling it takes to pull one picture away." },
topSpace: { type: ControlType.Number, title: "Top space", defaultValue: 72, min: 0, max: 200, step: 1, unit: "px", description: "Height of the navigation above." },
sideSpace: { type: ControlType.Number, title: "Side space", defaultValue: 48, min: 0, max: 120, step: 1, unit: "px", description: "Shrinks to 5% of the width on narrow screens." },
fontFamily: { type: ControlType.String, title: "Font", defaultValue: "Inter", description: "The family name used by the site's text styles." },
ink: { type: ControlType.Color, title: "Ink", defaultValue: "#14130F" },
mute: { type: ControlType.Color, title: "Muted", defaultValue: "#77746B" },
quietTone: { type: ControlType.Color, title: "Quiet tone", defaultValue: "#A3A097", description: "Titles of pictures already seen, and the headline's second tone." },
line: { type: ControlType.Color, title: "Line", defaultValue: "rgba(20,19,15,0.14)" },
imageSizes: { type: ControlType.String, title: "Image width", defaultValue: "40vw", description: "One length, for example 40vw." },
})
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.