Layr Components

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
50%
8 px
96 px
16 px
#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.