Scroll-driven character orbit
Scroll walks the camera a full circle around a figure on a pedestal. The title fades as the orbit begins, dark notes surface at the angles they belong to, and the closing line lands as the figure comes back to face you.
How it works
- The camera never moves. A
.worldwithtransform-style: preserve-3drotates about y byp × 360°under a 900px perspective, tilted −6° on x so you look slightly down at it. Orbit is linear in progress: a turntable eased with smoothstep feels like it sticks and slips. - The figure is planes, not a model: two body silhouettes crossed at 90° (so there is no angle where it goes edge-on), two wing planes hinged at the spine and swung ±38° so they open towards the viewer, a crown on the body plane, and a floor disc plus a shadow disc laid flat with
rotateX(90deg). All withbackface-visibility: visible. In the source it is a rendered clip or a WebGL model; the scroll → angle → copy mechanism is identical. - Notes own windows of the orbit — 0.18–0.42, 0.44–0.68, 0.62–0.86 — and fade in over the first 6% and out over the last 6% of their window. Each one is placed on the side of the stage the figure's back is turning towards, so the copy sits beside the character rather than over it.
- The title fades and drifts up over 0–0.16 with a 10% scale — the first sixth of the scroll is the title leaving; the orbit is already turning underneath it. The closing line rises over 0.86–0.96, as the orbit completes.
- The 520% track gives the full turn about four viewport heights, roughly 90° per flick on a trackpad. A readout prints progress and the current orbit angle.
Numbers
- Scroll distance
- 520% of the viewport
- Orbit
- rotateY(p × 360°), linear · tilt −6° · perspective 900px
- Wings
- hinged at the spine · ±38°
- Title out
- 0–0.16 · rise 30px · scale 1.1
- Note windows
- 0.18–0.42 · 0.44–0.68 · 0.62–0.86 · edges 6%
- Closing line
- 0.86–0.96
Prompt
Paste this into any coding model to get the effect from scratch. It states every number explicitly — vague words like “smooth” or “modern” produce a different result every time.
Build a scroll-driven character orbit in vanilla HTML/CSS/JS. Scroll container with a 520%-tall track and a sticky stage (height: 100vh, never 100%), light grey radial background. A .cam element at the stage centre with perspective: 900px and perspective-origin 50% 40%, containing a .world with transform-style: preserve-3d and transform: rotateX(-6deg) rotateY(calc(var(--p) * 360deg)). Inside the world, absolutely positioned planes with backface-visibility visible: two 150×300 body planes (inline SVG of a robed figure with a gold crown), the second rotated 90° about y; two 230×300 wing planes (inline SVG feathered wing, the right one mirrored) hinged at the spine with transform-origin on the inner edge and rotateY(−38deg) / rotateY(38deg); a 260px floor disc and a 200px shadow disc laid flat with rotateX(90deg) translateZ(−20px). Read progress p from layout (scrollTop / (trackHeight − clientHeight)) and write --p LINEARLY (no easing). A huge title at the top with opacity calc(1 − var(--p1)) rising 30px and scaling 1.1, where --p1 = smoothstep(slice(p, 0, .16)). Three dark note cards positioned around the stage, each with opacity --o = smoothstep(slice(p, a, a + .06)) × (1 − smoothstep(slice(p, b − .06, b))) for windows (.18,.42), (.44,.68), (.62,.86), rising 16px as they appear. A closing headline bottom-left with --o = smoothstep(slice(p, .86, .96)). Passive scroll listener, one requestAnimationFrame per burst, and a readout showing progress and the orbit angle in degrees.Source
The whole file. It is what the sample above is running — copy it into an .html file and it works with nothing else.
<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Scroll-driven character orbit</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; }
body { font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", sans-serif; background: #d6d6d8; color: #111; }
.scroller { height: 100%; overflow-y: auto; scrollbar-width: thin; }
.track { height: 520%; }
/* 🩸 100vh,不是 100%:.track 高 520%,100% 会把舞台撑成整条轨道那么高 */
.stage { position: sticky; top: 0; height: 100vh; overflow: hidden; background: radial-gradient(80% 70% at 50% 40%, #ececee, #c9c9cc 80%); }
.nav { position: absolute; left: 4%; right: 4%; top: 12px; display: flex; justify-content: space-between; font-size: 7px; letter-spacing: .1em; text-transform: uppercase; color: #11111199; }
.nav b { color: #111; }
/* ---------- the turntable ----------
The camera never moves. The world rotates about y by p × 360°, and the
figure is a handful of planes inside it: two crossed body silhouettes
(so there is no angle at which it goes edge-on), two wing planes hinged
at the spine, a crown ring, a floor disc. */
.cam { position: absolute; left: 50%; top: 62%; width: 0; height: 0; perspective: 900px; perspective-origin: 50% 40%; }
.world { position: absolute; left: 0; top: 0; transform-style: preserve-3d; transform: rotateX(-6deg) rotateY(calc(var(--p) * 360deg)) scale3d(.8, .8, .8); }
.plane { position: absolute; transform-style: preserve-3d; backface-visibility: visible; }
.plane svg { position: absolute; left: 0; top: 0; width: 100%; height: 100%; overflow: visible; }
.body { width: 150px; height: 300px; left: -75px; top: -280px; }
.body.b2 { transform: rotateY(90deg); }
.wing { width: 230px; height: 300px; top: -330px; }
.wing.l { left: -230px; transform-origin: 100% 55%; transform: rotateY(-38deg); }
.wing.r { left: 0; transform-origin: 0 55%; transform: rotateY(38deg); }
.floor { width: 260px; height: 260px; left: -130px; top: -130px; transform: rotateX(90deg) translateZ(-20px); border-radius: 50%; background: radial-gradient(circle, #bdbdc2, #bdbdc200 70%); }
.floor::after { content: ""; position: absolute; inset: 16px; border-radius: 50%; border: 1px dashed #11111133; }
.shadow { width: 200px; height: 200px; left: -100px; top: -100px; transform: rotateX(90deg) translateZ(-19px); border-radius: 50%; background: radial-gradient(circle, #00000038, #0000 70%); }
/* ---------- copy ---------- */
h1 {
position: absolute; left: 0; right: 0; top: 8%; text-align: center; z-index: 2;
font-size: 84px; font-weight: 800; letter-spacing: -.03em; line-height: 1; color: #111;
opacity: calc(1 - var(--p1)); transform: translateY(calc(var(--p1) * -30px)) scale(calc(1 + var(--p1) * .1));
}
.note { position: absolute; width: 170px; padding: 12px; background: #1c1c1e; color: #fff; font-size: 7.5px; line-height: 1.6; z-index: 2; opacity: var(--o); transform: translateY(calc((1 - var(--o)) * 16px)); }
.note small { display: block; font-size: 6px; letter-spacing: .18em; text-transform: uppercase; color: #ffffff80; margin-bottom: 6px; }
.note.a { left: 4%; top: 30%; }
.note.b { right: 4%; top: 30%; }
.note.c { left: 4%; top: 22%; }
.end {
position: absolute; left: 6%; bottom: 10%; z-index: 2; font-size: 56px; font-weight: 800; line-height: .9; letter-spacing: -.03em; text-transform: uppercase;
opacity: var(--o); transform: translateY(calc((1 - var(--o)) * 24px));
}
.readout { position: absolute; left: 4%; bottom: 6%; font: 500 10px/1.7 ui-monospace, Menlo, monospace; color: #11111180; z-index: 2; }
.readout b { color: #111; font-weight: 600; }
.cue { position: absolute; right: 4%; bottom: 6%; font-size: 10px; letter-spacing: .18em; text-transform: uppercase; color: #11111166; opacity: calc(1 - var(--p1)); }
</style>
<div class="scroller" id="scroller">
<div class="track">
<div class="stage" id="stage" style="--p:0;--p1:0">
<div class="nav"><span><b>Obsidian</b> ◇</span><span>Volume 01 · MMXXV</span><span>Ink, form & modern practice</span><span>☰</span></div>
<h1>OBSIDIAN</h1>
<div class="cam"><div class="world">
<div class="plane floor"></div><div class="plane shadow"></div>
<div class="plane wing l"><svg viewBox="0 0 170 220" aria-hidden="true"><defs><linearGradient id="w" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#fbfaf7"/><stop offset="1" stop-color="#cfcac2"/></linearGradient></defs>
<path d="M168 120 C120 20 40 0 6 30 C40 40 70 60 90 90 C50 80 20 90 4 120 C40 118 70 128 96 150 C60 160 34 180 24 214 C70 190 120 170 168 150 Z" fill="url(#w)"/>
<g fill="none" stroke="#b9b3a9" stroke-width="1"><path d="M168 122 C120 70 70 44 26 34"/><path d="M168 136 C120 110 70 108 20 118"/><path d="M168 150 C120 160 80 176 40 204"/></g></svg></div>
<div class="plane wing r"><svg viewBox="0 0 170 220" aria-hidden="true"><g transform="translate(170 0) scale(-1 1)">
<path d="M168 120 C120 20 40 0 6 30 C40 40 70 60 90 90 C50 80 20 90 4 120 C40 118 70 128 96 150 C60 160 34 180 24 214 C70 190 120 170 168 150 Z" fill="url(#w)"/>
<g fill="none" stroke="#b9b3a9" stroke-width="1"><path d="M168 122 C120 70 70 44 26 34"/><path d="M168 136 C120 110 70 108 20 118"/><path d="M168 150 C120 160 80 176 40 204"/></g></g></svg></div>
<div class="plane body b1"><svg viewBox="0 0 150 300" aria-hidden="true"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#f6f3ee"/><stop offset="1" stop-color="#c9c3b8"/></linearGradient></defs>
<path d="M75 30 C90 30 100 42 100 58 C100 74 92 84 84 90 L96 100 C120 120 120 180 118 300 L32 300 C30 180 30 120 54 100 L66 90 C58 84 50 74 50 58 C50 42 60 30 75 30 Z" fill="url(#g)"/>
<path d="M52 34 L52 14 L62 26 L70 6 L78 26 L86 6 L94 26 L98 14 L98 34 Z" fill="#c9a24c"/><rect x="52" y="30" width="46" height="8" rx="2" fill="#b58c3a"/>
<path d="M60 60 C64 76 86 76 90 60" stroke="#b9a992" stroke-width="2" fill="none"/></svg></div>
<div class="plane body b2"><svg viewBox="0 0 150 300" aria-hidden="true">
<path d="M75 30 C90 30 100 42 100 58 C100 74 92 84 84 90 L96 100 C120 120 120 180 118 300 L32 300 C30 180 30 120 54 100 L66 90 C58 84 50 74 50 58 C50 42 60 30 75 30 Z" fill="url(#g)"/>
<path d="M52 34 L52 14 L62 26 L70 6 L78 26 L86 6 L94 26 L98 14 L98 34 Z" fill="#c9a24c"/><rect x="52" y="30" width="46" height="8" rx="2" fill="#b58c3a"/></svg></div>
</div></div>
<div class="note a" id="n1"><small>Premise</small>Here in our studio is a refuge for intention, where patience meets technique and craft dissolves all limits. With global reverence and an unwavering devotion to the singular.</div>
<div class="note b" id="n2"><small>Process</small>We treat each tattoo as a silent dialogue, a mark that emerges from your story. Your form is a page, and we aspire to inscribe narratives that capture your singularity and essence.</div>
<div class="note c" id="n3"><small>Practice</small>Obsidian Studio, established by globally acclaimed visionaries of fine line, micro realism and minimalist tattoo, unites the world's finest artists in Brooklyn, NYC.</div>
<div class="end" id="end">Find<br>your<br>artist</div>
<div class="readout">progress <b id="rp">0.000</b> · orbit <b id="ro">0°</b></div>
<div class="cue">scroll ↓</div>
</div>
</div>
</div>
<script>
/* Progress → one angle. Every note owns a window of the orbit and fades in
and out inside it, so the copy arrives with the side of the figure it
belongs to and leaves before the next side comes round. */
const scroller = document.getElementById("scroller"), stage = document.getElementById("stage");
const rp = document.getElementById("rp"), ro = document.getElementById("ro");
const clamp01 = (v) => Math.min(1, Math.max(0, v));
const slice = (p, a, b) => clamp01((p - a) / (b - a));
const smooth = (x) => x * x * (3 - 2 * x);
const window_ = (p, a, b, e = 0.06) => smooth(slice(p, a, a + e)) * (1 - smooth(slice(p, b - e, b)));
const NOTES = [["n1", 0.18, 0.42], ["n2", 0.44, 0.68], ["n3", 0.62, 0.86]];
let raf = 0;
function update() {
raf = 0;
const max = scroller.firstElementChild.offsetHeight - scroller.clientHeight;
const p = max > 0 ? clamp01(scroller.scrollTop / max) : 0;
stage.style.setProperty("--p", p.toFixed(4)); // orbit is linear: no easing on a turntable
stage.style.setProperty("--p1", smooth(slice(p, 0, 0.16)).toFixed(4));
for (const [id, a, b] of NOTES) document.getElementById(id).style.setProperty("--o", window_(p, a, b).toFixed(3));
document.getElementById("end").style.setProperty("--o", smooth(slice(p, 0.86, 0.96)).toFixed(3));
rp.textContent = p.toFixed(3); ro.textContent = Math.round(p * 360) + "°";
}
scroller.addEventListener("scroll", () => { if (!raf) raf = requestAnimationFrame(update); }, { passive: true });
update();
</script>Where it breaks
- Planes are a stand-in. The wing planes visibly flatten at 90° and the crossed bodies show their seam from some angles; the point of the sample is the scroll → orbit → copy-window mechanism. With a real model, keep the windows and replace
.worldwith a camera orbit. preserve-3dwithoverflow: hiddenor afilteron any ancestor flattens the scene silently. The sticky stage hasoverflow: hidden— that is fine because the 3D context starts inside it at.cam; put a filter on.camand the orbit becomes a 2D squash.- A full 360° over four viewport heights is comfortable on a trackpad and slow on a mouse wheel (about 40 notches). If the audience is mouse-heavy, shorten the track rather than easing the orbit.
Seen in
@优卓UX上岸社 (Douyin) · 「UI交互动效描述」第 4 集 · Obsidian · 0:25