Skip to content

Motion

Motion in Control Room follows Law 7: alive at rest, explosive on event, always settles. Readable speed comes from contrast — low ambient life against held compositions, punctuated by a single focal eruption — not from constant movement. Constant motion everywhere reads as nothing.

Motion is organized into four tiers by cost and scope. The foundation is tiers 0 and 1; tiers 2 and 3 are deliberate, scoped enhancements.

TierNameEngineBudgetScopeStatus
0pressCSS transition<200mseverywhereship
1ambient lifeshared 180ms ticker (~5.5fps)~5.5fpssprites + chromeship
2choreographyrequestAnimationFrame + spring60fps · ≤1 mountfocal event onlyscoped
3shader atmosphereWebGL fragment shaderbezel overlaybezel screen onlyphase 1+

Ruled out: Rive / Lottie authored assets. The state machine already lives in the app’s session store; sprites are drawn from code, keeping per-session identity a fixed asset cannot. WebGL shaders may layer on top as a hero/bezel overlay — never as the animation engine.

  • MUST keep tier-1 ambient motion low and shared through one ticker.
  • MUST limit tier-2 choreography to one focal event at a time (≤1 concurrent mount).
  • MUST confine tier-3 shader effects to a bezel/screen overlay (Law 6), with a CSS fallback.
  • NEVER run tier-2/3 motion across the whole screen or at rest.

The mechanical feedback that makes the chassis feel physical. All CSS, <200ms.

Snap-press (the system’s signature). On :active, the element translates by the shadow offset while the shadow collapses to 0 — it presses into its own shadow.

.btn { transition: transform .05s, box-shadow .05s; }
.btn:active {
transform: translate(var(--shadow-off), var(--shadow-off));
box-shadow: 0 0 0 var(--shadow-col);
}

Stamp-in. A newly added token (Chip) stamps down.

@keyframes stamp { 0% { transform: scale(1.18); opacity: .4; } 100% { transform: scale(1); opacity: 1; } }

Kick. A one-shot border flash confirming an action fired.

.btn.kick::after { content: ""; position: absolute; inset: -3px;
border: 3px solid var(--sig-work); opacity: 0; animation: kick .2s ease-out 1; }
@keyframes kick { 0% { opacity: .9; transform: translate(-2px,0); }
100% { opacity: 0; transform: translate(2px,0); } }

One shared timer drives all ambient motion so it stays cheap and in sync (--tick-ambient: 180ms). It powers the pixel-cat frame rig (blink / ear-flick / tail-sway) and low-probability chrome glitches (brand shimmer, masthead scanline).

let tick = 0;
function rig() {
if (!reduced) { tick++; /* advance sprite frames + roll chrome glitches */ }
setTimeout(rig, 180);
}

Scanline — a slow, low-contrast CRT wash on chrome (masthead / instrument):

.mast::after { content: ""; position: absolute; inset: 0; pointer-events: none;
background: repeating-linear-gradient(0deg, transparent 0 3px, rgba(0,0,0,.06) 3px 4px);
animation: scan 7s linear infinite; }
@keyframes scan { to { background-position: 0 120px; } }
  • MUST gate every ambient effect on prefers-reduced-motion.
  • SHOULD keep chrome glitches low-probability and self-clearing.
  • NEVER ambient-animate a data surface (rows, tables, numerals).

The glitch vocabulary (Law 3 — severity readout)

Section titled “The glitch vocabulary (Law 3 — severity readout)”

Glitch intensity is the readout. Three tiers, mapped to severity.

T1 · split (nominal): RGB chromatic split via text-shadow.

.g-split.on { text-shadow: -2px 0 var(--sig-accent), 2px 0 var(--sig-work); }

T2 · slice (degraded): offset clip-path slices in --drip + --sig-accent.

.g-slice::before, .g-slice::after { content: attr(data-t); position: absolute;
left: 0; top: 0; width: 100%; opacity: 0; pointer-events: none; }
.g-slice.on::before { opacity: 1; color: var(--drip);
clip-path: inset(10% 0 60% 0); transform: translateX(-3px); }
.g-slice.on::after { opacity: 1; color: var(--sig-accent);
clip-path: inset(64% 0 6% 0); transform: translateX(3px); }

T3 · cursed (failed): zalgo combining marks. Max 2 combining marks per glyph or it overflows neighbouring lines.

Drip — vertical downward bleed in --drip, the house glitch for error surfaces and the masthead only.

  • MUST scale the tier to the actual severity.
  • MUST, for cursed text, keep the clean string as the accessible name and mark corruption aria-hidden (see references/accessibility.md).
  • NEVER glitch numerals, labels, or anything under 18px.
  • NEVER leave a glitch running ambiently — corruption that is always on carries no information.

Tie tier-2 eruptions to real state transitions, then settle:

  1. A session’s state changes in the store.
  2. The affected row/hero fires a one-shot eruption (.event rowglitch, a hero glitch, or a spring settle).
  3. The animation completes and the surface returns to its calm rest state.
// re-trigger a one-shot animation reliably
row.classList.remove("event");
void row.offsetWidth; // force reflow
row.classList.add("event");
  • MUST drive eruptions from genuine events, not timers.
  • MUST ensure every eruption settles back to rest.

The system is opt-out at the root — control-room.css already disables all animation and transition under prefers-reduced-motion: reduce. Beyond that:

  • MUST ensure every state remains fully legible with motion off — state is carried by color + shape + text, never by motion alone.
  • MUST check matchMedia("(prefers-reduced-motion: reduce)") before starting any JS-driven loop (ticker, rAF) and skip it when set.

The system is alive at rest (Law 7) — a low ambient floor, not a static screen. These opt-in CSS classes provide that floor. They are hardware-bound (use them on a bezel/screen/hero, not on a flat content field) and every one is killed by the global reduced-motion rule.

ClassLoopUse on
.cr-anim-scana thin CRT sweep bar falls down the surface (::after)a bezel/screen (host is position:relative; overflow:hidden)
.cr-anim-pulsea slow expanding ring — set --_pulse to re-key the huea signal dot that needs attention
.cr-anim-drifta halftone/dither surface drifts one cella .cr-tex--* hardware surface
.cr-anim-flicka two-step opacity flickera live ticker/cursor readout
<div class="cr-bezel cr-anim-scan">
<div class="cr-bezel__screen">> streaming · 14 sessions</div>
</div>
<span class="cr-dot cr-anim-pulse" style="--_pulse: var(--sig-err); background: var(--sig-err)"></span>
  • MUST keep loops low and slow (2–6s) — this is the ambient floor, not an event. A real event still gets the tier-2 eruption above, and there is only one eruption at a time.
  • SHOULD confine loops to hardware (bezel/screen/hero). A looping flat panel reads as noise and erases the contrast that makes a real event legible.
  • NEVER loop data, numerals, or labels; NEVER ambient-animate the whole screen at once.

The breach (Law 9) is the one exception that ambient-animates its own frame: its neon rim rotates and bright spots ride the border (@property --cr-breach-angle → a rotating conic gradient). That is licensed precisely because the breach is the single exceptional element — the same one-per-screen discipline as the eruption.

Motion can be tied to scroll position with zero JavaScript via CSS scroll-driven animations — an animation whose animation-timeline is a scroll progress, not the clock. The shipped .cr-scrollbar is a fixed progress rail bound to the document scroller:

<div class="cr-scrollbar" aria-hidden="true"></div>
.cr-scrollbar {
position: fixed; inset: 0 0 auto 0; height: 3px; transform: scaleX(0); transform-origin: 0 50%;
background: linear-gradient(90deg, var(--sig-accent), var(--sig-accent-2));
animation: cr-scrollfill linear both; animation-timeline: scroll(root block);
}
@keyframes cr-scrollfill { to { transform: scaleX(1); } }
  • Use scroll(root block) to bind to the page; add .cr-scrollbar--local (which switches to scroll(nearest block)) on an element inside a scroll container to bind to that container instead. view() timelines bind to an element entering the viewport.
  • MUST account for the global reduced-motion rule: it sets animation: none, which would freeze a scroll animation at its start frame — so the rail is display:none under prefers-reduced-motion rather than left stuck at empty. Scroll-linked motion is user-driven (not autonomous), but a frozen indicator is worse than none.
  • SHOULD reserve scroll-binding for progress/orientation cues, not for moving content around as the operator scrolls.

Governed effects — glitch · attention · interaction · 3D

Section titled “Governed effects — glitch · attention · interaction · 3D”

Motion beyond the press vocabulary, all under one governance: transform / opacity / shadow only (60fps), tokenized timing (--dur-fast|med|ambient, --ease-snap|step), opt-in by class, and silenced twice over — by the global prefers-reduced-motion rule and by the calm intensity profile (idle/ambient loops stop; interaction feedback stays). Never animate layout properties; never loop motion on body text.

ClassEffectUse
.cr-glitch (+ data-text)RGB-split datamosh slices on hover; --on runs it continuouslya breach/alert headline — sparingly
.cr-glitch--chromaa faint always-on RGB fringe so an element reads as “signal” at rest; the slice still fires on hover / --ona live/degraded readout that should feel unstable without moving
.cr-attentionslow breathing glow (keys to --cr-attn)draw the eye to the ONE primary / “needs you” action on a screen
.cr-keyedkeyed edge-sweep underline on hover/focusinteractive rows, nav items, cards
.cr-tilt / --liveperspective hover tilt; --live adds a slow idle floata sanctioned break of the flat plane — one accent element, like the Law-9 breach
.cr-cursed (+ optional data-text / data-seed)T3 decay (Law 3): zalgo combining marks, capped at 2/glyph. The painter moves the clean string to aria-label, marks the element role="img", and hides the corrupted glyphs (aria-hidden). Seeded → deterministic. Density follows --decoration-intensitya corrupted/failed label — checksum fail, dead daemon — where the decay is the meaning

Glitch is hover/--on only. There is no ambient random driver: a glitch fires on hover, or continuously while you set .cr-glitch--on, and .cr-glitch--chroma holds a faint always-on fringe without moving. Drive --on yourself if you want bursts, and keep it to a small handful of elements so the screen is never glitching everywhere at once. Cursed text is not motion — it renders once and holds — but it obeys the same restraint: it is a decay tier, not decoration to sprinkle.

Restraint is the rule. Glitch and 3D are rule-breaks: at most one of each per screen, on the exceptional element. Attention marks exactly one primary target — more than one and none of them reads as primary. Everything decays: nothing loops forever competing with the operator’s own signal.