Layr Components

Components / Hero

Ring

Pictures stand in a 3D ring on a hairline floor and turn past the viewer as you scroll the pinned section; each links to its page.

Scroll insideFull screen
Props
60%
69%
1.5
33 °
42%
#A3A3A0
170 vh
#0E0E0D
#F5F4F1

Set in code: Pictures. 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. Ring shows up in the Insert menu, and its controls appear in the right panel.
// Ring · Layr Components
//
// Pictures stand in a ring around the viewer, on a floor of hairlines, and turn
// past one by one as the page scrolls; each one can link to its page. The idea
// follows jesperlandberg.com's WebGL ring; this is plain CSS 3D with flat panels.
// Paste into a new code file in Framer. With Pin on (the default) it holds the ring
// in the window while the page scrolls past: set its width to Fill and its height to
// Fit. With Pin off it fills its frame and turns as the frame crosses the window, or
// follows the scroll of a tall section when it sits in that section's sticky frame.
// A picture that is not set shows a soft tone.

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

type Img = { src: string; srcSet?: string; alt?: string; positionX?: string; positionY?: string }
type Panel = { image?: Img; title?: string; link?: string }

interface RingProps {
    items: Panel[]
    panelHeight: number
    panelWidth: number
    shape: number
    spacing: number
    lift: number
    floor: boolean
    lineColour: string
    motion: "Follows scroll" | "Still"
    pin: boolean
    pinLength: number
    fontFamily: string
    textColour: string
    tintColour: string
    imageWidth: string
    style?: CSSProperties
}

const ITEMS: Panel[] = [
    { title: "Product launch" },
    { title: "Spring campaign" },
    { title: "New website" },
    { title: "Mobile app" },
    { title: "Brand refresh" },
    { title: "Search ads" },
]

const clamp = (v: number, lo = 0, hi = 1) => Math.min(hi, Math.max(lo, v))
const r3 = (v: number) => Math.round(v * 1000) / 1000

// Sizes follow the ring's own box (container units). The depth of view follows the window, as it did in
// the template, where the perspective was worked out against the window rather than the frame.
const sheet = (h: number, wide: number, shape: number, step: number, lift: number) => {
    const across = r3(wide / shape)
    const deep = r3(shape * 1.9)
    return `
.lyr-ring-track{position:relative;width:100%;height:100%}
.lyr-ring-pin{position:relative;width:100%;height:100%;overflow:hidden}
.lyr-ring-pinned{height:calc(100vh + var(--lyr-ring-len) * 1vh);height:calc(100svh + var(--lyr-ring-len) * 1lvh)}
@media (max-width:809px){.lyr-ring-pinned{height:calc(100vh + var(--lyr-ring-len) * .88vh);height:calc(100svh + var(--lyr-ring-len) * .88lvh)}}
.lyr-ring-pinned>.lyr-ring-pin{position:sticky;top:0;height:100vh;height:100svh}
.lyr-ring{position:relative;width:100%;height:100%;container-type:size;-webkit-font-smoothing:antialiased;--lyr-ring-ph:min(${h}cqh,${across}cqw);--lyr-ring-pw:calc(var(--lyr-ring-ph) * ${shape});--lyr-ring-P:calc(var(--lyr-ring-pw) * 1.9);--lyr-ring-x:0}
.lyr-ring-floorbox,.lyr-ring-list{position:absolute;inset:0;perspective:calc(min(${h}vh,${across}vw) * ${deep});perspective:calc(min(${h}svh,${across}svw) * ${deep});perspective-origin:50% ${lift}%}
.lyr-ring-floorbox{pointer-events:none}
.lyr-ring-floor{position:absolute;left:50%;top:${lift}%;width:calc(var(--lyr-ring-P) * 6);height:calc(var(--lyr-ring-P) * 6);margin:calc(var(--lyr-ring-P) * -3) 0 0 calc(var(--lyr-ring-P) * -3);transform:translateY(calc(var(--lyr-ring-ph) * .56)) translateZ(var(--lyr-ring-P)) rotateX(90deg);opacity:.5;background-image:linear-gradient(var(--lyr-ring-line) 1px,transparent 1px),linear-gradient(90deg,var(--lyr-ring-line) 1px,transparent 1px);background-size:calc(var(--lyr-ring-pw) * .3) calc(var(--lyr-ring-pw) * .3);background-position:calc(50% + var(--lyr-ring-x) * var(--lyr-ring-pw) * .3) 50%;-webkit-mask-image:radial-gradient(closest-side,#000 0,rgba(0,0,0,.85) 24%,transparent 58%);mask-image:radial-gradient(closest-side,#000 0,rgba(0,0,0,.85) 24%,transparent 58%)}
.lyr-ring-nofloor .lyr-ring-floor{display:none}
.lyr-ring-list{--lyr-ring-v:0}
.lyr-ring-item{position:absolute;left:50%;top:${lift}%;width:var(--lyr-ring-pw);height:var(--lyr-ring-ph);margin:calc(var(--lyr-ring-ph) / -2) 0 0 calc(var(--lyr-ring-pw) / -2);transform:translateZ(var(--lyr-ring-P)) rotateY(calc(var(--k,9) * ${-step}deg + var(--lyr-ring-v) * -1deg)) translateZ(calc(var(--lyr-ring-P) * -1));transform-origin:50% 50%;backface-visibility:hidden;will-change:transform;visibility:hidden;display:flex;flex-direction:column;gap:calc(var(--lyr-ring-pw) * .027);color:inherit;text-decoration:none;transition:opacity .2s ease}
.lyr-ring-item:nth-child(1){--k:0;visibility:visible}
.lyr-ring-item:nth-child(2){--k:1;visibility:visible}
.lyr-ring-item:nth-child(3){--k:2;visibility:visible}
.lyr-ring-item:nth-last-child(1):not(:nth-child(-n+3)){--k:-1;visibility:visible}
.lyr-ring-item:nth-last-child(2):not(:nth-child(-n+3)){--k:-2;visibility:visible}
.lyr-ring-list[data-on]>.lyr-ring-item{visibility:hidden}
.lyr-ring-list[data-on]>.lyr-ring-item[data-see]{visibility:visible}
.lyr-ring-pic{position:relative;flex:1 1 0;min-height:0;overflow:hidden;border-radius:calc(var(--lyr-ring-pw) * .022);background:var(--lyr-ring-tint)}
.lyr-ring-pic img,.lyr-ring-empty{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block}
.lyr-ring-empty{background:radial-gradient(70% 60% at 50% 40%,rgba(255,255,255,.12),rgba(255,255,255,0) 70%),linear-gradient(160deg,rgba(255,255,255,.06),rgba(0,0,0,.16))}
.lyr-ring-cap{flex:none;display:flex;align-items:center;justify-content:space-between;gap:12px;font-size:max(13px,calc(var(--lyr-ring-pw) * .052));line-height:1.2;letter-spacing:-.02em;white-space:nowrap}
.lyr-ring-cap span{overflow:hidden;text-overflow:ellipsis}
.lyr-ring-cap svg{flex:none;width:.62em;height:.62em}
@media (hover:hover){a.lyr-ring-item:hover{opacity:.78}}
@media (prefers-reduced-motion:reduce){.lyr-ring-list{--lyr-ring-v:0!important}.lyr-ring-pinned{height:auto}.lyr-ring-pinned>.lyr-ring-pin{position:relative}}
`
}

// The section to follow: the parent of the nearest sticky layer around the ring (its own pin, or one you
// built), when that section is taller than the window.
function trackOf(el: HTMLElement): HTMLElement | null {
    for (let n = el.parentElement; n && n !== document.body; n = n.parentElement) {
        if (getComputedStyle(n).position === "sticky") return n.parentElement
    }
    return null
}

const Arrow = () => (
    <svg viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.3" aria-hidden="true">
        <path d="M3 9L9 3M4.2 3H9v4.8" />
    </svg>
)

/**
 * @framerIntrinsicWidth 1200
 * @framerIntrinsicHeight 600
 *
 * @framerSupportedLayoutWidth any
 * @framerSupportedLayoutHeight any
 */
export default function Ring(props: RingProps) {
    const {
        items = ITEMS,
        panelHeight = 60,
        panelWidth = 69,
        shape = 1.5,
        spacing = 33,
        lift = 42,
        floor = true,
        lineColour = "#8B8880",
        motion = "Follows scroll",
        pin = true,
        pinLength = 170,
        fontFamily = "Inter",
        textColour = "#F2F0EA",
        tintColour = "#1B1B19",
        imageWidth = "50vw",
        style,
    } = props
    const isStatic = useIsStaticRenderer()
    const stage = useRef<HTMLDivElement>(null)
    const listRef = useRef<HTMLDivElement>(null)
    const still = String(motion || "").toLowerCase().includes("still")
    const list = Array.isArray(items) ? items.filter(Boolean) : []
    const count = list.length
    const pinned = !!pin && !isStatic

    useEffect(() => {
        const el = stage.current
        const ring = listRef.current
        if (!el || !ring || isStatic) return
        const panels = Array.from(ring.children).filter((c) => c.classList.contains("lyr-ring-item")) as HTMLElement[]
        const n = panels.length
        if (!n) return
        const paint = (o: number, v: number) => {
            for (let i = 0; i < n; i++) {
                let k = i - o
                k = ((((k + n / 2) % n) + n) % n) - n / 2
                panels[i].style.setProperty("--k", k.toFixed(4))
                if (Math.abs(k) < 2.45) panels[i].setAttribute("data-see", "")
                else panels[i].removeAttribute("data-see")
            }
            ring.style.setProperty("--lyr-ring-v", v.toFixed(3))
            el.style.setProperty("--lyr-ring-x", (-o * 1.6).toFixed(4))
            ring.setAttribute("data-on", "")
        }
        const reduce = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches
        if (still || reduce || n < 2) {
            paint(0, 0)
            return
        }
        const track = trackOf(el)
        let goal = 0,
            now = 0,
            raf = 0,
            last = 0,
            prev = 0
        const read = () => {
            const vh = window.innerHeight
            if (track) {
                const r = track.getBoundingClientRect()
                const travel = r.height - vh
                if (travel > 8) {
                    goal = clamp((-r.top / travel - 0.02) / 0.9) * (n - 1)
                    return
                }
            }
            const r = el.getBoundingClientRect()
            goal = clamp((vh - r.top) / (vh + r.height)) * (n - 1)
        }
        const tick = (t: number) => {
            const dt = last ? Math.min(64, t - last) : 16
            last = t
            now += (goal - now) * (1 - Math.exp(-dt / 140))
            if (Math.abs(goal - now) < 0.0005) now = goal
            // A slight lean in the direction of travel while the ring is turning.
            const v = clamp((now - prev) * 60, -1, 1) * 9
            prev = now
            paint(now, v)
            raf = now === goal ? 0 : requestAnimationFrame(tick)
            if (!raf) {
                last = 0
                paint(now, 0)
            }
        }
        const wake = () => {
            read()
            if (!raf) raf = requestAnimationFrame(tick)
        }
        wake()
        window.addEventListener("scroll", wake, { passive: true })
        window.addEventListener("resize", wake)
        return () => {
            window.removeEventListener("scroll", wake)
            window.removeEventListener("resize", wake)
            if (raf) cancelAnimationFrame(raf)
        }
    }, [isStatic, still, count, pinned])

    const h = clamp(Number(panelHeight) || 60, 20, 90)
    const wide = clamp(Number(panelWidth) || 69, 30, 95)
    const sh = clamp(Number(shape) || 1.5, 0.6, 2.4)
    const step = clamp(Number(spacing) || 33, 20, 60)
    const up = clamp(Number(lift) || 42, 30, 60)
    const vars = { "--lyr-ring-line": lineColour, "--lyr-ring-tint": tintColour, "--lyr-ring-len": Math.max(0, Number(pinLength) || 0) } as CSSProperties

    return (
        <div className={"lyr-ring-track" + (pinned ? " lyr-ring-pinned" : "")} style={{ ...style, ...vars, color: textColour, fontFamily: `"${String(fontFamily || "Inter").replace(/['"]/g, "")}", "Inter", system-ui, sans-serif` }}>
            <style dangerouslySetInnerHTML={{ __html: sheet(h, wide, sh, step, up) }} />
            <div className="lyr-ring-pin">
                <div ref={stage} className={"lyr-ring" + (floor === false ? " lyr-ring-nofloor" : "")}>
                    <div className="lyr-ring-floorbox" aria-hidden="true">
                        <div className="lyr-ring-floor" />
                    </div>
                    <div ref={listRef} className="lyr-ring-list">
                        {list.map((it, i) => {
                            const img = it.image && it.image.src ? it.image : null
                            const inner: ReactNode = (
                                <>
                                    <div className="lyr-ring-pic">
                                        {img ? (
                                            <img loading={i < 3 || i >= count - 2 ? "eager" : "lazy"} decoding="async" sizes={imageWidth || "50vw"} srcSet={img.srcSet} src={img.src} alt={img.alt || ""} style={{ objectPosition: `${img.positionX || "50%"} ${img.positionY || "50%"}` }} />
                                        ) : (
                                            <i className="lyr-ring-empty" />
                                        )}
                                    </div>
                                    <div className="lyr-ring-cap">
                                        <span>{it.title}</span>
                                        {it.link ? <Arrow /> : null}
                                    </div>
                                </>
                            )
                            return it.link ? (
                                <Link key={i} href={it.link}>
                                    <a className="lyr-ring-item">{inner}</a>
                                </Link>
                            ) : (
                                <div key={i} className="lyr-ring-item">
                                    {inner}
                                </div>
                            )
                        })}
                    </div>
                </div>
            </div>
        </div>
    )
}

addPropertyControls(Ring, {
    items: {
        type: ControlType.Array,
        title: "Pictures",
        maxCount: 16,
        control: {
            type: ControlType.Object,
            controls: {
                image: { type: ControlType.ResponsiveImage, title: "Image" },
                title: { type: ControlType.String, title: "Title", defaultValue: "Project" },
                link: { type: ControlType.Link, title: "Link" },
            },
        },
        defaultValue: ITEMS,
    },
    panelHeight: { type: ControlType.Number, title: "Picture height", min: 20, max: 90, step: 1, unit: "%", defaultValue: 60, description: "How tall each picture is, as a share of this frame's height." },
    panelWidth: { type: ControlType.Number, title: "Widest", min: 30, max: 95, step: 1, unit: "%", defaultValue: 69, description: "On a narrow screen a picture is never wider than this share of the frame." },
    shape: { type: ControlType.Number, title: "Picture shape", min: 0.6, max: 2.4, step: 0.05, defaultValue: 1.5, description: "Width divided by height. 1.5 suits a 3 by 2 picture." },
    spacing: { type: ControlType.Number, title: "Spacing", min: 20, max: 60, step: 1, unit: "°", defaultValue: 33, description: "The angle between one picture and the next. Lower brings them closer together." },
    lift: { type: ControlType.Number, title: "Eye level", min: 30, max: 60, step: 1, unit: "%", defaultValue: 42, description: "Where the middle of the pictures sits, from the top of the frame. The rest is floor." },
    floor: { type: ControlType.Boolean, title: "Floor", defaultValue: true, enabledTitle: "Show", disabledTitle: "Hide" },
    lineColour: { type: ControlType.Color, title: "Floor colour", defaultValue: "#8B8880" },
    motion: { type: ControlType.Enum, title: "Motion", options: ["Follows scroll", "Still"], optionTitles: ["Follows scroll", "Still"], defaultValue: "Follows scroll", description: "Still holds the first picture in the middle." },
    pin: { type: ControlType.Boolean, title: "Pin", defaultValue: true, description: "Holds the ring in the window while the page scrolls past, then lets it go. Set the height to Fit: it becomes one window plus Pin length." },
    pinLength: { type: ControlType.Number, title: "Pin length", min: 60, max: 400, step: 10, unit: "vh", defaultValue: 170, hidden: (p: Partial<RingProps>) => !p.pin, description: "How much scrolling one full turn takes. Phones use a little less." },
    fontFamily: { type: ControlType.String, title: "Font family", defaultValue: "Inter", description: "Use the family name of your text styles." },
    textColour: { type: ControlType.Color, title: "Text colour", defaultValue: "#F2F0EA" },
    tintColour: { type: ControlType.Color, title: "Tint colour", defaultValue: "#1B1B19", description: "Behind a picture that is not set yet." },
    imageWidth: { type: ControlType.String, title: "Image width", defaultValue: "50vw", description: "How wide a picture is drawn at most, for choosing a file size." },
})

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.