Design system · public

How motion behaves here.

BuybackAI sells "buying back time," so motion has to feel calm, deliberate, never frantic. This page is the source of truth — every component, easing curve, duration, and stagger value lives here. Built in public.

Overview

A unified motion system for the BuybackAI SaaS — landing, auth, onboarding, audit, dashboard, agents. The product sells "buying back time," so motion must feel calm, deliberate, and confident: never frantic, never decorative. Motion communicates state changes (an agent took an action), not personality.

Design Principles

Timing & Easing — global tokens

TokenValueUse
--ease-outcubic-bezier(0.16, 1, 0.3, 1)Default for entrances, hovers, page transitions
--ease-incubic-bezier(0.7, 0, 0.84, 0)Exits — fast acceleration off-screen
--ease-springcubic-bezier(0.34, 1.4, 0.64, 1)Reserved for "agent took action" confirmations only — slight overshoot
--ease-linearlinearPulse loops, progress bars
--dur-instant120msHover, focus, tap feedback
--dur-quick220msMicro UI: dropdowns, popovers, button states
--dur-base380msSection reveals, card entrances
--dur-slow680msHero entrance, audit-result reveal
--dur-pulse2400msLive-status pulse loop
--stagger70msDefault child stagger

Motion vocabulary (per surface)

1. Landing page (/)

Hero — left column slides up 14px + fades in over 680ms. Right column (audit-result card) starts at opacity: 0; translateY(24px), animates in 80ms after the headline finishes its first 60% (so they don't fight for attention).

Pull-quote — fades in (no transform) when 30% in viewport. Slow: 800ms.

Section eyebrows — typewriter-style char-by-char reveal at 28ms/char on first viewport entry. One time only.

Live-status dot (.pulse-soft) — opacity loop 0.5 → 1 → 0.5 over 2400ms, linear. Never stops.

Agent cards (3-card row) — staggered entrance 70ms each, translateY(16px) + opacity. On hover: hairline border brightens from rgba(255,255,255,0.06) to rgba(255,255,255,0.14) over 120ms; price chip scales 1 → 1.02 over 220ms (subtle, not bouncy).

CTA buttons — hover: background lightens 8% over 120ms. Active (mousedown): scale 0.98 over 80ms.

Audit-result card in hero — numbers tween in via requestAnimationFrame counter from 0 to target value over 1200ms with ease-out. Dollar values format with thousands separators as they tick.

2. Auth (/auth/login, /auth/signup)

Form entrance — single fade-up 380ms. No stagger; auth should feel terminal, not theatrical.

Submit state — button text crossfades to spinner (a single rotating hairline ring, 800ms linear loop). On success, button background shifts to accent and ✓ scales in via spring (0.34, 1.4, 0.64, 1) for 380ms, then route transition.

Error shake — 4px horizontal translate, 3 cycles, 60ms each, ease-out. Red border fades over 220ms.

3. Onboarding (/onboarding)

Step transitions — current step slides out left (-24px, 220ms ease-in), next step slides in from right (+24px → 0, 380ms ease-out, 60ms delay).

Master-prompt generation — token-streaming reveal: each token appended with opacity 0 → 1 over 80ms. Cursor blinks at 1Hz while streaming. When complete, the cursor fades out over 220ms.

Progress bar — width tweens linear, no spring. Width is bound to currentStep / totalSteps; transitions width 380ms cubic-bezier(0.16, 1, 0.3, 1).

4. Audit (/audit)

The audit is the product's "wow moment." It deserves the most motion budget.

Connecting state — terminal-style log lines append every 240–600ms (jittered to feel real, not metronomic). Each line: opacity 0 → 1 over 120ms + cursor advance.

Categorizing — accent shimmer travels left-to-right across the row being processed. 1200ms linear loop. Stops when row completes; checkmark scales in 220ms ease-spring.

Reveal of results — once categorization completes, the result card's blur drops (backdrop-filter: blur(20px) → blur(0)) over 680ms while the underlying numbers animate up from 0 in parallel.

Recoverable dollar amount — animates last (200ms after rows complete), counter from 0 → target over 1400ms, ease-out. Color: #d4ff3a. Subtle pulse (opacity 0.85 → 1 → 0.85) loops 3 times then settles.

5. Dashboard (/dashboard)

Agent cards (deployed) — entrance staggered 70ms. Each card has a "live" indicator (pulse dot) and a metrics row (hours saved, tasks completed) that ticks up with each successful cron run.

Action queue (drafts waiting) — new items push in from top, existing items translate down. Use FLIP (First-Last-Invert-Play): measure before, measure after, animate the delta. 380ms ease-out, max 6 simultaneous animations.

Approve / dismiss — approve: row glows accent for 220ms, then height collapses to 0 over 380ms ease-in, padding/margin collapse with it. Dismiss: same height collapse but no glow.

Empty states — center-aligned, 1.0 opacity, no animation on entrance (it's an absence, not an arrival).

Real-time notification ("Inbox Ivy drafted 3 replies") — toast slides up from bottom-right, +20px → 0, 380ms ease-out. Auto-dismiss after 4.8s with 220ms ease-in slide-down + fade.

6. Agents marketplace (/agents)

Card grid — 3-column, staggered diagonal entrance: row 1 left-to-right (70ms stagger), row 2 60ms after row 1 finishes.

Hover state — card lifts via translateY(-2px) + box-shadow deepens over 220ms ease-out. Hairline brightens. Inner "deploy" CTA fades in (it's hidden until hover) over 220ms.

Deploy click — card scales to 0.98 for 80ms (acknowledge press), then back to 1.0 with spring, accent border traces the perimeter over 880ms (CSS conic-gradient + mask trick), and the card transitions into the dashboard view.

Microinteraction copy

Every animation has a "tone" instruction for designers/devs to maintain consistency:

States & transitions

Audit run

Agent deployment

Form submission (auth, onboarding)

Implementation Guide

Global CSS tokens — drop in globals.css

:root {
  --ease-out: cubic-bezier(0.16, 1, 0.3, 1);
  --ease-in: cubic-bezier(0.7, 0, 0.84, 0);
  --ease-spring: cubic-bezier(0.34, 1.4, 0.64, 1);
  --dur-instant: 120ms;
  --dur-quick: 220ms;
  --dur-base: 380ms;
  --dur-slow: 680ms;
}

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 120ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 120ms !important;
    scroll-behavior: auto !important;
  }
  .reveal, .reveal-2 { transform: none !important; }
}

@keyframes reveal-up {
  from { opacity: 0; transform: translateY(14px); }
  to   { opacity: 1; transform: translateY(0); }
}

.reveal   { animation: reveal-up var(--dur-slow) var(--ease-out) both; }
.reveal-2 { animation: reveal-up var(--dur-slow) var(--ease-out) 200ms both; }

.pulse-soft {
  animation: pulse-soft var(--dur-pulse, 2400ms) linear infinite;
}
@keyframes pulse-soft {
  0%, 100% { opacity: 0.5; }
  50%      { opacity: 1.0; }
}

.stagger > * { opacity: 0; animation: reveal-up var(--dur-base) var(--ease-out) both; }
.stagger > *:nth-child(1) { animation-delay: 0ms; }
.stagger > *:nth-child(2) { animation-delay: 70ms; }
.stagger > *:nth-child(3) { animation-delay: 140ms; }
.stagger > *:nth-child(4) { animation-delay: 210ms; }
.stagger > *:nth-child(5) { animation-delay: 280ms; }
.stagger > *:nth-child(6) { animation-delay: 350ms; }

Framer Motion — primitives

// src/components/motion.tsx
"use client";
import { motion, useReducedMotion, type Variants } from "framer-motion";

export const ease = [0.16, 1, 0.3, 1] as const;
export const easeIn = [0.7, 0, 0.84, 0] as const;
export const spring = { type: "spring", stiffness: 320, damping: 26, mass: 0.8 } as const;

export const fadeUp: Variants = {
  hidden: { opacity: 0, y: 14 },
  show:   { opacity: 1, y: 0, transition: { duration: 0.68, ease } },
};

export const stagger: Variants = {
  hidden: {},
  show: { transition: { staggerChildren: 0.07, delayChildren: 0.1 } },
};

export function Reveal({ children, delay = 0 }: { children: React.ReactNode; delay?: number }) {
  const reduce = useReducedMotion();
  return (
    <motion.div
      initial={reduce ? "show" : "hidden"}
      whileInView="show"
      viewport={{ once: true, margin: "-10%" }}
      variants={fadeUp}
      transition={{ delay }}
    >
      {children}
    </motion.div>
  );
}

export function Counter({ value, prefix = "", suffix = "" }: { value: number; prefix?: string; suffix?: string }) {
  // implementation ticks 0 → value via rAF over 1200ms ease-out
  // (full impl in src/components/AnimatedCounter.tsx)
  return <span>{prefix}{value.toLocaleString()}{suffix}</span>;
}

Audit "shimmer" row — Three.js-free, GPU-only CSS

.shimmer {
  position: relative;
  overflow: hidden;
}
.shimmer::after {
  content: "";
  position: absolute; inset: 0;
  background: linear-gradient(
    90deg,
    transparent 0%,
    rgba(212, 255, 58, 0.08) 50%,
    transparent 100%
  );
  transform: translateX(-100%);
  animation: shimmer-pass 1200ms linear infinite;
  pointer-events: none;
}
@keyframes shimmer-pass {
  to { transform: translateX(100%); }
}

Three.js — background "orb field" (already exists in src/components/OrbField.tsx)

// principle: 30 low-opacity spheres drifting on z-axis sine curves
// camera locked, no user interaction, GPU-only
// frame budget: < 1.6ms/frame on mid-tier laptop
// pause when document.hidden = true (don't drain battery on backgrounded tabs)

Performance gates for OrbField:

Animation timeline — the audit reveal (the product's signature moment)

Frame 0ms:    User clicks "Run audit"
Frame 80ms:   Button confirms (scale 0.98 → 1.0 spring)
Frame 240ms:  Card eyebrow swaps "READY" → "CONNECTING" (220ms crossfade)
Frame 480ms:  Terminal line 1 appended ("> connecting gmail.aidan@... ✓")
Frame 920ms:  Line 2 ("> reading 14d window ........ 487 threads")
Frame 1320ms: Line 3 ("> reading calendar 14d ...... 62 events")
Frame 1840ms: Line 4 ("> categorizing with claude .. ") + spinner appended
Frame 1840–4200ms:  Real Claude call. While streaming, dots cycle "..." every 320ms.
Frame 4200ms: Line 4 completes ("done"). Line 5 appends.
Frame 4500ms: Result card BEGINS reveal — backdrop blur drops, rows shimmer in.
Frame 4500ms: Row 1 enters with shimmer pass (1200ms loop, 1 cycle then ends).
Frame 4640ms: Row 2 enters (stagger +140ms behind row 1).
Frame 4780ms: Row 3 enters.
Frame 4920ms: Row 4 enters.
Frame 5300ms: All rows resolved. Recoverable dollar amount begins ticking.
Frame 5300–6700ms: Counter from $0 → $3,420. Accent color, ease-out.
Frame 6700ms: Counter pulse starts (3 cycles, then settles).
Frame 8800ms: All motion concluded. UI is at rest.

Total: ~8.8s for the full reveal. Feels like ~3s subjectively because each beat lands when expected.

Technical notes

Handoff notes


This spec is the source of truth. When in doubt, refer back. When implementation deviates, update the spec — never let code and spec drift.