# Three Labels on a Tilted Ring Around the Skull, Dimming Behind It

> Year, stop count and VIEW orbit the skull on a tilted ring that dims labels as they pass behind it. This is the only place translate and parallax are permitted.

- Author: Dil Ahdan (https://dil.dev/)
- Published: 2026-09-26
- Updated: 2026-09-26
- Tags: three.js, WebGL, scroll, orbit
- Canonical: https://dil.dev/notes/skull-orbit-year-count-view/

## Key takeaways

- depthOpacity runs from 1.0 in front to 0.18 behind, eased with a cubic curve so the fade feels physical rather than linear.
- The orbit ring has a radius of 0.34 and a tilt of 0.28, centred on the head with angle 0 placing the first label at +Z.
- This is the deliberate exception to the site-wide no-translate and no-parallax rule, which applies to titles and everything else.
- Inside each stop the block rises via --lift (10svh on phones, 30svh on desktop) and the skull sways laterally. There is no vertical bob.
- Title characters still condense by opacity and blur only, never by translate.

The three labels that circle the skull sit on a tilted ring in Three.js space. As each label rotates behind the head it dims to near-invisible, then brightens again as it swings back to the front. Everything else on the page follows a strict no-translate, no-parallax rule. This ring is the exception, and it is an intentional one.

## The Ring Geometry

The orbit is defined in `site/src/lab/orbit.js` with three parameters that matter most:

- **Radius 0.34.** Tight enough to hug the skull without clipping through it at most viewport sizes.
- **Tilt 0.28.** A fractional radian lean that stops the ring looking like a flat equator and gives it a slight planetary feel.
- **Count 3.** Exactly three evenly spaced points, one per label.

`orbitPoints` centres on the head. Angle 0 places the first label at +Z, meaning it starts closest to the camera. The other two are distributed evenly around the ring from there.

## What the Three Labels Actually Are

The labels are short by design:

- **Year.** The calendar year of the stop, nothing more.
- **Stop count.** A simple index, so the reader knows where they are in the sequence.
- **VIEW.** A single call-to-action word.

Short labels matter here because they rotate through a tight arc. Anything longer would collide with the skull geometry or with each other at certain angles.

## depthOpacity: How the Dimming Works

Each label gets a `depthOpacity` value derived from its current Z position on the ring. The range is:

- **1.0 at the front.** Full opacity when the label is closest to the camera.
- **0.18 at the back.** Nearly invisible when it is behind the skull.

The transition between those two values is smoothed with a cubic ease. A linear fade would look mechanical. The cubic curve means labels spend more time near full brightness and then drop off quickly as they cross behind the head, which matches how occlusion feels in the real world.

The dimming is not a clip or a visibility toggle. The label is still there at 0.18 opacity. That matters for accessibility and for the animation feeling continuous rather than blinking.

## The No-Translate Rule and Why This Breaks It

The site has a deliberate constraint: no CSS translate on content, no scroll-linked parallax. The reasons are performance and predictability. Scroll-linked CSS transforms can cause layout recalculations and compositor skips, especially on lower-end phones.

The orbit labels are exempt because they live inside a Three.js render loop. Their position is updated on `requestAnimationFrame`, not tied to a scroll event listener that fires on the main thread. The same jank risk does not apply.

The spec (docs/superpowers/specs/2026-09-24-skull-orbit-design.md and AGENTS.md, both dated 24 September 2026) makes this explicit: the orbit labels and the block lift are the two permitted exceptions. Everything else, including title characters, stays off translate.

## The Block Lift Inside Each Stop

When you enter a stop, the content block rises. That rise is controlled by a custom property called `--lift`:

- **10svh on phones.**
- **30svh on desktop.**

The larger desktop value gives the content room to surface dramatically without feeling cramped. On phones the viewport is short enough that 10svh is already a meaningful distance.

This is translate, technically. But it is scoped to the block as a whole entering a stop, not a continuous scroll-linked parallax. The spec treats it as a discrete state change, not an ongoing transform.

## The Skull Sways, It Does Not Bob

The skull has lateral drift, handled in `site/src/lab/timeline.js`. As you move between stops it sways left and right. There is no vertical bob. Vertical motion on the skull itself would fight with the block lift and create a confusing sense of which thing is moving. Keeping the skull horizontal-only and the content vertical-only separates the two axes cleanly.

## Title Characters: Still Opacity and Blur Only

Even though the orbit labels translate through space, title characters do not. They condense as you scroll using:

- **Opacity.** Characters fade in or out.
- **Blur.** A CSS blur filter sharpens or softens them.

No translate. No scale. The constraint is still in force for titles because they are rendered in the DOM, not in WebGL, and the same performance concerns apply.

This also means the title treatment is consistent across every stop. The orbit labels are the exotic part. The titles stay calm.

## What I'd do differently

The cubic ease on `depthOpacity` was chosen by feel. I would instrument it next time, logging the opacity value at a few key angles and comparing against a reference video of a physical object rotating behind an occluder. Gut feel got us close, but a reference would let me tune the curve with a reason rather than a vibe.

The `--lift` values (10svh and 30svh) were also set by eye across two breakpoints. A more systematic approach would define a fluid clamp between those two values so the lift scales continuously with viewport height rather than jumping at a single breakpoint. That would help on tablets, which currently get the desktop value even when the screen is not tall enough to make 30svh feel right.

Finally, the three labels are hardcoded as year, count and VIEW. If the stop data ever needs a fourth label, the orbit geometry would need a count change and a spacing recalculation. Worth abstracting that into a data-driven config earlier rather than later.

## Quick answers

**How does the depth dimming work for the orbiting labels?**

A value called depthOpacity is calculated per label based on its Z position on the ring. It runs from 1.0 at the front to 0.18 at the back and is smoothed through a cubic ease, so labels fade out gradually as they rotate behind the skull rather than snapping.

**Why are translate and parallax allowed for the orbit but nowhere else?**

The site rule bans translate and parallax to keep scroll performance predictable and avoid layout jank. The orbit labels and the block lift are the explicit exceptions because their motion is driven by a Three.js render loop, not CSS scroll-linked animations, so the same jank risk does not apply.

**What are the three labels that orbit the skull?**

They are the year, the stop count and the word VIEW. All three are short by design so they read cleanly at small sizes as they rotate around the head.

**Does the skull move vertically as well as swaying?**

No. The skull drifts laterally (handled in timeline.js) but there is no vertical bob. The vertical motion inside a stop comes entirely from the content block rising via the --lift custom property.

**How does the --lift value change between mobile and desktop?**

On phones --lift is 10svh and on desktop it is 30svh. The block rises by that amount as you enter a stop, giving a sense of the content surfacing without any parallax on the title text itself.

**Do title characters animate with translate during the orbit scroll?**

No. Titles condense using opacity and blur only. Translate is still off-limits for them even though the orbit labels are an exception to that rule.
