v2 · Precision + Atmosphere — active since 2026-06-11 · v1/V7 legacy applies only to unmigrated app surfaces → /v1/  ·  agent guide
Outlex Design System v2

Motion with
restraint

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.

View Demos Motion Budget
§ 00 — Motion Budget

Four categories, one principle

Premium motion is mostly restraint plus precision. Below 300ms. GPU-compositable properties only. Frequency-gated — animate less as usage frequency increases.

enter — new content arrives
--ox-enter 220ms
--ox-ease cubic-bezier(0.23, 1, 0.32, 1)
from opacity:0, translateY(12px) scale(0.95)
use for modals, drawers, toasts, route enter
exit — old content departs
--ox-exit 130ms
ratio 1.7× faster than enter
to opacity:0, translateY(-8px) scale(0.97)
principle user is done with it — get out of the way
press — tactile confirmation
--ox-press 80ms
easing ease (CSS built-in)
transform scale(0.97) translateY(1px)
guard @media (hover: hover) and (pointer: fine)
spring — physics delight
--ox-ease-spring cubic-bezier(0.34, 1.56, 0.64, 1)
overshoot max 1.56 — legal context cap
use sparingly CTA hover lifts, icon reveals
never document/case/reading contexts
ambient — orb pulse (background only)
--ox-orb-cycle 8s ease-in-out infinite
animation opacity 1→0.7, scale 1→1.08
blur --ox-orb-blur: 120px
pointer-events none — never interactive
prefers-reduced-motion discipline Mandatory

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.

Disable under reduced-motion
translateY, translateX, rotate
scale changes > 5%
shimmer sweep (replace with pulse)
page transitions with displacement
orb pulse animation
Safe to keep short (50ms)
opacity fades (state changes)
color transitions (hover feedback)
scale(1 → 1) + other property
focus ring appearances
skeleton pulse (opacity only)
§ 01 — Live Demos

Every pattern, replayable

Click any replay button to re-trigger. Snippets collapsed below each demo.

Asymmetric Enter / Exit --ox-enter 220ms · --ox-exit 130ms
Enter — 220ms
Exit — 130ms
41% faster
GDPR Art. 28
enter 220
exit 130
ratio 1.7×
/* -- 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" */
Spring Drawer — background scale effect --ox-ease-drawer 350ms · scale(0.96)
Acordo de Assessoria Jurídica
Outlex Ltda. · CNPJ 12.345.678/0001-90
Pronto para assinar Revisão CNPD
Detalhes do Contrato
Art. 28 RGPD — Subcontratante de dados. Outlex Ltda. atua como responsável pelo tratamento em conformidade com o Regulamento (UE) 2016/679. Período de retenção: 5 anos. Supervisão: CNPD (Comissão Nacional de Proteção de Dados).
/* --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;
}
Card Hover Physics — shadow on pseudo-element 160ms transform · shadow opacity: GPU-only
RGPD Art. 28
Subcontratante de dados
Análise Lexi
DGSI · Tribunal Rel.
CNPD Conformidade
Parecer nº 2024/017
/* 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 */
}
Letter-Stagger Display Reveal 40ms per char · max 200ms total

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 */
Lexi Streaming — teal dot pulse + cursor blink dot 1.2s ease-in-out · cursor 1s · neutral card
Lexi
Nota: O Art. 28 do RGPD exige que o subcontratante implemente medidas técnicas e organizativas adequadas. Em conformidade com o Parecer CNPD n.º 2024/017, a Outlex Ltda. deve assegurar a eliminação ou devolução dos dados pessoais no prazo estabelecido.
Confiança 73%
/* 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 */
Skeleton Shimmer — warm-tinted, not grey 1.5s · 115deg diagonal · reduced-motion → pulse
/* 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; }
  }
}
Number Ticker — credit balance, metrics rAF spring · stiffness 280 damping 30
0
créditos Lexi
+350 este mês
0
documentos
+12 esta semana
0
consultas DGSI
+89 30 dias
/* 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 Ambient Pulse — the page backdrop is the demo 8s cycle · 3 orbs · 120px blur · fixed position
Sage Orb
600×500px · top:-100px left:-200px
oklch(0.60 0.10 150deg / 0.18)
delay: 0s
Blue Orb
700×600px · top:100px right:-250px
oklch(0.57 0.15 270deg / 0.14)
delay: 2.7s
Amber Orb
500×400px · bottom:200px left:30%
oklch(0.70 0.14 75deg / 0.10)
delay: 5.3s
/* 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 */
View Transition Cross-fade — the toggle is the demo exit 130ms · enter 220ms · asymmetric

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; } }
§ 02 — Zero Animation List

What never moves

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.

High-frequency interactions — never animate Emil Kowalski frequency rule
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.
Allowed with explicit justification Low frequency, meaningful actions
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.