Layr Components

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
75%
3
75 vh
24 px
48 px
#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.