Components / Hero
Quarter Turn
A 16:9 hero picture turns a quarter as you scroll and lands as a 9:16 card further down the page, the picture staying level inside.
Scroll insideFull screen
Props
#0E0E0D
#6E6E6B
#F5F4F1
Set in code: Picture. The preview fills these in with demo content.
Scroll inside the preview.
In Framer, open Assets, add a new code file, paste this over everything in it and save. Quarter Turn shows up in the Insert menu, and its controls appear in the right panel.
// Quarter Turn · Layr Components
//
// A landscape picture that turns upright as the page scrolls and lands
// further down the page as a portrait card, the picture inside staying level
// the whole way: a long video frame turning into a short.
// Paste into a new code file in Framer and use it twice, with the same Pair name and the same picture: Role Turning frame where the picture starts (it fills its box at 16:9) and Role Landing place where it ends (a 9:16 card, for example in a row further down). Without a partner, or with reduced motion, each one simply shows its picture; without a picture it shows a plain tone.
import { addPropertyControls, ControlType, useIsStaticRenderer } from "framer"
import { useEffect, useRef, useState, type CSSProperties } from "react"
type Picture = { src?: string; srcSet?: string; alt?: string }
interface QuarterTurnProps {
role: string
pair: string
image?: Picture
altText: string
focus: number
title: string
note: string
showCaption: boolean
place: string
hold: string
turn: string
corner: number
topGap: number
edge: number
imageWidth: string
loadFirst: boolean
fontFamily: string
textColour: string
mutedColour: string
groundColour: string
style?: CSSProperties
}
// The frames and landing places on the page, by pair name. Both roles run from this one file.
type Slot = { measure: () => DOMRect | null; setLanded: (landed: boolean) => void }
type Store = { slots: Record<string, Slot[]>; frames: Record<string, Array<() => void>> }
function store(): Store | null {
if (typeof window === "undefined") return null
const w = window as any
if (!w.__lyrQt) w.__lyrQt = { slots: {}, frames: {} }
return w.__lyrQt as Store
}
const SHEET = `
.lyr-qt{position:relative;width:100%;height:100%;container-type:size;z-index:3}
.lyr-qt-box{position:absolute;top:0;width:100%;aspect-ratio:16/9}
@supports (width:1cqw){.lyr-qt-box{width:min(100cqw,max(30cqw,calc((100cqh - var(--lyr-qt-cap)) * 16 / 9)))}}
.lyr-qt-frame{position:absolute;inset:0;overflow:hidden;transform-origin:50% 50%;border-radius:var(--lyr-qt-r);background:var(--lyr-qt-ground)}
.lyr-qt-img{position:absolute;left:0;top:0;width:100%;height:100%;object-fit:cover;display:block;max-width:none}
.lyr-qt-cap{position:absolute;left:0;right:0;top:100%;margin-top:14px;display:flex;justify-content:space-between;gap:16px;font-size:15px;line-height:1.3;letter-spacing:-0.01em;font-weight:400;color:var(--lyr-qt-text)}
.lyr-qt-cap span:last-child{color:var(--lyr-qt-muted);text-align:right;white-space:nowrap}
.lyr-qt-slot{position:relative;width:100%;aspect-ratio:9/16;overflow:hidden;border-radius:var(--lyr-qt-r);background:var(--lyr-qt-ground)}
.lyr-qt-slot .lyr-qt-img{transition:opacity .16s linear}
@media (max-width:809.98px){.lyr-qt-cap{font-size:14px;margin-top:10px}}
`
// Shown when the picture is not set yet.
const EMPTY = "linear-gradient(160deg, #5d5a54, #26241f)"
const clamp = (v: number, a = 0, b = 1) => Math.min(b, Math.max(a, v))
const mix = (a: number, b: number, t: number) => a + (b - a) * t
const ease = (t: number) => t * t * t * (t * (t * 6 - 15) + 10)
const FRAME = 16 / 9
function pictureProps(image: Picture | undefined, altText: string, imageWidth: string, eager: boolean) {
// loading and decoding first, then srcSet and sizes, then src: React sets attributes in this order.
return {
loading: (eager ? "eager" : "lazy") as "eager" | "lazy",
decoding: "async" as const,
srcSet: image?.srcSet,
sizes: image?.srcSet ? imageWidth || "100vw" : undefined,
src: image?.src,
alt: altText || image?.alt || "",
}
}
function vars(p: QuarterTurnProps): CSSProperties {
return {
"--lyr-qt-r": `${p.corner}px`,
"--lyr-qt-ground": p.image?.src ? p.groundColour : EMPTY,
"--lyr-qt-text": p.textColour,
"--lyr-qt-muted": p.mutedColour,
"--lyr-qt-cap": p.showCaption && (p.title || p.note) ? "44px" : "0px",
} as CSSProperties
}
/**
* @framerIntrinsicWidth 640
* @framerIntrinsicHeight 400
*
* @framerSupportedLayoutWidth any
* @framerSupportedLayoutHeight any
*/
export default function QuarterTurn(props: QuarterTurnProps) {
const {
role = "Turning frame",
pair = "hero",
image,
altText = "",
focus = 50,
title = "The full session",
note = "Video, 24 min",
showCaption = true,
place = "Right",
hold = "Top",
turn = "Clockwise",
corner = 8,
topGap = 96,
edge = 16,
imageWidth = "60vw",
loadFirst = false,
fontFamily = "Inter",
textColour = "#EFECE6",
mutedColour = "#8F8A81",
groundColour = "#171614",
style,
} = props
const all: QuarterTurnProps = { role, pair, image, altText, focus, title, note, showCaption, place, hold, turn, corner, topGap, edge, imageWidth, loadFirst, fontFamily, textColour, mutedColour, groundColour, style }
return String(role).toLowerCase().includes("land") ? <LandingPlace {...all} /> : <TurningFrame {...all} />
}
function LandingPlace(props: QuarterTurnProps) {
const { pair, image, altText, focus, imageWidth } = props
const isStatic = useIsStaticRenderer()
const ref = useRef<HTMLDivElement>(null)
const [landed, setLanded] = useState(true)
useEffect(() => {
const s = store()
if (!s || isStatic) return
const me: Slot = { measure: () => (ref.current ? ref.current.getBoundingClientRect() : null), setLanded }
const list = (s.slots[pair] = s.slots[pair] || [])
list.push(me)
const wake = () => (s.frames[pair] || []).forEach((k) => k())
wake()
return () => {
const i = list.indexOf(me)
if (i >= 0) list.splice(i, 1)
wake()
}
}, [pair, isStatic])
return (
<div ref={ref} className="lyr-qt-slot" style={{ ...vars(props), ...props.style }}>
<style dangerouslySetInnerHTML={{ __html: SHEET }} />
{image?.src ? <img className="lyr-qt-img" {...pictureProps(image, altText, imageWidth, false)} style={{ objectPosition: `${focus}% 50%`, opacity: landed ? 1 : 0 }} /> : null}
</div>
)
}
function TurningFrame(props: QuarterTurnProps) {
const { pair, image, altText, focus, title, note, showCaption, place, hold, turn, corner, topGap, edge, imageWidth, loadFirst, fontFamily } = props
const isStatic = useIsStaticRenderer()
const boxRef = useRef<HTMLDivElement>(null)
const frameRef = useRef<HTMLDivElement>(null)
const imgRef = useRef<HTMLImageElement>(null)
const capRef = useRef<HTMLDivElement>(null)
const live = useRef<{ p: number; raf: number; last: number; on: boolean; landed: boolean | null; vw: number; vh: number }>({ p: 0, raf: 0, last: 0, on: false, landed: null, vw: 0, vh: 0 })
const side = String(place).toLowerCase()
const boxSide: CSSProperties = side.includes("left") ? { left: 0 } : side.includes("cent") ? { left: "50%", transform: "translateX(-50%)" } : { right: 0 }
const boxHold: CSSProperties = String(hold).toLowerCase().includes("bottom") ? { top: "auto", bottom: "var(--lyr-qt-cap)" } : {}
const dir = String(turn).toLowerCase().includes("anti") ? -1 : 1
useEffect(() => {
const s = store()
if (!s || isStatic) return
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)")
const coarse = window.matchMedia("(pointer: coarse)")
const st = live.current
const rest = () => {
const frame = frameRef.current,
img = imgRef.current,
cap = capRef.current
if (frame) {
frame.style.transform = ""
frame.style.borderRadius = ""
frame.style.visibility = ""
}
if (img) {
img.style.width = ""
img.style.height = ""
img.style.left = ""
img.style.top = ""
img.style.transform = ""
img.style.objectFit = ""
}
if (cap) cap.style.opacity = ""
if (st.on || st.landed !== null) {
st.on = false
st.landed = null
;(s.slots[pair] || []).forEach((x) => x.setLanded(true))
}
}
const paint = (now: number) => {
st.raf = 0
const box = boxRef.current,
frame = frameRef.current,
img = imgRef.current
if (!box || !frame || !img || reduce.matches || !img.naturalWidth) {
rest()
return
}
const b = box.getBoundingClientRect()
// Framer keeps a hidden copy of a layer for other breakpoints: use the landing place that is on show
let t: DOMRect | null = null
for (const x of s.slots[pair] || []) {
const r = x.measure()
if (r && r.width > 0 && r.height > 0) {
t = r
break
}
}
if (!t || !b.width) {
rest()
return
}
const slots = s.slots[pair] || []
const sy = window.scrollY,
vw = document.documentElement.clientWidth
// on touch screens the window height changes as the toolbar hides; only follow it when the width changes too
if (st.vw !== vw || !coarse.matches || !st.vh) {
st.vw = vw
st.vh = window.innerHeight
}
const vh = st.vh
const W = b.width,
H = b.height
const boxTop = b.top + sy
const from = Math.max(0, boxTop - Math.min(boxTop, vh * 0.25))
const landAt = t.top + sy - Math.max(topGap, (vh - t.height) / 2)
const span = Math.max(1, landAt - from)
let goal = clamp((sy - from) / span)
if (goal > 0.998) goal = 1
if (!st.on) {
st.on = true
st.p = goal
st.last = now
}
const dt = Math.min(64, Math.max(0, now - st.last))
st.last = now
st.p += (goal - st.p) * (1 - Math.exp(-dt / 110))
if (Math.abs(goal - st.p) < 0.0006) st.p = goal
const p = st.p,
e = ease(p)
const landed = p >= 1
if (landed !== st.landed) {
st.landed = landed
slots.forEach((x) => x.setLanded(landed))
}
frame.style.visibility = landed ? "hidden" : ""
if (capRef.current) capRef.current.style.opacity = String(1 - clamp(p / 0.1))
if (!landed) {
const rad = (90 * e * Math.PI) / 180,
c = Math.abs(Math.cos(rad)),
sn = Math.abs(Math.sin(rad))
let sc = mix(1, t.height / W, e)
const x0 = b.left + W / 2,
y0 = b.top + H / 2
const x1 = t.left + t.width / 2,
y1 = t.top + t.height / 2
let cx = mix(x0, x1, e)
const cy = mix(y0, y1, p)
// keep the turning frame inside the window so it never adds sideways scroll
const room = vw - edge * 2
let bw = (W * c + H * sn) * sc
if (bw > room) {
sc *= room / bw
bw = room
}
const held = clamp(cx, edge + bw / 2, vw - edge - bw / 2)
const endsInside = x1 - t.width / 2 >= edge - 1 && x1 + t.width / 2 <= vw - edge + 1
cx = endsInside ? held : mix(held, cx, e * e * e)
frame.style.transform = `translate3d(${(cx - x0).toFixed(2)}px,${(cy - y0).toFixed(2)}px,0) rotate(${(dir * 90 * e).toFixed(3)}deg) scale(${sc.toFixed(5)})`
frame.style.borderRadius = `${(corner / sc).toFixed(2)}px`
// the picture: level, and covering the frame at every angle
const a = img.naturalWidth / img.naturalHeight
const k0 = Math.max(FRAME / a, 1)
const bwU = FRAME * c + sn,
bhU = FRAME * sn + c
const m = Math.max(bwU / a, bhU) / k0
const wPct = ((a * k0) / FRAME) * 100,
hPct = k0 * 100
const slack = Math.max(0, m - bwU / (a * k0)) / 2
const shift = (0.5 - focus / 100) * 2 * slack * 100
img.style.objectFit = "fill"
img.style.width = `${wPct.toFixed(3)}%`
img.style.height = `${hPct.toFixed(3)}%`
img.style.left = "50%"
img.style.top = "50%"
img.style.transform = `translate(-50%,-50%) rotate(${(-dir * 90 * e).toFixed(3)}deg) translateX(${shift.toFixed(3)}%) scale(${m.toFixed(5)})`
}
if (p !== goal) st.raf = requestAnimationFrame(paint)
}
const kick = () => {
if (!st.raf) st.raf = requestAnimationFrame(paint)
}
const kicks = (s.frames[pair] = s.frames[pair] || [])
kicks.push(kick)
const img = imgRef.current
img?.addEventListener("load", kick)
window.addEventListener("scroll", kick, { passive: true })
window.addEventListener("resize", kick)
reduce.addEventListener?.("change", kick)
const ro = typeof ResizeObserver !== "undefined" && boxRef.current ? new ResizeObserver(kick) : null
if (ro && boxRef.current) ro.observe(boxRef.current)
kick()
return () => {
img?.removeEventListener("load", kick)
window.removeEventListener("scroll", kick)
window.removeEventListener("resize", kick)
reduce.removeEventListener?.("change", kick)
ro?.disconnect()
if (st.raf) cancelAnimationFrame(st.raf)
st.raf = 0
const i = kicks.indexOf(kick)
if (i >= 0) kicks.splice(i, 1)
rest()
}
}, [pair, isStatic, focus, corner, topGap, edge, dir, Boolean(image?.src)])
const caption = showCaption && (title || note)
return (
<div className="lyr-qt" style={{ ...vars(props), fontFamily: `"${fontFamily}", "Inter", system-ui, sans-serif`, ...props.style }}>
<style dangerouslySetInnerHTML={{ __html: SHEET }} />
<div ref={boxRef} className="lyr-qt-box" style={{ ...boxSide, ...boxHold }}>
<div ref={frameRef} className="lyr-qt-frame">
{image?.src ? <img ref={imgRef} className="lyr-qt-img" {...pictureProps(image, altText, imageWidth, loadFirst)} style={{ objectPosition: `${focus}% 50%` }} /> : null}
</div>
{caption ? (
<div ref={capRef} className="lyr-qt-cap">
<span>{title}</span>
<span>{note}</span>
</div>
) : null}
</div>
</div>
)
}
const isLanding = (p: Partial<QuarterTurnProps>) => String(p.role).toLowerCase().includes("land")
addPropertyControls(QuarterTurn, {
role: { type: ControlType.Enum, title: "Role", defaultValue: "Turning frame", options: ["Turning frame", "Landing place"], optionTitles: ["Turning frame", "Landing place"], description: "Turning frame is where the picture starts (16:9). Landing place is where it ends (9:16)." },
pair: { type: ControlType.String, title: "Pair", defaultValue: "hero", description: "The same word on the frame and on its landing place." },
image: { type: ControlType.ResponsiveImage, title: "Picture" },
altText: { type: ControlType.String, title: "Alt text", defaultValue: "", description: "Describes the picture for screen readers. Leave empty to use the picture's own alt text." },
focus: { type: ControlType.Number, title: "Focus", defaultValue: 50, min: 0, max: 100, step: 1, unit: "%", description: "Where the subject is, from left (0) to right (100). Used when the picture is cropped upright." },
showCaption: { type: ControlType.Boolean, title: "Caption", defaultValue: true, hidden: (p: Partial<QuarterTurnProps>) => isLanding(p) },
title: { type: ControlType.String, title: "Caption left", defaultValue: "The full session", hidden: (p: Partial<QuarterTurnProps>) => isLanding(p) || !p.showCaption },
note: { type: ControlType.String, title: "Caption right", defaultValue: "Video, 24 min", hidden: (p: Partial<QuarterTurnProps>) => isLanding(p) || !p.showCaption },
place: { type: ControlType.Enum, title: "Sits", defaultValue: "Right", options: ["Left", "Centre", "Right"], optionTitles: ["Left", "Centre", "Right"], hidden: (p: Partial<QuarterTurnProps>) => isLanding(p), description: "Which side of its box the frame keeps to when the box is wider than the frame." },
hold: { type: ControlType.Enum, title: "Holds to", defaultValue: "Top", options: ["Top", "Bottom"], optionTitles: ["Top", "Bottom"], hidden: (p: Partial<QuarterTurnProps>) => isLanding(p), description: "Whether the frame keeps to the top or the bottom of its box when the box is taller than the frame." },
turn: { type: ControlType.Enum, title: "Turns", defaultValue: "Clockwise", options: ["Clockwise", "Anticlockwise"], optionTitles: ["Clockwise", "Anticlockwise"], hidden: (p: Partial<QuarterTurnProps>) => isLanding(p) },
corner: { type: ControlType.Number, title: "Corner", defaultValue: 8, min: 0, max: 40, step: 1, unit: "px" },
topGap: { type: ControlType.Number, title: "Top gap", defaultValue: 96, min: 0, max: 240, step: 4, unit: "px", hidden: (p: Partial<QuarterTurnProps>) => isLanding(p), description: "Room kept for the navigation when the frame lands in a short window." },
edge: { type: ControlType.Number, title: "Edge gap", defaultValue: 16, min: 0, max: 80, step: 2, unit: "px", hidden: (p: Partial<QuarterTurnProps>) => isLanding(p), description: "The frame stays this far inside the window while it turns." },
imageWidth: { type: ControlType.String, title: "Image width", defaultValue: "60vw", description: "How wide the picture is drawn at its largest, as one length (60vw, 100vw, 640px). Use the same value on both of a pair." },
loadFirst: { type: ControlType.Boolean, title: "Load first", defaultValue: false, hidden: (p: Partial<QuarterTurnProps>) => isLanding(p), description: "On for the one picture at the top of the page." },
fontFamily: { type: ControlType.String, title: "Font family", defaultValue: "Inter", hidden: (p: Partial<QuarterTurnProps>) => isLanding(p) || !p.showCaption },
textColour: { type: ControlType.Color, title: "Text colour", defaultValue: "#EFECE6", hidden: (p: Partial<QuarterTurnProps>) => isLanding(p) || !p.showCaption },
mutedColour: { type: ControlType.Color, title: "Muted colour", defaultValue: "#8F8A81", hidden: (p: Partial<QuarterTurnProps>) => isLanding(p) || !p.showCaption },
groundColour: { type: ControlType.Color, title: "Ground colour", defaultValue: "#171614", description: "Shows behind the picture while it loads." },
})
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.