Motion
Four curves, a press affordance on everything, and the anti-blank-screen rule.
Short transitions (120–350 ms). Never gratuitous. Always under
prefers-reduced-motion: no-preference.
The curve vocabulary
Click Replay — the difference between "gentle" and "spring" is the whole point.
--ease-gentlecubic-bezier(.2,.7,.3,1)Gentle deceleration — page/popover entrances, general transitions.
--ease-springcubic-bezier(.34,1.56,.64,1)Overshoot spring — menu/modal pops, gauges, slider thumbs. The Titan signature.
--ease-shakecubic-bezier(.36,.07,.19,.97)Shake — invalid input.
| Curve | Role |
|---|---|
cubic-bezier(.2,.7,.3,1) | Gentle deceleration — page/popover entrances, general transitions |
cubic-bezier(.34,1.56,.64,1) | Overshoot spring — menu/modal pops, gauges, slider thumbs. The Titan signature |
cubic-bezier(.36,.07,.19,.97) | Shake — invalid input |
ease / ease-out | Short colour/opacity transitions |
Universal interactions
Every interactive element gets the same transition set and the same press:
button, a, [role="button"], input {
transition: transform .14s cubic-bezier(.2,.7,.3,1),
background-color .15s ease, color .15s ease, border-color .15s ease,
box-shadow .2s ease, filter .2s ease, opacity .2s ease;
}
button:active, a:active, [role="button"]:active {
transform: scale(.96); /* the Apple press */
}| Pattern | Effect |
|---|---|
| Press | scale(.96) on every interactive element |
| Material icon on hover | scale(1.08–1.12), .28s spring. Settings gear → rotate(70deg) |
Primary CTA (.titan-cta) | hover brightness(1.12), active scale(.98) |
Card (.card-lift) | hover translateY(-2px) + border-color: var(--border-strong) |
Table row (.row-hover) | hover background: var(--hover) |
Connect button (.btn-chrome) | animated 4s shimmer |
The segmented control's thumb uses the same vocabulary — the active item drops back to
--surface on a --surface-3 track:
Named keyframes
| Animation | Duration | Usage |
|---|---|---|
titan-page-in | .35s | Route entrance (translateY 8px → 0) |
wp-in | .15–.22s | Popover / menu entrance (translateY -6px + scale .98) |
kbd-modal-in / scaleIn | .26–.28s | Modal entrance (scale .96 → 1) |
titan-flash-up/down | .55s | Price glow on tick |
dt-drawer-shake | .36s | Invalid amount |
dt-drawer-hint | 2.6s ∞ | Slide-to-confirm handle pulse |
diamond/obsidianShimmer | 4s ∞ | Connect button |
tlpLivePulse / wp-pulse | ∞ | "Live" halo |
The anti-blank-screen rule
Page entrances never touch opacity. titan-page-in translates only — translateY(8px) → 0,
opacity pinned at 1.
A frozen-animation screenshotter (visual regression, an OG image renderer, a crawler) captures
frame zero. If frame zero is opacity: 0, every screenshot of the app is a blank page. Translate
costs nothing perceptually and makes that failure impossible.
Reduced motion
Everything lives under @media (prefers-reduced-motion: no-preference). Under reduce, durations
drop to ~0.01ms.
This is not optional politeness — a trading terminal with constantly-updating rows is exactly the kind of interface that triggers vestibular symptoms.
@media (prefers-reduced-motion: no-preference) {
.titan-page-in { animation: titan-page-in .35s var(--ease-gentle); }
}The demo above respects it: with reduce active, the dot renders at its end position and the page
says so.