# DF4 Labs — Motion

> **Motion clarifies state, hierarchy, or ownership. It never decorates emptiness.**

This is the third hard rule of the DF4 system. It applies to every surface — parent site, every product company site, every product app, every OG card, every email template that supports motion.

If an animation cannot answer one of these three questions, it does not ship:

1. **What changed?** — a state transition (loading → loaded, idle → active, before → after).
2. **What matters?** — a hierarchy cue (the new entry, the focused row, the route the user just landed on).
3. **Whose surface is this?** — an ownership signal (the logo, the live ticker, the telemetry feed).

Everything else is fluff and is forbidden across the entire family.

---

## The hard motion rules

These are absolute. They sit above any individual designer's preference.

1. **Motion clarifies state, hierarchy, or ownership.** Nothing else.
2. **No ambient motion** unless it is one of three sanctioned surfaces: the **logomark** (arrow nudges out of the box on hover/load), the **HeaderTicker** (operational text marquees), or the **TelemetryPane** (new rows stream in). Anywhere else, ambient motion is forbidden — no breathing gradients, no drifting orbs, no parallax, no "vibe" loops.
3. **No hover scale, ever.** Buttons, cards, links, images, product tiles — none of them grow on hover. Hover is a colour or border change, not a transform. The only sanctioned hover transforms are: (a) underline grow on text links, (b) the logomark arrow nudge.
4. **No decorative loops.** If an element animates forever without conveying state, it is decorative. Remove it.
5. **Reduced-motion must preserve the same information.** When `prefers-reduced-motion: reduce` is set, every animation either becomes an instant snap *or* fades over 200ms with no movement. The user must learn the same thing they would have learned from the full motion — never less.
6. **One curve, one reveal distance, one timing family.** The system has exactly one easing curve, one reveal-translate distance, and one timing family. Designers may pick *which* token from the family — they may not invent new ones.

---

## The motion tokens

There are three families, and that is all there is.

### Curve

There is **one** easing curve in the entire system:

```css
--ease: cubic-bezier(0.2, 0, 0, 1);
```

This is a soft, asymmetric ease-out. It feels editorial, not bouncy. Linear is permitted only for: progress bars, indeterminate spinners, the telemetry status dot pulse, and CSS `transform: translate` marquees. Everything else uses `--ease`.

Forbidden curves: `ease-in-out` (feels mechanical), `cubic-bezier` with overshoot (feels like a toy), spring physics from a library (feels like Material).

### Timing

```css
--t-instant: 120ms;   /* state flip — checkbox, toggle, hover colour    */
--t-quick:   240ms;   /* small reveal — entry fade-up, dropdown, toast  */
--t-base:    360ms;   /* element reveal — card in, row in, pane in      */
--t-slow:    560ms;   /* hero reveal — page-load headline, ledger fill  */
--t-march:  1600ms;   /* sanctioned ambient — telemetry tick, ticker    */
```

Pick by **what** is animating, not by aesthetic preference:

- **State flip** (the value just changed): `--t-instant`.
- **Small UI reveal** (a tooltip, a dropdown, a toast): `--t-quick`.
- **An element entering the page** (a card, a list row, a pane): `--t-base`.
- **A whole route revealing itself** (the route hero, the page-load sequence): `--t-slow`.
- **Sanctioned ambient** (telemetry, ticker, status dot): `--t-march`.

Anything outside this set is not in the system.

### Reveal distance

There is **one** reveal-translate distance:

```css
--reveal: 8px;
```

When an element enters via fade-up, it travels exactly 8px. Not 12, not 16, not "depending on hierarchy". Eight. The hierarchy cue is **timing**, not distance — a hero uses `--t-slow`, a row uses `--t-base`, both travel 8px.

Forbidden distances: 24px+ slide-ins (feels like a slide deck), horizontal slides (feels like a carousel), parallax differentials (feels like a 2017 hero).

---

## The four sanctioned primitives

Every animation in the system is built from one of these four. If you find yourself reaching for a fifth, the answer is to compose two of these — not invent a new one.

### 1. `reveal` — element entering on page load or scroll

Used for: route hero text, cards on first paint, list rows, panes. Composes via `--t-base` (default), `--t-slow` (hero), `--t-quick` (toast).

```css
@keyframes df4-reveal {
  from { opacity: 0; transform: translateY(var(--reveal)); }
  to   { opacity: 1; transform: none; }
}
.reveal { animation: df4-reveal var(--t-base) var(--ease) both; }
.reveal--hero { animation-duration: var(--t-slow); }
.reveal--toast { animation-duration: var(--t-quick); }
```

Stagger rule: when revealing a list, increment `animation-delay` by **40ms** per item, capped at 8 items (320ms). After 8, the rest reveal together.

### 2. `flip` — value changed in place

Used for: a number incrementing in the telemetry pane, a status pill switching colour, a tab indicator sliding to the new tab. The value changes **in the same slot**, briefly.

```css
@keyframes df4-flip {
  0%   { opacity: 1; }
  40%  { opacity: 0; }
  100% { opacity: 1; }
}
.flip { animation: df4-flip var(--t-instant) var(--ease) both; }
```

Flips are always `--t-instant` (120ms). If a flip needs more time, it is not a flip — it is a `reveal`.

### 3. `tick` — sanctioned ambient

The only ambient motion in the system. Used in three places, named here exhaustively:

- **TelemetryPane row insertion** — a new row fades up using `reveal --t-quick`, the rest of the rows shift down. The status dot pulses opacity 1 → 0.4 → 1 over `--t-march`.
- **HeaderTicker** — text content marquees horizontally at a fixed pixel rate (60px/sec). Pauses on `:hover` of the bar.
- **Logomark arrow nudge** — the orange arrow translates +2px / -2px on x/y over `--t-march`, easing both directions. Optional; only on the parent-site hero.

Any other "subtle ambient motion" idea is not in the system.

### 4. `route` — between-route transition

The page-level reveal, used once per navigation. The outgoing route fades to opacity 0 over `--t-quick`. The incoming route's `RouteHero` reveals with `--t-slow`, then its body sections reveal staggered at `--t-base`.

Total budget for a route transition: **800ms ceiling**. Anything longer is wasted attention.

---

## Reduced motion — the same information, no movement

When `prefers-reduced-motion: reduce` is set, the system does not just shorten animations; it **rebuilds them so they convey the same thing without translation**.

```css
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 1ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 1ms !important;
    scroll-behavior: auto !important;
  }
  /* But: keep the FADE for `reveal` so the hierarchy cue survives. */
  .reveal, .reveal--hero, .reveal--toast {
    animation: df4-reveal-rm 200ms linear both;
  }
}
@keyframes df4-reveal-rm { from { opacity: 0; } to { opacity: 1; } }
```

The information that was carried by the slide is now carried by the fade. The information that was carried by the stagger is gone — and that is acceptable, because stagger is hierarchy decoration. The information that was carried by the flip remains, because the flip is opacity-only already.

The TelemetryPane and HeaderTicker pause entirely under reduced-motion. They render the **most recent state** as static content. This is a content decision, not a motion fallback: a reduced-motion user gets the latest event as text, the same way an OG card does.

---

## What is forbidden — explicit list

To remove ambiguity:

| Forbidden | Why |
|---|---|
| `transform: scale(1.02)` on hover | Cards / buttons should not grow. The hover signal is colour. |
| Bouncy springs (overshoot easings) | Reads as toy / consumer-friendly. DF4 is editorial. |
| Parallax on scroll | Reads as 2017 startup landing page. |
| Background gradient breathing / hue shift | The system uses paper, not gradient atmosphere. |
| Floating orbs, blobs, particle fields | Nothing exists in the system to make particles of. |
| Carousel auto-advance | We do not show people content they did not ask to see. |
| Slide-in from left/right > 16px | Horizontal motion implies "next slide", not "this is the page". |
| Counting-up numbers on scroll | The number is the same number whether it counted or not. |
| Letter-by-letter heading reveals | The headline is a sentence, not a typewriter demo. |
| `animation: ... infinite` outside the three sanctioned ambients | Decorative loops are forbidden. |
| Different easings for different elements on the same page | One curve, one system. |

---

## Implementation checklist for any new surface

Before any DF4 surface ships, the designer or engineer signs off on this:

- [ ] Every animation uses `var(--ease)` (or sanctioned linear).
- [ ] Every animation uses one of `--t-instant / --t-quick / --t-base / --t-slow / --t-march`.
- [ ] Every translate uses `var(--reveal)` (8px) or 0.
- [ ] No `transform: scale()` on hover.
- [ ] No `animation: ... infinite` except the three sanctioned ambients (telemetry, ticker, logomark nudge).
- [ ] `prefers-reduced-motion: reduce` has been tested and the page still tells the same story.
- [ ] Total page-load motion budget ≤ 800ms.

If any box is unchecked, the surface is not shipping with motion — it ships static, and the motion lands in a follow-up.

---

## File map

- `motion.css` — the tokens (`--ease`, `--t-*`, `--reveal`) and the four primitive keyframes. Imported by `colors_and_type.css`.
- `motion.html` — live, side-by-side reference for every primitive, every timing token, the reduced-motion fallback, and the explicit-forbidden examples (shown crossed out).
- `ui_kits/_system/*` — every existing system module already pulls from these tokens. No module hardcodes a duration.

If you change a token in `motion.css`, every DF4 surface inherits the change at the next deploy. If you change a primitive's keyframe, you have changed the doctrine — escalate.
