Every animation in Outlex earns its place. This lab documents the four allowed motion categories, the zero-animation list, exact timing tokens, and live replayable demos — each with its snippet.
Premium motion is mostly restraint plus precision. Below 300ms. GPU-compositable properties only. Frequency-gated — animate less as usage frequency increases.
prefers-reduced-motion: reduce
does not mean zero animation — it means
no spatial movement.
Opacity fades that aid comprehension are preserved. All duration tokens collapse to 0.01ms.
Click any replay button to re-trigger. Snippets collapsed below each demo.
/* -- ENTER: slow generous arrive -- */ @keyframes box-enter { from { opacity: 0; transform: translateY(16px) scale(0.95); } to { opacity: 1; transform: translateY(0) scale(1); } } @keyframes box-exit { from { opacity: 1; transform: translateY(0) scale(1); } to { opacity: 0; transform: translateY(-8px) scale(0.97); } } .element.entering { animation: box-enter 220ms var(--ox-ease) both; /* --ox-enter */ } .element.exiting { animation: box-exit 130ms var(--ox-ease) both; /* --ox-exit */ } /* Ratio: exit = 130/220 = 0.59 ≈ "exit is 1.7× faster" */ /* Principle: Emil Kowalski — "exit half the duration of enter" */
/* --ox-ease-drawer: cubic-bezier(0.32, 0.72, 0, 1) — iOS drawer feel */ .drawer { transform: translateY(100%); transition: transform 350ms var(--ox-ease-drawer); } .drawer.open { transform: translateY(0); } /* Linear/Vaul signature: background scales behind open drawer */ .page-behind { transition: transform 350ms var(--ox-ease-drawer), border-radius 350ms var(--ox-ease-drawer); } .page-behind.drawer-open { transform: scale(0.96) translateY(-8px); border-radius: 12px; }
/* Shadow on ::after — avoids box-shadow paint on hover */ .card { transition: transform 160ms var(--ox-ease); /* transform only */ will-change: transform; } .card::after { content: ''; position: absolute; inset: 0; border-radius: inherit; box-shadow: 0 8px 24px rgba(0,0,0,0.45), 0 20px 60px rgba(0,0,0,0.30); opacity: 0; transition: opacity 220ms ease; /* shadow trails transform */ pointer-events: none; } /* Gate hover on true pointer devices — mobile taps fire :hover */ @media (hover: hover) and (pointer: fine) { .card:hover { transform: translateY(-4px); } .card:hover::after { opacity: 1; } } .card:active { transform: translateY(1px) scale(0.98); transition-duration: var(--ox-press); /* 80ms */ }
Outlex · Legal Intelligence Platform · Lisboa, Portugal
function staggerReveal(el, text, startDelay = 0) { el.innerHTML = text .split('') .map(ch => { const c = ch === ' ' ? ' ' : ch; return `<span class="stagger-char hidden">${c}</span>`; }) .join(''); [...el.querySelectorAll('.stagger-char')].forEach((span, i) => { const delay = Math.min(startDelay + i * 40, startDelay + 200); setTimeout(() => span.classList.replace('hidden', 'visible'), delay); }); } /* CSS on .stagger-char.visible: */ /* transition: opacity 400ms ease, transform 400ms var(--ox-ease) */ /* .hidden: opacity:0; transform: translateY(110%); */ /* .visible: opacity:1; transform: translateY(0); */ /* Parent .stagger-line must have overflow:hidden */
/* Teal dot pulse — class-driven, removed when done */ .lexi-card.streaming .lexi-stream-dot { animation: lexi-dot-pulse 1.2s ease-in-out infinite; } @keyframes lexi-dot-pulse { 0%, 100% { background: var(--ox-ai-pulse-lo); } 50% { background: var(--ox-ai-pulse-hi); box-shadow: 0 0 6px 2px rgba(84,214,164,0.35); } } /* --ox-ai-pulse-lo: rgba(84,214,164,0.30) */ /* --ox-ai-pulse-hi: rgba(84,214,164,0.70) */ /* Streaming cursor */ .lexi-cursor { display: inline-block; width: 2px; height: 1em; background: var(--ox-ai-fill); vertical-align: text-bottom; animation: cursor-blink 1s ease-in-out infinite; } @keyframes cursor-blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } } /* Remove .streaming class AND cursor when done streaming */
/* Warm-tinted shimmer — matches Outlex paper tones, not neutral grey */ .sk { background: linear-gradient( 115deg, /* slightly diagonal — more premium */ oklch(0.22 0.010 270deg) 25%, oklch(0.26 0.010 270deg) 50%, oklch(0.22 0.010 270deg) 75% ); background-size: 200% 100%; animation: shimmer 1.5s infinite; } /* Light theme: use oklch(0.93 0.007 75deg) → oklch(0.95 0.007 75deg) */ @keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } } /* Reduced motion: replace shimmer with gentle opacity pulse */ @media (prefers-reduced-motion: reduce) { .sk { animation: sk-pulse 2s ease-in-out infinite; background: oklch(0.22 0.010 270deg); } @keyframes sk-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.55; } } }
/* Vanilla JS spring ticker — no library required */ function animateTicker(el, from, to, stiffness = 280, damping = 30) { let current = from, velocity = 0, raf; const mass = 1, dt = 1 / 60; const tick = () => { const spring = -stiffness * (current - to); const dampF = -damping * velocity; const accel = (spring + dampF) / mass; velocity += accel * dt; current += velocity * dt; el.textContent = Math.round(current).toLocaleString('pt-PT'); if (Math.abs(current - to) > 0.5 || Math.abs(velocity) > 0.5) { raf = requestAnimationFrame(tick); } else { el.textContent = to.toLocaleString('pt-PT'); /* snap to target */ } }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); } /* Usage: animateTicker(document.querySelector('#counter'), 0, 1247) */ /* Reduce motion: skip animation, set value directly */
/* Orb container: position:fixed, z-index:0, pointer-events:none */ .orb { position: absolute; border-radius: 50%; filter: blur(var(--ox-orb-blur)); /* 120px */ animation: orb-pulse var(--ox-orb-cycle) ease-in-out infinite; /* 8s */ pointer-events: none; } @keyframes orb-pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.7; transform: scale(1.08); } } .orb-sage { background: radial-gradient(circle, var(--ox-orb-sage-color) 0%, transparent 70%); animation-delay: 0s; } .orb-blue { background: radial-gradient(circle, var(--ox-orb-blue-color) 0%, transparent 70%); animation-delay: 2.7s; } .orb-amber { background: radial-gradient(circle, var(--ox-orb-amber-color) 0%, transparent 70%); animation-delay: 5.3s; } /* Content z-index must be > 0 to sit above orbs */
The theme toggle in the topbar IS this demo. When
document.startViewTransition()
is available, the entire page cross-fades with the asymmetric timing
below. The old page exits at 130ms, the new theme enters at 220ms.
On browsers without View Transitions API support, the theme toggle
still works — CSS token swaps are instant.
/* JS — wrap data-theme toggle in startViewTransition() */ toggleBtn.addEventListener('click', () => { const next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark'; if (!document.startViewTransition) { document.documentElement.dataset.theme = next; /* fallback */ return; } document.startViewTransition(() => { document.documentElement.dataset.theme = next; localStorage.setItem('ox-theme', next); }); }); /* CSS — asymmetric timing on view transition pseudo-elements */ ::view-transition-old(root) { animation: 130ms ease-in-out both vt-fade-out; /* --ox-exit */ } ::view-transition-new(root) { animation: 220ms cubic-bezier(0.23, 1, 0.32, 1) both vt-fade-in; /* --ox-enter */ } @keyframes vt-fade-out { from { opacity: 1; } to { opacity: 0; } } @keyframes vt-fade-in { from { opacity: 0; } to { opacity: 1; } }
Premium legal software signals trust through restraint. The zero-animation list is as important as the animation list. Frequency-gated: animate less as usage frequency increases.
| Element | Frequency | Reason |
|---|---|---|
| Table row content, text in cells | 100+/day | Any delay feels like latency, not polish. Document tables used continuously. |
| Keyboard shortcuts (⌘K, ⌘/, bulk actions) | 100+/day | Power users trigger these constantly. 0ms is the only correct answer. |
| Sidebar navigation items | dozens/session | Used every session multiple times. No hover lift, no transition. |
| Filter / sort changes on lists | dozens/session | Data updates should feel instant. Animation implies latency. |
| Document viewer content (legal text) | reading mode | Motion competes with reading focus. Zero motion in all reading contexts. |
| Contract clauses, RGPD article text | reading mode | Legal text demands full attention. Any motion is a distraction. |
| Error states and validation messages | urgency | Urgency signals should appear instantly. Animation delays user response. |
| Form field focus in legal forms | functional | Focus is a functional signal, not a decorative moment. |
| Price and billing numbers (confirmed state) | trust context | Numbers must feel stable and certain. Counters on confirmed values erode trust. |
| Loading % progress text | data | Progress data should snap to reality, not animate to it. Use progress bars instead. |
| Element | Allowed motion | Why it earns animation |
|---|---|---|
| Modal open | scale(0.97)→1, opacity 0→1, 220ms |
First-time action per user intent — earns arrival. |
| Drawer / bottom sheet | translateY(100%)→0, 350ms spring |
Spatial orientation — user needs to register where content came from. |
| Toast / notification | translateY(-100%)→0, 400ms ease |
Rare event, needs to announce itself without being jarring. |
| Card hover lift | translateY(-4px), 160ms |
Affordance signal — communicates interactivity at browse frequency. |
| Button press | scale(0.97), 80ms |
Tactile confirmation of every intentional action. |
| Skeleton → content reveal | opacity 0→1, 200ms |
Loading resolution — gentle transition so content doesn't "pop". |
| Lexi streaming — left border | border-left-color pulse 1.2s |
Active AI process signal — amber whisper communicates "working". |
| Plan upgrade success | celebration animation, 400–600ms |
Rare, high-value moment — delight is earned here. |
| Credit balance change | number spring ticker, ~300ms |
Makes cost of AI use tangible — reinforces real-world value. |