Components / Preloader
Hyphen Curtain
The page opens through a hyphen: a small ink bar in the middle of a paper curtain widens to the whole window in under a second.
Props
#FFFFFF
#0E0E0D
Press Replay to open it again.
In Framer, open Assets, add a new code file, paste this over everything in it and save. Hyphen Curtain shows up in the Insert menu, and its controls appear in the right panel.
// Hyphen Curtain · Layr Components
//
// The page opens through a hyphen. A small ink bar sits in the middle of a paper
// curtain, then widens to the whole window, under a second in all. Drawn with CSS
// alone, so it plays from the first paint and cannot trap a visitor; once per
// visit, and gone from the page when it has finished.
// Paste into a new code file in Framer. Place one on each page (or in the layout
// template), any size: it covers the window by itself. On the canvas it is an empty layer.
import { addPropertyControls, ControlType, useIsStaticRenderer } from "framer"
import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from "react"
const useBeforePaint = typeof window !== "undefined" ? useLayoutEffect : useEffect
const CSS = `
.lyr-hcu{position:fixed;inset:0;z-index:2147483000;pointer-events:none;overflow:hidden;animation:lyr-hcu-gone 0s linear var(--hcu-total) forwards}
.lyr-hcu-hole{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:var(--hcu-w);height:calc(var(--hcu-w) * .3);border-radius:3px;box-shadow:0 0 0 200vmax var(--hcu-paper);outline:1px solid var(--hcu-ink);animation:lyr-hcu-open var(--hcu-open) cubic-bezier(.72,0,.18,1) var(--hcu-hold) forwards}
.lyr-hcu-hole::before{content:"";position:absolute;inset:-1px;border-radius:inherit;background:var(--hcu-ink);animation:lyr-hcu-clear .16s linear calc(var(--hcu-hold) - .1s) forwards}
@keyframes lyr-hcu-open{70%{outline-color:var(--hcu-ink)}to{width:calc(100vw + 8px);height:calc(100vh + 8px);border-radius:0;outline-color:transparent}}
@keyframes lyr-hcu-clear{to{opacity:0}}
@keyframes lyr-hcu-gone{to{visibility:hidden}}
html[data-lyr-hcu="seen"] .lyr-hcu{display:none}
@media (prefers-reduced-motion:reduce){.lyr-hcu{display:none}}
`
const KEY = "lyr-hyphen-curtain"
// Runs as the page is parsed: on a later page of the same visit the curtain is hidden before it can paint.
const ONCE = `try{if(sessionStorage.getItem("${KEY}")){document.documentElement.setAttribute("data-lyr-hcu","seen")}else{sessionStorage.setItem("${KEY}","1");document.documentElement.setAttribute("data-lyr-hcu","first")}}catch(e){}`
interface HyphenCurtainProps {
paper: string
ink: string
hyphen: number
hold: number
open: number
once: boolean
style?: CSSProperties
}
/**
* @framerSupportedLayoutWidth any
* @framerSupportedLayoutHeight any
* @framerDisableUnlink
*/
export default function HyphenCurtain(props: HyphenCurtainProps) {
const { paper = "#F5F4F0", ink = "#121211", hyphen = 12, hold = 0.3, open = 0.55, once = true } = props
const isStatic = useIsStaticRenderer()
const [gone, setGone] = useState(false)
const el = useRef<HTMLDivElement>(null)
const total = Math.min(1.4, Math.max(0.2, hold + open))
// Drawn in the browser without the inline script having run (a preview): do its check here.
useBeforePaint(() => {
if (!once || typeof document === "undefined" || document.documentElement.hasAttribute("data-lyr-hcu")) return
try {
if (sessionStorage.getItem(KEY)) {
document.documentElement.setAttribute("data-lyr-hcu", "seen")
setGone(true)
} else {
sessionStorage.setItem(KEY, "1")
document.documentElement.setAttribute("data-lyr-hcu", "first")
}
} catch (e) {}
}, [once])
useEffect(() => {
const node = el.current
if (!node) return
// Once it has played, later pages of the visit (which Framer draws without a reload) skip it.
const finish = () => {
if (once) document.documentElement.setAttribute("data-lyr-hcu", "seen")
setGone(true)
}
// Already finished (or skipped) before the page became interactive: leave at once.
const cs = getComputedStyle(node)
if (cs.display === "none" || cs.visibility === "hidden") {
setGone(true)
return
}
const t = window.setTimeout(finish, total * 1000 + 250)
const onEnd = (e: AnimationEvent) => {
if (e.animationName === "lyr-hcu-gone") finish()
}
node.addEventListener("animationend", onEnd)
return () => {
window.clearTimeout(t)
node.removeEventListener("animationend", onEnd)
}
}, [total, once])
if (isStatic) return <div style={{ width: "100%", height: "100%" }} />
if (gone) return null
const vars = {
"--hcu-paper": paper,
"--hcu-ink": ink,
"--hcu-hold": hold + "s",
"--hcu-open": open + "s",
"--hcu-total": total + "s",
"--hcu-w": `clamp(56px, ${hyphen}vw, 190px)`,
} as CSSProperties
return (
<>
<style dangerouslySetInnerHTML={{ __html: CSS }} />
{once ? <script dangerouslySetInnerHTML={{ __html: ONCE }} /> : null}
<div ref={el} className="lyr-hcu" style={vars} aria-hidden="true">
<div className="lyr-hcu-hole" />
</div>
</>
)
}
addPropertyControls(HyphenCurtain, {
paper: { type: ControlType.Color, title: "Curtain colour", defaultValue: "#F5F4F0", description: "Use the page colour." },
ink: { type: ControlType.Color, title: "Hyphen colour", defaultValue: "#121211" },
hyphen: { type: ControlType.Number, title: "Hyphen width", defaultValue: 12, min: 4, max: 30, step: 1, unit: "%", description: "Share of the window width." },
hold: { type: ControlType.Number, title: "Hold", defaultValue: 0.3, min: 0.1, max: 0.6, step: 0.05, unit: "s", description: "How long the hyphen sits before it opens." },
open: { type: ControlType.Number, title: "Open", defaultValue: 0.55, min: 0.2, max: 0.8, step: 0.05, unit: "s", description: "How long the opening takes. Hold plus Open should stay under a second." },
once: { 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.