Layr Components

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
12%
0.3 s
0.55 s

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.