Components / Preloader
Kept Frame
A sheet of twelve empty frames counts to the kept one, marked in the accent colour, which opens until it fills the window. Once per session.
Props
#0E0E0D
#6E6E6B
#E6E5E1
#FF4A1C
#FFFFFF
Press Replay to count the sheet again.
In Framer, open Assets, add a new code file, paste this over everything in it and save. Kept Frame shows up in the Insert menu, and its controls appear in the right panel.
// Kept Frame · Layr Components
//
// A short opening for the first page a visitor sees. A contact sheet of twelve
// empty frames is counted through, one is marked as the kept frame in the
// accent colour, and that frame opens until it is the whole window. About a
// second and a half; it waits a little longer while the first photos on screen
// load (3.6 seconds at most), plays once per session and takes itself out.
// Paste into a new code file in Framer. Place one in the layout template (or on
// each page); it is 1 × 1 on the canvas and its curtain is fixed over the window.
// To make it also wait for a layer of your own, give that layer the attribute
// data-lyr-kf-wait and set it to "done" when it is ready.
import { addPropertyControls, ControlType, useIsStaticRenderer } from "framer"
import { useEffect, useRef, useState, type CSSProperties } from "react"
interface KeptFrameProps {
text: string
kept: number
oncePerSession: boolean
fontFamily: string
ink: string
muted: string
line: string
accent: string
ground: string
style?: CSSProperties
}
const FRAMES = 12
// Seconds. T0 is the pause before the count starts, STEP is the time per frame.
const T0 = 0.2
const STEP = 0.055
const KEY = "lyr-kept-frame"
// The whole sequence is plain CSS, so it plays from the first paint, before
// any script runs, and it always ends hidden even if a script fails. The
// curtain is the kept frame itself: its shadow covers the window, and when
// the frame grows to the size of the window the page is what is left.
const SHEET = `
.lyr-kf{position:fixed;left:0;top:0;width:100vw;height:100vh;height:100dvh;z-index:2147483000;pointer-events:none;overflow:hidden;font-size:13px;line-height:1.45;letter-spacing:-.005em;font-variant-numeric:tabular-nums;--g:8px;--cols:4;--rows:3;--gw:min(52vw,80vh,760px);--cw:calc((var(--gw) - (var(--cols) - 1) * var(--g)) / var(--cols));--ch:calc(var(--cw) * 2 / 3);--gh:calc(var(--rows) * var(--ch) + (var(--rows) - 1) * var(--g));--x0:calc(50% - var(--gw) / 2);--y0:calc(50% - var(--gh) / 2 - 16px);--mark:calc(var(--t0) + (var(--k) - 1) * var(--step));--open:calc(var(--mark) + .3s);animation:lyr-kf-end .01s linear calc(var(--open) + .78s) both}
.lyr-kf-at{position:absolute;box-sizing:border-box;--c:var(--c4);--r:var(--r4);left:calc(var(--x0) + var(--c) * (var(--cw) + var(--g)));top:calc(var(--y0) + var(--r) * (var(--ch) + var(--g)));width:var(--cw);height:var(--ch)}
.lyr-kf-sheet{position:absolute;left:0;top:0;width:100%;height:100%;z-index:2;animation:lyr-kf-away .2s ease var(--open) both}
.lyr-kf-cell{border:1px solid var(--kf-line);animation:lyr-kf-in .3s ease both}
.lyr-kf-cell::after,.lyr-kf-kept::after{content:"";position:absolute;left:0;top:0;right:0;bottom:0;background:var(--kf-ink);opacity:0}
.lyr-kf-cell[data-lit="1"]::after{animation:lyr-kf-flash .34s ease-out calc(var(--t0) + var(--i) * var(--step)) both}
.lyr-kf-n{position:absolute;left:7px;bottom:5px;font-size:10px;line-height:1;color:var(--kf-muted);opacity:.75}
.lyr-kf-kept{z-index:1;background:var(--kf-ground);box-shadow:0 0 0 100vmax var(--kf-ground);outline:1.5px solid transparent;outline-offset:9px;animation:lyr-kf-mark .24s cubic-bezier(.2,.7,.2,1) var(--mark) both,lyr-kf-clear .16s linear var(--open) both,lyr-kf-open .78s cubic-bezier(.77,0,.18,1) var(--open) both}
.lyr-kf-kept::before{content:"";position:absolute;left:0;top:0;right:0;bottom:0;border:1px solid var(--kf-line);animation:lyr-kf-in .3s ease both,lyr-kf-unline .2s linear var(--open) both}
.lyr-kf-kept::after{animation:lyr-kf-hold .3s ease-out var(--mark) both}
.lyr-kf-num{animation:lyr-kf-in .3s ease both}
.lyr-kf-cap{position:absolute;left:var(--x0);top:calc(var(--y0) + var(--gh) + 14px);width:var(--gw);display:flex;justify-content:space-between;gap:16px;white-space:nowrap;color:var(--kf-muted);animation:lyr-kf-in .3s ease both}
.lyr-kf-left{display:flex;gap:.28em;flex:none}
.lyr-kf-hot{color:var(--kf-accent)}
.lyr-kf-od{display:block;height:1.45em;overflow:hidden;color:var(--kf-accent)}
.lyr-kf-strip{display:block;--end:calc((var(--k) - 1) * -1.45em)}
.lyr-kf-strip span{display:block;height:1.45em}
.lyr-kf-name{color:var(--kf-ink);min-width:0;overflow:hidden;text-overflow:ellipsis}
html[data-lyr-kf-seen] .lyr-kf{display:none}
html[data-lyr-kf-hold] .lyr-kf,html[data-lyr-kf-hold] .lyr-kf-sheet{animation-name:none}
html[data-lyr-kf-hold] .lyr-kf-kept{animation:lyr-kf-mark .24s cubic-bezier(.2,.7,.2,1) var(--mark) both}
html[data-lyr-kf-hold] .lyr-kf-kept::before{animation:lyr-kf-in .3s ease both}
html[data-lyr-kf-go] .lyr-kf{animation:lyr-kf-end .01s linear .78s both}
html[data-lyr-kf-go] .lyr-kf-sheet{animation:lyr-kf-away .2s ease both}
html[data-lyr-kf-go] .lyr-kf-kept{animation:lyr-kf-mark .24s cubic-bezier(.2,.7,.2,1) var(--mark) both,lyr-kf-clear .16s linear both,lyr-kf-open .78s cubic-bezier(.77,0,.18,1) both}
html[data-lyr-kf-go] .lyr-kf-kept::before{animation:lyr-kf-in .3s ease both,lyr-kf-unline .2s linear both}
@keyframes lyr-kf-in{from{opacity:0}to{opacity:1}}
@keyframes lyr-kf-away{to{opacity:0}}
@keyframes lyr-kf-flash{0%{opacity:0}18%{opacity:.14}100%{opacity:0}}
@keyframes lyr-kf-hold{0%{opacity:0}25%{opacity:.16}100%{opacity:0}}
@keyframes lyr-kf-count{to{transform:translateY(var(--end))}}
@keyframes lyr-kf-mark{from{outline-color:transparent;outline-offset:9px}to{outline-color:var(--kf-accent);outline-offset:3px}}
@keyframes lyr-kf-clear{to{background-color:transparent}}
@keyframes lyr-kf-unline{to{border-color:transparent}}
@keyframes lyr-kf-open{to{left:0;top:0;width:100%;height:100%}}
@keyframes lyr-kf-end{from{visibility:visible}to{visibility:hidden}}
@keyframes lyr-kf-fade{to{opacity:0;visibility:hidden}}
@media (max-aspect-ratio:1/1){
.lyr-kf{--cols:3;--rows:4;--gw:min(78vw,56vh,560px)}
.lyr-kf-at{--c:var(--c3);--r:var(--r3)}
}
@media (prefers-reduced-motion:reduce){
.lyr-kf,.lyr-kf *,.lyr-kf *::before,.lyr-kf *::after{animation:none!important}
.lyr-kf{animation:lyr-kf-fade .4s ease .5s both!important}
.lyr-kf-strip{transform:translateY(var(--end))}
.lyr-kf-kept{outline-color:var(--kf-accent);outline-offset:3px}
}
`
// Runs as soon as the curtain is in the page, before the rest of the site
// has started. It remembers that this visitor has seen the opening, and it
// holds the marked frame until the pictures on the first screen have
// arrived, so the frame opens onto photographs and not onto empty boxes.
// Without it the CSS above still plays the whole sequence on its own clock.
// window.__lyrKf keeps the decision for this page: once played, a later copy
// of the component (after a page change) does not play it again.
const WAIT_AT_MOST = 3600
const script = (once: boolean, openAt: number) =>
`(function(){var d=document.documentElement,w=window,s=w.__lyrKf||(w.__lyrKf={});if(s.run)return;var me=s.run={};try{` +
(once
? `if(!s.state){s.state=sessionStorage.getItem("${KEY}")?"seen":"first";sessionStorage.setItem("${KEY}","1")}if(s.state!=="first"){d.setAttribute("data-lyr-kf-seen","");s.run=null;return}`
: `d.removeAttribute("data-lyr-kf-seen");`) +
`if(w.matchMedia&&w.matchMedia("(prefers-reduced-motion:reduce)").matches){s.run=null;return}` +
`d.removeAttribute("data-lyr-kf-go");d.setAttribute("data-lyr-kf-hold","");var t0=Date.now(),ok=0;` +
`function seen(e){var r=e.getBoundingClientRect();return r.width>1&&r.height>1&&r.bottom>0&&r.top<w.innerHeight&&r.right>0&&r.left<w.innerWidth}` +
`function ready(){if(document.readyState!=="complete")return false;var i,q=document.querySelectorAll("[data-lyr-kf-wait]");for(i=0;i<q.length;i++){if(q[i].getAttribute("data-lyr-kf-wait")!=="done"&&seen(q[i]))return false}var m=document.images;for(i=0;i<m.length;i++){if(!m[i].complete&&seen(m[i]))return false}return true}` +
`function go(){if(s.run!==me)return;d.removeAttribute("data-lyr-kf-hold");d.setAttribute("data-lyr-kf-go","")}` +
`(function tick(){if(s.run!==me)return;var t=Date.now()-t0;if(t>=${WAIT_AT_MOST})return go();if(t>=${openAt}&&ready()){if(++ok>=2)return go()}else{ok=0}setTimeout(tick,80)})()` +
`}catch(e){s.run=null;try{d.removeAttribute("data-lyr-kf-hold")}catch(x){}}})()`
// Clears this run's marks on the page. Called when the curtain is gone, or
// when the component leaves before it finished, so the next one starts clean.
function release(played: boolean) {
const s = (window as any).__lyrKf
if (s) {
s.run = null
if (played && s.state === "first") s.state = "played"
}
document.documentElement.removeAttribute("data-lyr-kf-hold")
document.documentElement.removeAttribute("data-lyr-kf-go")
}
const two = (n: number) => String(n).padStart(2, "0")
/**
* @framerIntrinsicWidth 1
* @framerIntrinsicHeight 1
*
* @framerSupportedLayoutWidth fixed
* @framerSupportedLayoutHeight fixed
*/
export default function KeptFrame(props: KeptFrameProps) {
const {
text = "Selected work",
kept = 6,
oncePerSession = true,
fontFamily = "Inter",
ink = "#ECE9E2",
muted = "#8E8B84",
line = "rgba(236, 233, 226, 0.14)",
accent = "#F0C445",
ground = "#0A0A0A",
} = props
const isStatic = useIsStaticRenderer()
const curtain = useRef<HTMLDivElement | null>(null)
const finished = useRef(false)
const [done, setDone] = useState(false)
const k = Math.min(FRAMES, Math.max(1, Math.round(kept) || 1))
const code = script(oncePerSession, Math.round((T0 + (k - 1) * STEP + 0.3) * 1000))
const finish = () => {
finished.current = true
setDone(true)
}
useEffect(() => {
if (isStatic || done) return
// The script only runs by itself when the page arrives as HTML. If
// this component was drawn by the browser instead, run it now.
const s = (window as any).__lyrKf
if (!s || !s.run) {
const tag = document.createElement("script")
tag.text = code
document.head.appendChild(tag)
tag.remove()
}
const el = curtain.current
// Already seen, or the sequence finished before this script started.
if (document.documentElement.hasAttribute("data-lyr-kf-seen") || !el || getComputedStyle(el).visibility === "hidden") {
finish()
return
}
// Safety net: take the curtain out even if the animation event
// never arrives.
const timer = window.setTimeout(finish, WAIT_AT_MOST + 2400)
return () => {
window.clearTimeout(timer)
release(finished.current)
}
}, [code, isStatic, done])
const anchor = { ...props.style, position: "relative", width: 1, height: 1, pointerEvents: "none" } as CSSProperties
if (isStatic || done) return <div style={anchor} aria-hidden="true" />
const font = `"${fontFamily}", "Inter", system-ui, sans-serif`
const vars = {
"--kf-ink": ink,
"--kf-muted": muted,
"--kf-line": line,
"--kf-accent": accent,
"--kf-ground": ground,
"--k": k,
"--t0": `${T0}s`,
"--step": `${STEP}s`,
} as CSSProperties
// Where a frame sits on the sheet: four across on a wide window, three
// across on a tall one.
const place = (i: number) => ({ "--c4": i % 4, "--r4": Math.floor(i / 4), "--c3": i % 3, "--r3": Math.floor(i / 3), "--i": i }) as CSSProperties
const frames = Array.from({ length: FRAMES }, (_, i) => i)
const count = k > 1 ? { animation: `lyr-kf-count ${((k - 1) * STEP).toFixed(3)}s steps(${k - 1},end) ${T0}s both` } : undefined
return (
<div style={anchor} aria-hidden="true">
<style dangerouslySetInnerHTML={{ __html: SHEET }} />
<div
ref={curtain}
className="lyr-kf"
style={{ ...vars, fontFamily: font }}
onAnimationEnd={(e) => {
if (e.animationName === "lyr-kf-end" || e.animationName === "lyr-kf-fade") finish()
}}
>
<div className="lyr-kf-at lyr-kf-kept" style={place(k - 1)} />
<div className="lyr-kf-sheet">
<div className="lyr-kf-at lyr-kf-num" style={place(k - 1)}>
<span className="lyr-kf-n">{two(k)}</span>
</div>
{frames
.filter((i) => i !== k - 1)
.map((i) => (
<div key={i} className="lyr-kf-at lyr-kf-cell" data-lit={i < k - 1 ? "1" : "0"} style={place(i)}>
<span className="lyr-kf-n">{two(i + 1)}</span>
</div>
))}
<div className="lyr-kf-cap">
<span className="lyr-kf-left">
<span className="lyr-kf-hot">Frame</span>
<span className="lyr-kf-od">
<span className="lyr-kf-strip" style={count}>
{frames.slice(0, k).map((i) => (
<span key={i}>{two(i + 1)}</span>
))}
</span>
</span>
<span>kept of {FRAMES}</span>
</span>
<span className="lyr-kf-name">{text}</span>
</div>
</div>
</div>
<script dangerouslySetInnerHTML={{ __html: code }} />
</div>
)
}
addPropertyControls(KeptFrame, {
text: { type: ControlType.String, title: "Text", defaultValue: "Selected work" },
kept: {
type: ControlType.Number,
title: "Kept frame",
defaultValue: 6,
min: 1,
max: 12,
step: 1,
displayStepper: true,
description: "Which of the twelve frames is marked and opens into the page.",
},
oncePerSession: {
type: ControlType.Boolean,
title: "Once per session",
defaultValue: true,
description: "On: a visitor sees it on the first page they open, not again until they close the tab. Off: it plays on every full page load.",
},
fontFamily: {
type: ControlType.String,
title: "Font family",
defaultValue: "Inter",
description: "Use the family name of your text styles.",
},
ink: { type: ControlType.Color, title: "Text colour", defaultValue: "#ECE9E2" },
muted: { type: ControlType.Color, title: "Muted colour", defaultValue: "#8E8B84" },
line: { type: ControlType.Color, title: "Line colour", defaultValue: "rgba(236, 233, 226, 0.14)" },
accent: { type: ControlType.Color, title: "Accent", defaultValue: "#F0C445" },
ground: { type: ControlType.Color, title: "Background", defaultValue: "#0A0A0A" },
})
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.