Components / Preloader
Waterfall
A cover that charts the page's real requests (document, fonts, scripts, images) on a live time axis, then opens like shutters.
Props
#FFFFFF
#0E0E0D
#6E6E6B
#A3A3A0
#E6E5E1
#FF4A1C
Press Replay to chart the load again.
In Framer, open Assets, add a new code file, paste this over everything in it and save. Waterfall shows up in the Insert menu, and its controls appear in the right panel.
// Waterfall · Layr Components
//
// A cover that charts this page's own requests as they finish (document,
// typeface, script, images) on a live time axis and counts the milliseconds.
// When the page is ready the five bars open like shutters onto it. The bars
// and the number are read from the visitor's browser (Resource Timing), not
// made up. It plays once a visit, is skipped with reduced motion, and never
// shows without JavaScript. When it lands it sets window.__lyrWfMs to the
// number it shows and fires a "lyr-wf:ready" event; "lyr-wf:done" once it has opened.
// Paste into a new code file in Framer. Place one on each page (or in a layout template), fixed, as wide and as tall as the window, above everything else.
import { addPropertyControls, ControlType, useIsStaticRenderer } from "framer"
import { useEffect, useMemo, useRef, useState, type CSSProperties } from "react"
interface WaterfallProps {
play: "once" | "always" | "off"
row1: string
row2: string
row3: string
row4: string
row5: string
unit: string
shortest: number
longest: number
fontFamily: string
ground: string
ink: string
mute: string
quiet: string
hairline: string
accent: string
style?: CSSProperties
}
type Settings = Omit<WaterfallProps, "style">
const esc = (s: string) => String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """)
const X = (v: string) => `calc(var(--x0) + (100% - var(--x0) - var(--x1)) * var(${v}) / var(--max))`
const EASE = "cubic-bezier(.76,0,.24,1)"
const css = (p: Settings) => `
.lyr-wf{display:none}
html[data-lyr-wf="run"] .lyr-wf{display:flex}
.lyr-wf{position:fixed;inset:0;z-index:10;flex-direction:column;overflow:hidden;pointer-events:auto;--g:${p.ground};--ink:${p.ink};--mute:${p.mute};--quiet:${p.quiet};--hair:${p.hairline};--accent:${p.accent};--edge:var(--hair);--tick:var(--hair);--x0:250px;--x1:48px;--pad:48px;--bar:10px;--max:500;color:var(--ink);font-family:"${p.fontFamily}","Inter",system-ui,sans-serif;font-weight:400;font-size:15px;line-height:1.2;letter-spacing:-0.01em;font-variant-numeric:tabular-nums;-webkit-font-smoothing:antialiased;animation:lyr-wf-safe 0s linear 8s forwards}
@supports (color:color-mix(in srgb,red 50%,transparent)){.lyr-wf{--edge:color-mix(in srgb,var(--ink) 36%,transparent);--tick:color-mix(in srgb,var(--hair) 60%,transparent)}}
@keyframes lyr-wf-safe{to{visibility:hidden}}
@media (max-width:809.98px){.lyr-wf{--x0:20px;--x1:20px;--pad:20px;--bar:8px}}
@media (prefers-reduced-motion:reduce){html[data-lyr-wf="run"] .lyr-wf{display:none}}
.lyr-wf-row{position:relative;flex:1 1 0;min-height:0;display:flex;flex-direction:column;--a:0;--b:0}
.lyr-wf-top,.lyr-wf-bot{flex:1 1 0;min-height:0;background:var(--g)}
.lyr-wf-mid{flex:0 0 auto;height:var(--bar);display:flex;background:var(--g)}
.lyr-wf-l{flex:0 0 auto;width:${X("--a")};background:var(--g)}
.lyr-wf-r{flex:0 0 auto;width:calc(100% - ${X("--b")});background:var(--g)}
.lyr-wf-bar{flex:1 1 0;min-width:0;background:var(--ink);transform:scaleX(0);transform-origin:0 50%;transition:transform .32s cubic-bezier(.2,.7,.2,1)}
.lyr-wf-row.lyr-wf-on .lyr-wf-bar{transform:none}
.lyr-wf-row.lyr-wf-rdy .lyr-wf-bar{background:var(--accent)}
.lyr-wf-name{position:absolute;left:var(--pad);top:50%;transform:translateY(-50%);white-space:nowrap;color:var(--quiet);transition:color .25s ease,opacity .2s ease}
.lyr-wf-row.lyr-wf-on .lyr-wf-name{color:var(--ink)}
.lyr-wf-ms{position:absolute;left:calc(${X("--b")} + 12px);top:50%;transform:translateY(-50%);white-space:nowrap;font-size:14px;color:var(--mute);opacity:0;transition:opacity .25s ease .1s}
.lyr-wf-row.lyr-wf-on .lyr-wf-ms{opacity:1}
.lyr-wf-axis{position:absolute;inset:0;pointer-events:none;transition:opacity .2s ease}
.lyr-wf-tk{position:absolute;top:0;bottom:0;left:${X("--t")};width:1px;background:var(--tick);opacity:0;transition:opacity .25s ease}
.lyr-wf-tk span{position:absolute;top:28px;left:8px;font-size:13px;color:var(--quiet)}
.lyr-wf[data-step="1"] .lyr-wf-tk,.lyr-wf[data-step="2"] .lyr-wf-s2,.lyr-wf[data-step="5"] .lyr-wf-s5,.lyr-wf[data-step="10"] .lyr-wf-s10,.lyr-wf[data-step="20"] .lyr-wf-s20,.lyr-wf[data-step="50"] .lyr-wf-s50{opacity:1}
.lyr-wf-head{position:absolute;top:0;bottom:0;left:${X("--t")};width:1px;background:var(--accent);--t:0}
.lyr-wf-num{position:absolute;right:var(--x1);bottom:calc(10% + 24px);white-space:nowrap;font-size:64px;line-height:1;letter-spacing:-0.045em;transition:opacity .2s ease .14s}
.lyr-wf-u{font-size:.4em;letter-spacing:-0.02em;margin-left:.3em;color:var(--mute)}
@media (max-width:809.98px){.lyr-wf-name{top:calc(50% - 24px)}.lyr-wf-ms{left:${X("--a")};top:calc(50% + 20px)}.lyr-wf-num{font-size:44px;bottom:calc(10% + 20px)}.lyr-wf-tk span{left:6px}.lyr-wf-head{bottom:20%}}
@keyframes lyr-wf-edge{0%{box-shadow:inset 0 0 0 1px transparent}18%,62%{box-shadow:inset 0 0 0 1px var(--edge)}100%{box-shadow:inset 0 0 0 1px transparent}}
.lyr-wf.lyr-wf-open{pointer-events:none}
.lyr-wf.lyr-wf-open .lyr-wf-mid{height:100%;background:transparent;transition:height .5s ${EASE} calc(var(--i) * 30ms)}
.lyr-wf.lyr-wf-open .lyr-wf-l,.lyr-wf.lyr-wf-open .lyr-wf-r{width:0;transition:width .5s ${EASE} calc(var(--i) * 30ms)}
.lyr-wf.lyr-wf-open .lyr-wf-bar{background-color:transparent;transition:background-color .22s ease calc(var(--i) * 30ms);animation:lyr-wf-edge .54s linear calc(var(--i) * 30ms) both}
.lyr-wf.lyr-wf-open .lyr-wf-name,.lyr-wf.lyr-wf-open .lyr-wf-ms,.lyr-wf.lyr-wf-open .lyr-wf-axis,.lyr-wf.lyr-wf-open .lyr-wf-num{opacity:0}
`
// Runs from the server HTML before React, so it is written as plain script text.
const SCRIPT = `(function(){
var W=window,D=document,H=D.documentElement,P=W.performance,S=D.currentScript,R=S&&S.parentNode;
if(!R||!P||!P.now)return;
var st=H.getAttribute("data-lyr-wf");
if(!st){var play=R.getAttribute("data-play"),skip=play==="off";
try{if(W.matchMedia&&W.matchMedia("(prefers-reduced-motion: reduce)").matches)skip=true}catch(e){}
try{if(!skip&&play==="once"){if(W.sessionStorage.getItem("lyr-waterfall"))skip=true;else W.sessionStorage.setItem("lyr-waterfall","1")}}catch(e){}
st=skip?"seen":"run";H.setAttribute("data-lyr-wf",st)}
if(st!=="run")return;if(W.__lyrWf){R.style.display="none";return}if(!R.getClientRects().length)return;
var api=W.__lyrWf={hyd:0,hydrated:function(){if(!api.hyd)api.hyd=P.now()}};
function done(){H.setAttribute("data-lyr-wf","done");R.style.display="none";try{W.dispatchEvent(new Event("lyr-wf:done"))}catch(e){}}
try{
var rows=R.querySelectorAll(".lyr-wf-row"),head=R.querySelector(".lyr-wf-head"),num=R.querySelector(".lyr-wf-n"),unit=R.getAttribute("data-unit")||"";
var minShow=+R.getAttribute("data-min")||0,maxWait=+R.getAttribute("data-wait")||4000;
var nav=P.getEntriesByType?P.getEntriesByType("navigation")[0]:null;
var t0=P.now(),domAt=0,readyAt=0,step=0,last="";
if(D.readyState!=="loading")domAt=(nav&&nav.domContentLoadedEventEnd)||t0;else D.addEventListener("DOMContentLoaded",function(){domAt=P.now()});
var le=nav?nav.loadEventEnd:0,replay=le>0&&t0>le+600,total=replay?Math.round(le):0;
var A=[0,0,0,0,0],B=[0,0,0,0,0],on=[1,0,0,0,0];
function kind(n,it){if(/\\.(woff2?|ttf|otf)(\\?|#|$)/i.test(n)||/\\/\\/fonts\\.(googleapis|gstatic)\\.com\\//i.test(n))return 1;if(it==="script"||/\\.m?js(\\?|#|$)/i.test(n))return 2;if(it==="img"||it==="image"||/\\.(png|jpe?g|webp|avif|gif|svg)(\\?|#|$)/i.test(n))return 3;return -1}
function scan(lim){var a=[0,1e12,1e12,1e12],b=[0,0,0,0],es=P.getEntriesByType?P.getEntriesByType("resource"):[],i,e,k,x;
for(i=0;i<es.length;i++){e=es[i];if(lim&&e.startTime>lim)continue;k=kind(e.name,e.initiatorType);if(k<0)continue;x=e.responseEnd||(e.startTime+e.duration);if(lim&&x>lim)x=lim;if(e.startTime<a[k])a[k]=e.startTime;if(x>b[k])b[k]=x}
for(k=1;k<4;k++)if(b[k]>0){A[k]=a[k];B[k]=b[k];on[k]=1}}
function fmt(n){n=Math.round(n);var s=String(n);return n>=1000?s.slice(0,-3)+","+s.slice(-3):s}
function draw(v){scan(replay?v:0);
var re=nav?nav.responseEnd:0;B[0]=re>0&&re<v?re:v;
var max=Math.max(500,v/0.8),w=R.clientWidth,x0=w<810?20:250,x1=w<810?20:48,px=Math.max(1,w-x0-x1)/max,thin=6/px,sq=(w<810?8:10)/px;
R.style.setProperty("--max",max.toFixed(1));
var s=px*100>=72?1:px*200>=72?2:px*500>=72?5:px*1000>=72?10:px*2000>=72?20:50;if(s!==step){step=s;R.setAttribute("data-step",s)}
for(var k=0;k<5;k++){var r=rows[k];if(!r||!on[k])continue;
r.style.setProperty("--a",Math.max(0,Math.min(A[k],B[k]-(k===4?sq:thin))).toFixed(1));r.style.setProperty("--b",B[k].toFixed(1));
if(!r.__on){r.__on=1;r.className+=k===4?" lyr-wf-on lyr-wf-rdy":" lyr-wf-on"}
if(k<4){var ms=fmt(B[k]-A[k])+(unit?" "+unit:"");if(r.__ms!==ms){r.__ms=ms;r.lastChild.textContent=ms}}}
head.style.setProperty("--t",v.toFixed(1));var t=fmt(v);if(t!==last){last=t;num.textContent=t}}
function open(){R.className+=" lyr-wf-open";setTimeout(done,680)}
function finish(){W.__lyrWfMs=Math.round(readyAt);
var a=0,k;for(k=0;k<4;k++)if(on[k]&&B[k]>a)a=B[k];A[4]=Math.min(a,readyAt);B[4]=readyAt;on[4]=1;draw(readyAt);
try{W.dispatchEvent(new Event("lyr-wf:ready"))}catch(e){}
setTimeout(open,Math.max(120,t0+minShow-P.now()+120))}
function tick(){var now=P.now(),v=now;
if(replay){v=Math.min(total,(now-t0)/700*total);if(v>=total)readyAt=total}
else{var hyd=api.hyd,cap=now-t0>maxWait;
if(domAt&&(hyd||now>domAt+2500||cap)){var late=Math.max(domAt,hyd||0);
if(cap||!D.fonts||D.fonts.status==="loaded"||now>late+1500){scan(0);readyAt=hyd&&!cap?Math.min(now,Math.max(late,B[1])):now;v=readyAt}}}
if(readyAt)finish();else{draw(v);W.requestAnimationFrame(tick)}}
tick();
}catch(e){done()}
})()`
function ticks() {
const at: number[] = []
for (let t = 0; t <= 2000; t += 100) at.push(t)
for (let t = 2500; t <= 10000; t += 500) at.push(t)
for (let t = 11000; t <= 30000; t += 1000) at.push(t)
const label = (t: number) => (t >= 1000 ? String(t).slice(0, -3) + "," + String(t).slice(-3) : String(t))
return at
.map((t) => {
const c = ["lyr-wf-tk", t % 200 === 0 ? "lyr-wf-s2" : "", t % 500 === 0 ? "lyr-wf-s5" : "", t % 1000 === 0 ? "lyr-wf-s10" : "", t % 2000 === 0 ? "lyr-wf-s20" : "", t % 5000 === 0 ? "lyr-wf-s50" : ""].filter(Boolean).join(" ")
return `<div class="${c}" style="--t:${t}"><span>${label(t)}</span></div>`
})
.join("")
}
function markup(p: Settings) {
const names = [p.row1, p.row2, p.row3, p.row4, p.row5]
const rows = names
.map(
(n, i) =>
`<div class="lyr-wf-row" style="--i:${i}"><div class="lyr-wf-top"></div><div class="lyr-wf-mid"><div class="lyr-wf-l"></div><div class="lyr-wf-bar"></div><div class="lyr-wf-r"></div></div><div class="lyr-wf-bot"></div><div class="lyr-wf-name">${esc(n)}</div><div class="lyr-wf-ms"></div></div>`
)
.join("")
const shortest = Math.round(Math.max(0, p.shortest) * 1000)
const longest = Math.round(Math.max(1, p.longest) * 1000)
return `<style>${css(p)}</style><div class="lyr-wf" data-play="${esc(p.play)}" data-min="${shortest}" data-wait="${longest}" data-unit="${esc(p.unit)}">${rows}<div class="lyr-wf-axis">${ticks()}<div class="lyr-wf-head"></div></div><div class="lyr-wf-num"><span class="lyr-wf-n">0</span><span class="lyr-wf-u">${esc(p.unit)}</span></div><script>${SCRIPT}</script></div>`
}
/**
* @framerSupportedLayoutWidth fixed
* @framerSupportedLayoutHeight fixed
*/
export default function Waterfall(props: WaterfallProps) {
const {
play = "once",
row1 = "Document",
row2 = "Typeface",
row3 = "Script",
row4 = "Images",
row5 = "Ready",
unit = "ms",
shortest = 0.45,
longest = 4,
fontFamily = "Inter",
ground = "#0D0C0A",
ink = "#F0ECE4",
mute = "#918D84",
quiet = "#625F58",
hairline = "rgba(240,236,228,0.14)",
accent = "#FFB02E",
} = props
const isStatic = useIsStaticRenderer()
const host = useRef<HTMLDivElement>(null)
const [gone, setGone] = useState(false)
const html = useMemo(
() => markup({ play, row1, row2, row3, row4, row5, unit, shortest, longest, fontFamily, ground, ink, mute, quiet, hairline, accent }),
[play, row1, row2, row3, row4, row5, unit, shortest, longest, fontFamily, ground, ink, mute, quiet, hairline, accent]
)
useEffect(() => {
if (isStatic || typeof window === "undefined") return
const root = document.documentElement
const el = host.current
if (!el) return
if (!root.getAttribute("data-lyr-wf")) {
// Drawn by the browser, not from the server HTML: the script was inserted without running. Run it now.
const cover = el.querySelector(".lyr-wf")
const old = cover ? cover.querySelector("script") : null
if (cover && old) {
const fresh = document.createElement("script")
fresh.textContent = old.textContent
old.remove()
cover.appendChild(fresh)
}
}
const api = (window as any).__lyrWf
if (api && typeof api.hydrated === "function") api.hydrated()
const state = root.getAttribute("data-lyr-wf")
if (state !== "run") {
setGone(true)
return
}
const done = () => setGone(true)
window.addEventListener("lyr-wf:done", done)
return () => window.removeEventListener("lyr-wf:done", done)
}, [isStatic])
if (isStatic) return <div style={{ width: "100%", height: "100%" }} aria-hidden="true" />
if (gone) return null
return <div ref={host} aria-hidden="true" dangerouslySetInnerHTML={{ __html: html }} />
}
addPropertyControls(Waterfall, {
play: { type: ControlType.Enum, title: "Play", options: ["once", "always", "off"], optionTitles: ["Once a visit", "Every load", "Off"], defaultValue: "once", description: "Once a visit is right for a live site. Every load helps while you are editing." },
row1: { type: ControlType.String, title: "Row 1", defaultValue: "Document", description: "The page itself." },
row2: { type: ControlType.String, title: "Row 2", defaultValue: "Typeface", description: "The font files." },
row3: { type: ControlType.String, title: "Row 3", defaultValue: "Script" },
row4: { type: ControlType.String, title: "Row 4", defaultValue: "Images" },
row5: { type: ControlType.String, title: "Row 5", defaultValue: "Ready", description: "The moment the page can be used." },
unit: { type: ControlType.String, title: "Unit", defaultValue: "ms" },
shortest: { type: ControlType.Number, title: "Shortest show", defaultValue: 0.45, min: 0, max: 2, step: 0.05, unit: "s", description: "On a fast connection the chart stays up at least this long before it opens." },
longest: { type: ControlType.Number, title: "Longest wait", defaultValue: 4, min: 1, max: 10, step: 0.5, unit: "s", description: "It opens after this long even if the page is still loading." },
fontFamily: { type: ControlType.String, title: "Font", defaultValue: "Inter", description: "The family name used by the site's text styles." },
ground: { type: ControlType.Color, title: "Page", defaultValue: "#0D0C0A" },
ink: { type: ControlType.Color, title: "Ink", defaultValue: "#F0ECE4" },
mute: { type: ControlType.Color, title: "Muted", defaultValue: "#918D84" },
quiet: { type: ControlType.Color, title: "Quiet", defaultValue: "#625F58" },
hairline: { type: ControlType.Color, title: "Hairline", defaultValue: "rgba(240,236,228,0.14)" },
accent: { type: ControlType.Color, title: "Accent", defaultValue: "#FFB02E" },
})
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.