Layr Components

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