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