<section class="demo">
<div class="demo__sticky">
<svg class="demo__ring" viewBox="0 0 120 120">
<circle cx="60" cy="60" r="52" class="demo__track"></circle>
<circle cx="60" cy="60" r="52" id="progress"></circle>
</svg>
<strong id="pct">18%</strong>
</div>
<article class="demo__copy">
<p>Motion is a way of saying what the layout cannot. Timing, pause, and weight turn a surface into a sentence.</p>
<p>Keep the ring in view while the copy moves. The stroke is not decoration — it is a meter.</p>
<p>When the last line leaves, the circle should feel complete, not louder.</p>
</article>
</section>.demo {
display: grid;
grid-template-columns: 120px 1fr;
gap: 28px;
min-height: 220vh;
padding: 18vh 24px 40vh;
background: #08080a;
color: #f4f4f2;
font-family: "Helvetica Neue", system-ui, sans-serif;
}
.demo__sticky {
position: sticky;
top: 28vh;
height: 140px;
display: grid;
place-items: center;
}
.demo__ring { width: 112px; transform: rotate(-90deg); }
.demo__track { fill: none; stroke: #1c1c22; stroke-width: 2.5; }
#progress {
fill: none;
stroke: #f4f4f2;
stroke-width: 2.5;
stroke-linecap: round;
}
.demo__sticky strong {
position: absolute;
font-size: 13px;
letter-spacing: 0.08em;
font-weight: 500;
}
.demo__copy p {
margin: 0 0 1.4em;
font-size: 22px;
line-height: 1.45;
letter-spacing: -0.03em;
color: #c8c8cc;
}const circle = document.querySelector("#progress");
const pct = document.querySelector("#pct");
const length = 2 * Math.PI * 52;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const proxy = { offset: length * 0.82 };
function paint() {
circle.style.strokeDasharray = String(length);
circle.style.strokeDashoffset = String(proxy.offset);
pct.textContent = Math.round((1 - proxy.offset / length) * 100) + "%";
}
paint();
if (reduce) {
proxy.offset = 0;
paint();
} else if (window.ScrollTrigger) {
gsap.to(proxy, {
offset: 0,
ease: "none",
onUpdate: paint,
scrollTrigger: {
trigger: ".demo",
start: "top 80%",
end: "bottom 20%",
scrub: 0.3,
},
});
}