Layr Components

Components / Images

Pushes

A photo under a grid of dark squares that clear a few at a time, roughly left to right, as it scrolls up the window.

Scroll insideFull screen
Props
28 px
72%
6 px
#FFFFFF
#E6E5E1

Set in code: Image. 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. Pushes shows up in the Insert menu, and its controls appear in the right panel.
// Pushes · Layr Components
//
// A photograph seen through a grid of dark squares. As it scrolls up the
// window the squares clear a few at a time, roughly left to right, until the
// whole picture shows. Without JavaScript, or with reduced motion, the
// photograph is simply whole.
// Paste into a new code file in Framer and give it a fixed size. Without a photo it shows a plain tone.

import { addPropertyControls, ControlType, useIsStaticRenderer } from "framer"
import { useEffect, useRef, type CSSProperties } from "react"

type Picture = { src: string; srcSet?: string; alt?: string }

interface PushesProps {
    image?: Picture
    altText: string
    cell: number
    focusY: number
    radius: number
    cover: string
    hairline: string
    imageSizes: string
    style?: CSSProperties
}

// Shown when no photo is set: a quiet tone, so the squares still have something to clear.
const EMPTY = "linear-gradient(160deg, #3a3631, #1f1d1a)"

/**
 * @framerIntrinsicWidth 1200
 * @framerIntrinsicHeight 700
 *
 * @framerSupportedLayoutWidth fixed
 * @framerSupportedLayoutHeight fixed
 */
export default function Pushes(props: PushesProps) {
    const { image, altText = "", cell = 28, focusY = 72, radius = 6, cover = "#0D0C0A", hairline = "rgba(240,236,228,0.14)", imageSizes = "94vw" } = props
    const isStatic = useIsStaticRenderer()
    const root = useRef<HTMLDivElement>(null)
    const canvas = useRef<HTMLCanvasElement>(null)

    useEffect(() => {
        if (isStatic) return
        const el = root.current
        const cv = canvas.current
        if (!el || !cv) return
        if (typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches) return
        const ctx = cv.getContext("2d")
        if (!ctx) return
        let cols = 0
        let rows = 0
        let size = 0
        let order: number[] = []
        let w = 0
        let h = 0
        let frame = 0
        let drawn = -1
        const probe = document.createElement("span")
        probe.style.cssText = "position:absolute;visibility:hidden"
        el.appendChild(probe)
        const colour = (c: string) => {
            probe.style.color = c
            return getComputedStyle(probe).color
        }
        let fill = "#0D0C0A"
        let line = "rgba(240,236,228,0.1)"
        const layout = () => {
            w = el.offsetWidth
            h = el.offsetHeight
            const dpr = Math.min(2, window.devicePixelRatio || 1)
            cv.width = Math.round(w * dpr)
            cv.height = Math.round(h * dpr)
            ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
            cols = Math.max(6, Math.round(w / Math.max(12, cell)))
            size = w / cols
            rows = Math.ceil(h / size)
            // each square gets a fixed turn: mostly left to right, with enough scatter to look like days of work
            let seed = 7
            const rnd = () => (seed = (seed * 16807) % 2147483647) / 2147483647
            const turn = Array.from({ length: cols * rows }, (_, i) => ((i % cols) / cols) * 0.55 + rnd() * 0.45)
            order = turn
                .map((t, i) => [t, i] as [number, number])
                .sort((a, b) => a[0] - b[0])
                .map((x) => x[1])
            fill = colour(cover)
            line = colour(hairline)
            drawn = -1
            cv.style.opacity = "1"
        }
        const paint = () => {
            frame = 0
            const r = el.getBoundingClientRect()
            const vh = window.innerHeight
            const p = Math.min(1, Math.max(0, (vh * 0.92 - r.top) / (vh * 0.62)))
            const cleared = Math.round(order.length * p)
            if (cleared === drawn) return
            drawn = cleared
            ctx.clearRect(0, 0, w, h)
            ctx.fillStyle = fill
            ctx.strokeStyle = line
            ctx.lineWidth = 1
            for (let n = cleared; n < order.length; n++) {
                const i = order[n]
                const x = (i % cols) * size
                const y = Math.floor(i / cols) * size
                ctx.fillRect(x, y, size + 0.5, size + 0.5)
                ctx.strokeRect(x + 1.5, y + 1.5, size - 3, size - 3)
            }
        }
        const onScroll = () => {
            if (!frame) frame = requestAnimationFrame(paint)
        }
        const onResize = () => {
            layout()
            paint()
        }
        onResize()
        const ro = typeof ResizeObserver === "function" ? new ResizeObserver(onResize) : null
        if (ro) ro.observe(el)
        window.addEventListener("scroll", onScroll, { passive: true })
        return () => {
            window.removeEventListener("scroll", onScroll)
            if (ro) ro.disconnect()
            cancelAnimationFrame(frame)
            probe.remove()
        }
    }, [isStatic, cell, cover, hairline])

    const fit: CSSProperties = { position: "absolute", inset: 0, width: "100%", height: "100%", display: "block" }
    return (
        <div ref={root} style={{ position: "relative", width: "100%", height: "100%", overflow: "hidden", borderRadius: radius, ...props.style }}>
            {image && image.src ? (
                <img
                    srcSet={image.srcSet}
                    sizes={image.srcSet ? imageSizes : undefined}
                    src={image.src}
                    alt={altText || image.alt || ""}
                    loading="lazy"
                    decoding="async"
                    draggable={false}
                    style={{ ...fit, objectFit: "cover", objectPosition: `50% ${focusY}%` }}
                />
            ) : (
                <div role="img" aria-label={altText || "Photo"} style={{ ...fit, background: EMPTY }} />
            )}
            <canvas ref={canvas} aria-hidden="true" style={{ ...fit, opacity: 0, pointerEvents: "none" }} />
        </div>
    )
}

addPropertyControls(Pushes, {
    image: { type: ControlType.ResponsiveImage, title: "Image" },
    altText: { type: ControlType.String, title: "Alt text", defaultValue: "", description: "Describe the photograph for people who cannot see it." },
    cell: { type: ControlType.Number, title: "Square size", defaultValue: 28, min: 12, max: 120, step: 1, unit: "px" },
    focusY: { type: ControlType.Number, title: "Focus", defaultValue: 72, min: 0, max: 100, step: 1, unit: "%", description: "Which part of a tall photograph stays in view: 0 is the top, 100 the bottom." },
    radius: { type: ControlType.Number, title: "Radius", defaultValue: 6, min: 0, max: 60, step: 1, unit: "px" },
    cover: { type: ControlType.Color, title: "Cover", defaultValue: "#0D0C0A", description: "The colour of the squares before they clear. Use the page colour." },
    hairline: { type: ControlType.Color, title: "Hairline", defaultValue: "rgba(240,236,228,0.14)" },
    imageSizes: { type: ControlType.String, title: "Image width", defaultValue: "94vw", description: "One length, for example 94vw." },
})

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.