Layr Components

Components / Preloader

Ten Minutes

A hairline clock hand sweeps ten minutes, the page shows through the wedge it leaves, then the wedge opens all the way round.

Props
10
26 vmin
#FFFFFF
#0E0E0D

Press Replay to sweep the hand again.

In Framer, open Assets, add a new code file, paste this over everything in it and save. Ten Minutes shows up in the Insert menu, and its controls appear in the right panel.
// Ten Minutes · Layr Components
//
// A hairline clock hand sweeps ten minutes from the top of the hour. The page
// shows through the wedge it leaves, then the wedge opens all the way round.
// Under a second, once per visit, and gone from the page afterwards.
// Paste into a new code file in Framer. Place one in the layout template (or on each page); it covers the window by itself, fixed above everything.

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

const useBeforePaint = typeof window !== "undefined" ? useLayoutEffect : useEffect

interface TenMinutesProps {
    minutes: number
    hand: number
    groundColour: string
    lineColour: string
    oncePerVisit: boolean
    style?: CSSProperties
}

const KEY = "lyr-ten-minutes"

// A registered angle drives a conic mask: the cover is cut away behind the hand as it sweeps.
const SHEET = `
@property --lyr-tmn-a{syntax:"<angle>";inherits:true;initial-value:0deg}
.lyr-tmn{position:fixed;inset:0;z-index:2147483000;pointer-events:none;overflow:hidden;--lyr-tmn-a:0deg;animation:lyr-tmn-sweep .9s linear both,lyr-tmn-end .01s linear .92s forwards}
.lyr-tmn-cur{position:absolute;inset:0;background:var(--lyr-tmn-g);-webkit-mask-image:conic-gradient(from 0deg at 50% 50%,transparent 0 var(--lyr-tmn-a),#000 var(--lyr-tmn-a) 360deg);mask-image:conic-gradient(from 0deg at 50% 50%,transparent 0 var(--lyr-tmn-a),#000 var(--lyr-tmn-a) 360deg)}
.lyr-tmn-hand{position:absolute;left:calc(50% - .75px);bottom:50%;width:1.5px;height:var(--lyr-tmn-len);background:var(--lyr-tmn-line);transform-origin:50% 100%;animation:lyr-tmn-hands .9s linear both}
.lyr-tmn-hand+.lyr-tmn-hand{transform:rotate(var(--lyr-tmn-a))}
@keyframes lyr-tmn-sweep{0%,8%{--lyr-tmn-a:0deg;animation-timing-function:cubic-bezier(.2,.7,.2,1)}46%,55%{--lyr-tmn-a:var(--lyr-tmn-sweep);animation-timing-function:cubic-bezier(.65,0,.25,1)}100%{--lyr-tmn-a:360deg}}
@keyframes lyr-tmn-hands{0%{opacity:0}8%,60%{opacity:1}92%,100%{opacity:0}}
@keyframes lyr-tmn-end{to{visibility:hidden}}
html[data-lyr-tmn-seen] .lyr-tmn{display:none}
@media (prefers-reduced-motion:reduce){.lyr-tmn{animation:lyr-tmn-fade .3s linear forwards}.lyr-tmn-hand{animation:none;opacity:0}@keyframes lyr-tmn-fade{to{opacity:0;visibility:hidden}}}
`

// Runs as the page is parsed: on a later page of the same visit the cover is switched off before it can paint.
const SEEN = `try{var s=sessionStorage.getItem("${KEY}");sessionStorage.setItem("${KEY}","1");if(s){document.documentElement.setAttribute("data-lyr-tmn-seen","");window.__lyrTmnPre="seen"}else{window.__lyrTmnPre="first"}}catch(e){window.__lyrTmnPre="first"}`

/**
 * @framerIntrinsicWidth 1
 * @framerIntrinsicHeight 1
 *
 * @framerSupportedLayoutWidth fixed
 * @framerSupportedLayoutHeight fixed
 */
export default function TenMinutes(props: TenMinutesProps) {
    const { minutes = 10, hand = 26, groundColour = "#0D0C0B", lineColour = "#EFECE6", oncePerVisit = true, style } = props
    const isStatic = useIsStaticRenderer()
    const ref = useRef<HTMLDivElement>(null)
    const [done, setDone] = useState(false)

    // When the page was drawn by the browser instead of the server, the script above never ran:
    // apply the once-per-visit rule here, before paint.
    useBeforePaint(() => {
        const w = window as any
        if (w.__lyrTmnDone) {
            setDone(true)
            return
        }
        if (!oncePerVisit || w.__lyrTmnPre) return
        try {
            if (sessionStorage.getItem(KEY)) {
                w.__lyrTmnPre = "seen"
                setDone(true)
                return
            }
            sessionStorage.setItem(KEY, "1")
        } catch (e) {}
        w.__lyrTmnPre = "first"
    }, [])

    useEffect(() => {
        const el = ref.current
        if (!el) return
        const finish = () => {
            ;(window as any).__lyrTmnDone = true
            setDone(true)
        }
        const cs = getComputedStyle(el)
        if (cs.display === "none" || cs.visibility === "hidden") {
            finish()
            return
        }
        const end = (e: AnimationEvent) => {
            if (e.target === el && (e.animationName === "lyr-tmn-end" || e.animationName === "lyr-tmn-fade")) finish()
        }
        el.addEventListener("animationend", end)
        const safety = window.setTimeout(finish, 1700)
        return () => {
            el.removeEventListener("animationend", end)
            window.clearTimeout(safety)
        }
    }, [])

    if (isStatic) return <div style={{ width: 1, height: 1 }} />
    if (done) return null
    const sweep = Math.min(55, Math.max(1, Number(minutes) || 10))
    const vars = { "--lyr-tmn-g": groundColour, "--lyr-tmn-line": lineColour, "--lyr-tmn-sweep": `${sweep * 6}deg`, "--lyr-tmn-len": `${hand}vmin` } as CSSProperties
    return (
        <div ref={ref} className="lyr-tmn" aria-hidden="true" style={{ ...style, ...vars }}>
            <style dangerouslySetInnerHTML={{ __html: SHEET }} />
            {oncePerVisit ? <script dangerouslySetInnerHTML={{ __html: SEEN }} /> : null}
            <div className="lyr-tmn-cur" />
            <div className="lyr-tmn-hand" />
            <div className="lyr-tmn-hand" />
        </div>
    )
}

addPropertyControls(TenMinutes, {
    minutes: { type: ControlType.Number, title: "Minutes", defaultValue: 10, min: 1, max: 55, step: 1, displayStepper: true, description: "How far the hand sweeps before the page opens. Ten minutes is a sixth of the face." },
    hand: { type: ControlType.Number, title: "Hand length", defaultValue: 26, min: 8, max: 50, step: 1, unit: "vmin" },
    groundColour: { type: ControlType.Color, title: "Ground colour", defaultValue: "#0D0C0B" },
    lineColour: { type: ControlType.Color, title: "Line colour", defaultValue: "#EFECE6" },
    oncePerVisit: { type: ControlType.Boolean, title: "Once per visit", defaultValue: true, description: "On: plays on the first page of a visit only. Off: plays on every full page load." },
})

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.