Layr Components

Components / Navigation

Cut Transition

Clicking through to another page hard-cuts to a dark "Cut to" slate with the title and a running timecode, then cuts into the new page.

Props
0.42 s
#FFFFFF
#0E0E0D
#FF4A1C

Click a project, then Next project or All work.

In Framer, open Assets, add a new code file, paste this over everything in it and save. Cut Transition shows up in the Insert menu, and its controls appear in the right panel.
// Cut Transition · Layr Components
//
// Clicking through to another page hard-cuts to a dark slate: "Cut to", the
// title of what was clicked and a running timecode. Then it cuts straight into
// the new page. No fades: it should feel like a cut in an edit. Links that open
// a new tab, go to another website, or jump within the page are left alone.
// Paste into a new code file in Framer. Place one on each page whose links
// should cut (anywhere: it draws nothing on the live site). On the canvas it
// shows as a small label. The title comes from a layer named Title inside the
// clicked link.

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

interface CutTransitionProps {
    links: string
    titleFrom: string
    label: string
    hold: number
    background: string
    color: string
    accent: string
    fontFamily: string
    numeralFamily: string
    style?: CSSProperties
}

interface Look {
    label: string
    hold: number
    background: string
    color: string
    accent: string
    font: string
    mono: string
}

const SLATE_ID = "lyr-cut-slate"
const FPS = 24
const MONO = "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace"

const pad = (value: number) => String(Math.floor(value)).padStart(2, "0")
const toTimecode = (seconds: number) => `${pad(seconds / 3600)}:${pad((seconds % 3600) / 60)}:${pad(seconds % 60)}:${pad((seconds % 1) * FPS)}`

function playCut(title: string, look: Look) {
    if (typeof document === "undefined") return
    document.getElementById(SLATE_ID)?.remove()

    const slate = document.createElement("div")
    slate.id = SLATE_ID
    slate.setAttribute("aria-hidden", "true")
    Object.assign(slate.style, {
        position: "fixed",
        inset: "0",
        zIndex: "2147483000",
        background: look.background,
        color: look.color,
        display: "flex",
        flexDirection: "column",
        alignItems: "center",
        justifyContent: "center",
        gap: "18px",
        padding: "24px",
        pointerEvents: "none",
        textAlign: "center",
        fontFamily: look.font,
    })

    const label = document.createElement("div")
    Object.assign(label.style, { display: "flex", alignItems: "center", gap: "10px", fontSize: "14px", lineHeight: "1", letterSpacing: "-0.005em" })
    const dot = document.createElement("span")
    Object.assign(dot.style, { width: "8px", height: "8px", borderRadius: "8px", background: look.accent })
    const cutTo = document.createElement("span")
    cutTo.textContent = look.label
    label.append(dot, cutTo)
    slate.append(label)

    if (title) {
        const heading = document.createElement("div")
        heading.textContent = title
        Object.assign(heading.style, { fontSize: "clamp(40px, 7vw, 96px)", lineHeight: "1", letterSpacing: "-0.035em", maxWidth: "16ch", textWrap: "balance" })
        slate.append(heading)
    }

    const timecode = document.createElement("div")
    Object.assign(timecode.style, { fontFamily: look.mono, fontSize: "12px", lineHeight: "1", letterSpacing: "0.04em", opacity: "0.6", fontVariantNumeric: "tabular-nums" })
    slate.append(timecode)
    document.body.append(slate)

    const hold = look.hold * 1000
    const max = Math.max(1600, hold + 600)
    const start = performance.now()
    const from = window.location.pathname
    let arrivedAt = 0
    // The timecode runs every frame; the cut itself is timed separately so it stays crisp.
    const runTimecode = () => {
        if (!slate.isConnected) return
        timecode.textContent = toTimecode((performance.now() - start) / 1000)
        requestAnimationFrame(runTimecode)
    }
    const waitForCut = () => {
        if (!slate.isConnected) return
        const now = performance.now()
        if (!arrivedAt && window.location.pathname !== from) arrivedAt = now
        if ((arrivedAt && now - arrivedAt >= hold) || now - start >= max) {
            slate.remove()
            return
        }
        window.setTimeout(waitForCut, 30)
    }
    runTimecode()
    waitForCut()
}

/**
 * @framerIntrinsicWidth 200
 * @framerIntrinsicHeight 28
 *
 * @framerSupportedLayoutWidth auto
 * @framerSupportedLayoutHeight auto
 */
export default function CutTransition(props: CutTransitionProps) {
    const {
        links = "a",
        titleFrom = '[data-framer-name="Title"]',
        label = "Cut to",
        hold = 0.42,
        background = "#0A0A0A",
        color = "#F2EFE9",
        accent = "#E9FFA1",
        fontFamily = "Inter",
        numeralFamily = "",
    } = props
    const isStatic = useIsStaticRenderer()

    useEffect(() => {
        if (isStatic) return
        const look: Look = {
            label,
            hold: Math.min(1.5, Math.max(0, Number(hold) || 0)),
            background,
            color,
            accent,
            font: `"${fontFamily}", "Inter", system-ui, sans-serif`,
            mono: numeralFamily ? `"${numeralFamily}", ${MONO}` : MONO,
        }
        const onClick = (event: MouseEvent) => {
            if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
            if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return
            const start = event.target as Element | null
            const link = start?.closest?.("a")
            if (!start || !link) return
            let hit: Element | null = null
            try {
                hit = start.closest(links || "a")
            } catch (e) {
                return
            }
            if (!hit) return
            if (link.target === "_blank" || link.hasAttribute("download")) return
            const href = link.getAttribute("href") ?? ""
            if (!href || href.startsWith("#") || href.startsWith("mailto:") || href.startsWith("tel:")) return
            if (/^(https?:)?\/\//.test(href) && !href.startsWith(window.location.origin)) return
            // A link to the page you are already on does not change page.
            const url = new URL(link.href, window.location.href)
            if (url.origin !== window.location.origin) return
            if (url.pathname === window.location.pathname && url.search === window.location.search) return
            const scope = hit.contains(link) ? hit : link
            let title = link.getAttribute("data-cut-title") ?? ""
            try {
                if (!title && titleFrom) title = scope.querySelector(titleFrom)?.textContent ?? ""
            } catch (e) {}
            playCut(title.trim(), look)
        }
        // Capture, so the slate is up before the site's router reacts to the click.
        document.addEventListener("click", onClick, true)
        return () => document.removeEventListener("click", onClick, true)
    }, [isStatic, links, titleFrom, label, hold, background, color, accent, fontFamily, numeralFamily])

    if (!isStatic) return null
    return (
        <div
            style={{
                ...props.style,
                padding: "7px 10px",
                borderRadius: 4,
                background,
                color,
                border: `1px dashed ${color}`,
                fontFamily: `"${fontFamily}", "Inter", system-ui, sans-serif`,
                fontSize: 12,
                lineHeight: 1,
                whiteSpace: "nowrap",
                opacity: 0.8,
            }}
        >
            Cut transition · plays on link clicks
        </div>
    )
}

addPropertyControls(CutTransition, {
    links: {
        type: ControlType.String,
        title: "Links",
        defaultValue: "a",
        description: 'Which links cut, as a CSS selector. "a" is every link to another page; [data-framer-name="Card"] is only links on layers named Card.',
    },
    titleFrom: {
        type: ControlType.String,
        title: "Title from",
        defaultValue: '[data-framer-name="Title"]',
        description: "Where the slate's title comes from: the first match inside the clicked link. The default is a text layer named Title.",
    },
    label: { type: ControlType.String, title: "Label", defaultValue: "Cut to" },
    hold: {
        type: ControlType.Number,
        title: "Hold",
        defaultValue: 0.42,
        min: 0,
        max: 1.5,
        step: 0.02,
        unit: "s",
        description: "How long the slate stays after the new page has arrived. If the page is slow, it leaves anyway after 1.6 s (or Hold plus 0.6 s, if that is longer).",
    },
    background: { type: ControlType.Color, title: "Background", defaultValue: "#0A0A0A" },
    color: { type: ControlType.Color, title: "Text", defaultValue: "#F2EFE9" },
    accent: { type: ControlType.Color, title: "Dot", defaultValue: "#E9FFA1" },
    fontFamily: { type: ControlType.String, title: "Font family", defaultValue: "Inter", description: "Use the family name of your text styles." },
    numeralFamily: {
        type: ControlType.String,
        title: "Numeral font",
        defaultValue: "",
        placeholder: "System monospace",
        description: "For the timecode. Leave empty for the system monospace.",
    },
})

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.