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