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