Layr Components

Components / Preloader

Hung Level

The page arrives tilted like a picture just hung, swings once and settles while a spirit-level bubble finds the middle. Once per visit.

Props
2.2 °
0.95
112 px
#0E0E0D
#FF4A1C
#FFFFFF

Press Replay to hang it again.

In Framer, open Assets, add a new code file, paste this over everything in it and save. Hung Level shows up in the Insert menu, and its controls appear in the right panel.
// Hung Level · Layr Components
//
// The page arrives like a picture just put on its nail: a touch crooked. It
// swings once and settles while the bubble in a spirit level finds the middle.
// 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), fixed, as wide and as tall as the window.

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

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

interface HungLevelProps {
    tilt: number
    seconds: number
    size: number
    line: string
    bubble: string
    glass: string
    nail: boolean
    oncePerVisit: boolean
    style?: CSSProperties
}

// The swing of a picture on one nail: out, a third of the way back past level,
// a tenth the other way, then still. The bubble in the level runs the other way.
const SWING = [1, -0.3, 0.1, 0]
const AT = [12, 50, 76, 100]
const EASE = "animation-timing-function:cubic-bezier(.45,0,.55,1)"

// Everything is CSS, so the page is already hanging crooked in its first paint
// and straightens itself even if no script runs. There is no cover: the page
// itself turns about the nail at the top centre of the window.
function css(tilt: number, total: number) {
    const swing = (total * 0.82).toFixed(2)
    const hang = AT.map((at, k) => `${at}%{transform:rotate(${(SWING[k] * tilt).toFixed(3)}deg);opacity:1;${EASE}}`).join("")
    const run = AT.map((at, k) => `${at}%{transform:translateX(calc(var(--run) * ${-SWING[k]}));${EASE}}`).join("")
    return `
html:not([data-lyr-hl-seen]),html:not([data-lyr-hl-seen]) body{overflow-x:clip}
html:not([data-lyr-hl-seen]) #main{transform-origin:50% 0;animation:lyr-hl-hang ${swing}s}
.lyr-hl{position:absolute;left:0;top:0;width:100%;height:100vh;height:100svh;pointer-events:none;animation:lyr-hl-end .01s linear ${total.toFixed(2)}s forwards}
.lyr-hl-vial,.lyr-hl-nail{animation:lyr-hl-out ${(total * 0.15).toFixed(2)}s ease-out ${(total * 0.85).toFixed(2)}s forwards}
.lyr-hl-vial{position:absolute;left:50%;top:50%;box-sizing:border-box;width:var(--w);height:calc(var(--w) * .21);margin:calc(var(--w) * -.105) 0 0 calc(var(--w) * -.5);border:1.5px solid var(--line);border-radius:999px;background:var(--glass);overflow:hidden}
.lyr-hl-tick{position:absolute;top:0;bottom:0;left:50%;width:1.5px;margin-left:-.75px;background:var(--line)}
.lyr-hl-tick.a{transform:translateX(calc(var(--w) * -.14))}
.lyr-hl-tick.b{transform:translateX(calc(var(--w) * .14))}
.lyr-hl-bubble{position:absolute;left:50%;top:50%;width:calc(var(--w) * .22);height:calc(var(--w) * .115);margin:calc(var(--w) * -.0575) 0 0 calc(var(--w) * -.11);border-radius:999px;background:var(--bubble);--run:calc(var(--w) * .3);animation:lyr-hl-run ${swing}s .03s both}
.lyr-hl-nail{position:absolute;left:50%;top:14px;width:8px;height:8px;margin-left:-4px;border-radius:50%;background:var(--line)}
@keyframes lyr-hl-hang{0%{transform:rotate(${tilt.toFixed(3)}deg);opacity:.4}${hang}}
@keyframes lyr-hl-run{0%{transform:translateX(calc(var(--run) * -1))}${run}}
@keyframes lyr-hl-out{to{opacity:0}}
@keyframes lyr-hl-end{to{visibility:hidden}}
html[data-lyr-hl-seen] .lyr-hl{display:none!important}
@media (prefers-reduced-motion:reduce){.lyr-hl{display:none!important}html:not([data-lyr-hl-seen]) #main{animation:none}}
`
}

// Runs as the page is parsed: on a second page in the same visit, or on a link
// that opens part-way down a page, the swing is switched off before it can paint.
const SEEN = `try{var s=sessionStorage.getItem("lyr-hung-level");sessionStorage.setItem("lyr-hung-level","1");if(s||location.hash){document.documentElement.setAttribute("data-lyr-hl-seen","");window.__lyrHlPre="seen"}else{window.__lyrHlPre="first"}}catch(e){window.__lyrHlPre="first"}`

/**
 * @framerIntrinsicWidth 200
 * @framerIntrinsicHeight 120
 *
 * @framerSupportedLayoutWidth fixed
 * @framerSupportedLayoutHeight fixed
 */
export default function HungLevel(props: HungLevelProps) {
    const { tilt = 2.2, seconds = 0.95, size = 112, line = "#14130F", bubble = "#E2502A", glass = "#FBFAF7", nail = true, oncePerVisit = true, style } = props
    const isStatic = useIsStaticRenderer()
    const rootRef = useRef<HTMLDivElement>(null)
    const [done, setDone] = useState(false)

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

    useEffect(() => {
        const el = rootRef.current
        if (!el) return
        const finish = () => {
            ;(window as any).__lyrHlDone = true
            setDone(true)
        }
        const finished = () => {
            const cs = getComputedStyle(el)
            return cs.display === "none" || cs.visibility === "hidden"
        }
        // The swing may have finished before this script ran.
        if (finished()) {
            finish()
            return
        }
        const onEnd = (e: AnimationEvent) => {
            if (e.animationName === "lyr-hl-end") finish()
        }
        el.addEventListener("animationend", onEnd)
        const safety = window.setTimeout(finish, Math.max(1200, seconds * 1000 + 900))
        return () => {
            el.removeEventListener("animationend", onEnd)
            window.clearTimeout(safety)
        }
    }, [seconds])

    if (isStatic) {
        // On the canvas: a small tile, so the layer can be found and selected without covering the page.
        return (
            <div style={{ ...style, width: "100%", height: "100%", display: "flex", alignItems: "center", justifyContent: "center", background: glass }}>
                <div style={{ position: "relative", width: 112, height: 24, boxSizing: "border-box", border: `1.5px solid ${line}`, borderRadius: 999 }}>
                    <div style={{ position: "absolute", left: "50%", top: "50%", width: 25, height: 13, margin: "-6.5px 0 0 -12.5px", borderRadius: 999, background: bubble }} />
                </div>
            </div>
        )
    }
    if (done) return null

    const angle = Math.min(6, Math.max(0.5, Number(tilt) || 2.2))
    const total = Math.min(1.6, Math.max(0.6, Number(seconds) || 0.95))
    // The level keeps its set width on a phone and grows with the window, up to half as wide again.
    const base = Math.min(240, Math.max(64, Number(size) || 112))
    const vars = { "--w": `clamp(${base}px, 10vw, ${Math.round(base * 1.5)}px)`, "--line": line, "--bubble": bubble, "--glass": glass } as CSSProperties

    return (
        <>
            <style dangerouslySetInnerHTML={{ __html: css(angle, total) }} />
            {oncePerVisit && <script dangerouslySetInnerHTML={{ __html: SEEN }} />}
            <div ref={rootRef} className="lyr-hl" style={vars} aria-hidden="true">
                {nail && <div className="lyr-hl-nail" />}
                <div className="lyr-hl-vial">
                    <div className="lyr-hl-tick a" />
                    <div className="lyr-hl-tick b" />
                    <div className="lyr-hl-bubble" />
                </div>
            </div>
        </>
    )
}

addPropertyControls(HungLevel, {
    tilt: { type: ControlType.Number, title: "Tilt", defaultValue: 2.2, min: 0.5, max: 6, step: 0.1, unit: "°", description: "How crooked the page hangs before it settles." },
    seconds: { type: ControlType.Number, title: "Seconds", defaultValue: 0.95, min: 0.6, max: 1.6, step: 0.05, description: "From first paint to a level page." },
    size: { type: ControlType.Number, title: "Level width", defaultValue: 112, min: 64, max: 240, step: 4, unit: "px", description: "Its width on a phone. On wider windows it grows, up to half as wide again." },
    line: { type: ControlType.Color, title: "Line", defaultValue: "#14130F" },
    bubble: { type: ControlType.Color, title: "Bubble", defaultValue: "#E2502A" },
    glass: { type: ControlType.Color, title: "Glass", defaultValue: "#FBFAF7", description: "Fills the level so it reads over a picture." },
    nail: { type: ControlType.Boolean, title: "Nail", defaultValue: true, description: "The dot at the top centre that the page hangs from." },
    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.