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