Swoosh/docs/superpowers/specs/2026-03-19-micro-animations-design.md
Paweł Orzech 163d6596c9
docs: update micro-animations spec after review
Address all review findings: fix Bouncy spring damping (0.55→0.65),
add BouncyQuick for press feedback, document stagger pattern and
already-animated tracking, add reduced motion support, fix nav
transitions with popEnter/popExit, add disconnect confirmation
dialog as new behavior, and add PulsingPlaceholder component.
2026-03-19 10:33:20 +01:00

12 KiB

Micro-Animations Design — Swoosh

Date: 2026-03-19 Status: Approved Style: Expressive & playful — bouncy springs, overshoot, lively feedback

Overview

Add 19 micro-animations + 5 navigation transitions across all screens to make the app feel alive. Create a shared SwooshMotion object with predefined animation specs ensuring consistent character throughout the app.

Shared Animation Specs — SwooshMotion

Central object in a new file ui/animation/SwooshMotion.kt providing reusable animation specifications:

Name Type Parameters Use case
Bouncy Spring dampingRatio=0.65, stiffness=400 FAB entrance, chips, badges
BouncyQuick Spring dampingRatio=0.7, stiffness=1000 Press feedback (settles in ~150ms)
Snappy Spring dampingRatio=0.7, stiffness=800 Expand/collapse, dialogs
Gentle Spring dampingRatio=0.8, stiffness=300 Cards, content reveal
Quick Tween 200ms, FastOutSlowInEasing Fade, color transitions
StaggerDelay Offset 50ms per item List item entrances

Reduced Motion Support

SwooshMotion exposes val isReducedMotion: Boolean that reads the system accessibility setting (Settings.Global.ANIMATOR_DURATION_SCALE). When true, all spring specs fall back to snap() (instant) and all tweens use 0ms duration. Every animation in this spec must route through SwooshMotion so the reduced-motion check is centralized.

Stagger Pattern

For staggered entrance animations (#3, #13, #14), each item uses a local MutableState<Boolean> controlled by a LaunchedEffect:

val visible = remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
    delay(SwooshMotion.StaggerDelay * index)
    visible.value = true
}
AnimatedVisibility(visible = visible.value, ...) { ... }

AnimatedVisibility has no built-in delay parameter — this pattern is the canonical approach.

Already-Animated Tracking (LazyColumn)

For staggered items inside LazyColumn (#3), items re-enter composition on scroll. To prevent replaying the entrance animation, hoist a mutableStateSetOf<String>() of already-animated item keys in FeedScreen:

val animatedKeys = remember { mutableStateSetOf<String>() }
// Inside each item:
val shouldAnimate = post.id !in animatedKeys
LaunchedEffect(Unit) {
    if (shouldAnimate) {
        delay(SwooshMotion.StaggerDelay * index)
        animatedKeys.add(post.id)
    }
    visible.value = true  // instant if already animated
}

Only animate the first batch visible on initial load (cap at ~8 items). Items appended via infinite scroll appear without stagger.

Feed Screen — 7 animations

1. FAB entrance (SPRING/Bouncy)

When the feed screen opens, the FAB scales from 0 to 1 with Bouncy spring. One visible overshoot — the FAB "pops" onto screen.

  • Compose API: animateFloatAsState with Bouncy spring on scale modifier
  • Trigger: LaunchedEffect(Unit) sets target to 1f

2. FAB press (SPRING/BouncyQuick)

On tap, FAB shrinks to 85% and snaps back to 100%. Must settle in ~150ms so it completes before navigation fires.

  • Compose API: Modifier.pointerInput detecting press → animateFloatAsState scale 0.85f → 1f
  • Spring spec: BouncyQuick (dampingRatio=0.7, stiffness=1000)
  • Navigation fires immediately on tap — the spring animation is interrupted by the screen transition, which is fine

3. Post cards staggered entrance (SLIDE/Gentle)

Cards slide in from bottom with cascading delay — 50ms per item. "Waterfall" effect.

  • Compose API: AnimatedVisibility per item with slideInVertically + fadeIn using Gentle spring
  • Delay: Stagger pattern (see shared section). Capped at first ~8 visible items
  • Already-animated tracking via animatedKeys set (see shared section)
  • Items appended via infinite scroll appear without stagger (instant visible = true)

4. "Show more" expand (SPRING/Snappy)

Card content transitions between truncated and full text with animated height change.

  • Compose API: AnimatedContent(targetState = expanded) with fadeIn + expandVertically / fadeOut + shrinkVertically as content transform
  • Height spring: Snappy
  • This replaces the current discrete text swap with a smooth animated transition

5. Empty state (FADE/Quick + scale)

"No posts yet" icon and text fade in with subtle scale from 0.9 to 1.0.

  • Compose API: AnimatedVisibility with fadeIn + scaleIn(initialScale = 0.9f)

6. Queue status chip (BOUNCE/Bouncy)

During upload: chip pulses (infinite alpha animation 0.6→1.0). On status change (success/fail): bounce scale + color crossfade. On FAILED: shake animation (horizontal offset oscillation) to draw attention.

  • Compose API: rememberInfiniteTransition for pulse; animateColorAsState + scale bounce on status change
  • Shake on FAILED: Animatable with animateTo offset -4dp → 4dp → 0dp (3 oscillations)
  • Color transition: Quick tween
  • Note: Each chip in UPLOADING state creates its own rememberInfiniteTransition. If many posts queue simultaneously, this is fine for ~5 items but could cause jank beyond that (unlikely in normal use)

7. Snackbar error (SLIDE/Snappy)

Slides in from bottom with slight overshoot. Fades out on timeout.

  • Compose API: AnimatedVisibility with slideInVertically(initialOffsetY = { it }) + Snappy spring
  • Exit: fadeOut with Quick tween

Composer Screen — 7 animations

8. Image preview (SCALE/Bouncy)

After picking an image, the preview scales from 0 with bouncy spring. Close button fades in with scale.

  • Compose API: AnimatedVisibility with scaleIn using Bouncy spring
  • Close button: fadeIn + scaleIn(initialScale = 0.5f) — no rotation (an "X" icon should feel stable and immediately tappable)

After link loads, card slides up from below + fades in. While loading: pulsing placeholder card.

  • Compose API: AnimatedVisibility with slideInVertically + fadeIn using Gentle spring
  • Loading state: Replace current LinearProgressIndicator with a pulsing placeholder card using rememberInfiniteTransition on alpha (0.3→0.7). Simpler than a full shimmer gradient — just a fading placeholder shape

10. Schedule chip (SPRING/Bouncy)

After picking date, chip pops in with bouncy spring.

  • Compose API: AnimatedVisibility with scaleIn using Bouncy

11. Publish button (BOUNCE/BouncyQuick)

Subtle bounce on activation. During publishing: loading pulse. On success: checkmark icon scales in.

  • Compose API: Scale bounce on click via animateFloatAsState with BouncyQuick; rememberInfiniteTransition for pulse; AnimatedContent for icon swap with scaleIn

12. Character counter color (FADE/Quick)

Smooth color crossfade when exceeding 280 characters — neutral → red.

  • Compose API: animateColorAsState with Quick tween
  • Trigger: text.length > 280

13. Action buttons staggered entrance (SCALE/Gentle)

Draft, Schedule, Publish buttons — cascading scale-in with 50ms delay each.

  • Compose API: Stagger pattern (see shared section) with AnimatedVisibility + scaleIn + fadeIn

NEW: 13b. Error text (SLIDE/Snappy)

Composer error text slides in from left + fades in, consistent with snackbar style.

  • Compose API: AnimatedVisibility with slideInHorizontally + fadeIn using Snappy spring

Detail Screen — 4 animations

14. Content reveal (FADE/Gentle)

Elements appear sequentially: status badge → text → image → metadata. 80ms delay each.

  • Compose API: Stagger pattern (see shared section) with AnimatedVisibility per section using fadeIn + slideInVertically(initialOffsetY = { 20 })
  • Delay: 80ms per element (4 elements total = 320ms cascade)

15. Status badge entrance (SPRING/Bouncy)

Badge scales from 0 with bounce — first visible element, draws attention. Part of the content reveal sequence (index 0, no delay).

  • Compose API: animateFloatAsState scale with Bouncy spring

16. Delete confirmation dialog (SCALE/Snappy)

Dialog scales from center (0.8→1.0) with spring + backdrop fades in.

  • Compose API: Custom AnimatedDialog wrapper with scaleIn(initialScale = 0.8f) + fadeIn backdrop
  • Spring spec: Snappy

17. Metadata section (SLIDE/Gentle)

Bottom metadata slides up — last in the content reveal sequence (index 3, 240ms delay).

  • Compose API: Part of stagger pattern in #14

Settings Screen — 3 animations

18. "Settings saved" feedback (SPRING/Bouncy)

Existing "Settings saved" text (currently static conditional) gets animated: pops in with bounce (scale 0→1). After 2 seconds, fades out.

  • Compose API: AnimatedVisibility with scaleIn using Bouncy spring; LaunchedEffectdelay(2000) → hide
  • Exit: fadeOut with Quick tween
  • Text and color remain as-is: "Settings saved" with MaterialTheme.colorScheme.primary

19. Disconnect confirmation dialog (NEW behavior + FADE+SCALE/Snappy)

New behavior: Add a confirmation dialog before disconnect (currently the button directly clears credentials). The dialog uses the same AnimatedDialog wrapper as #16. "Disconnect" button has subtle red pulse.

  • Compose API: Same AnimatedDialog wrapper as #16
  • Red pulse: rememberInfiniteTransition on alpha of error color

NEW: 19b. Disconnect dialog behavior

The OutlinedButton currently calls credentials.clear() + onLogout() directly. After this change, it shows a confirmation dialog first. Confirm action triggers the existing clear + logout flow.

Navigation Transitions — 5

Each composable() call in NavGraph.kt receives enterTransition, exitTransition, popEnterTransition, and popExitTransition lambdas. Back navigation uses popEnterTransition/popExitTransition (the reverse of the enter animation) — these are distinct parameters, not automatic.

Feed → Composer

Slide up from bottom + fade.

  • enterTransition = slideInVertically(initialOffsetY = { it }) + fadeIn()
  • exitTransition = fadeOut()
  • popEnterTransition = fadeIn()
  • popExitTransition = slideOutVertically(targetOffsetY = { it }) + fadeOut()

Feed → Detail

Slide in from right + fade.

  • enterTransition = slideInHorizontally(initialOffsetX = { it }) + fadeIn()
  • exitTransition = fadeOut()
  • popEnterTransition = fadeIn()
  • popExitTransition = slideOutHorizontally(targetOffsetX = { it }) + fadeOut()

Detail → Composer (via Edit)

Same as Feed → Composer (slide up from bottom). The Composer always enters with the same transition regardless of source.

Feed → Settings

Standard slide from right.

  • enterTransition = slideInHorizontally(initialOffsetX = { it })
  • exitTransition = fadeOut()
  • popEnterTransition = fadeIn()
  • popExitTransition = slideOutHorizontally(targetOffsetX = { it })

Setup → Feed

Crossfade — smooth transition from animated setup background to feed.

  • enterTransition = fadeIn(tween(500))
  • exitTransition = fadeOut(tween(500))

File Structure

ui/
├── animation/
│   └── SwooshMotion.kt          # Shared animation specs object + reduced motion check
├── components/
│   ├── AnimatedDialog.kt        # Reusable animated dialog wrapper (used by #16, #19)
│   └── PulsingPlaceholder.kt    # Pulsing loading placeholder (used by #9)
├── feed/
│   └── FeedScreen.kt            # Modified: #1-#7
├── composer/
│   └── ComposerScreen.kt        # Modified: #8-#13, #13b
├── detail/
│   └── DetailScreen.kt          # Modified: #14-#17
├── settings/
│   └── SettingsScreen.kt        # Modified: #18-#19, #19b (new disconnect dialog)
└── navigation/
    └── NavGraph.kt              # Modified: navigation transitions with enter/exit/popEnter/popExit

New files: 3

  • ui/animation/SwooshMotion.kt
  • ui/components/AnimatedDialog.kt
  • ui/components/PulsingPlaceholder.kt

Modified files: 5

  • FeedScreen.kt, ComposerScreen.kt, DetailScreen.kt, SettingsScreen.kt, NavGraph.kt

Testing Strategy

  • Existing unit tests should pass unchanged (animations don't affect business logic)
  • Manual verification: each animation visually correct on emulator
  • Compose Preview for individual animated components where feasible
  • Test with system "Remove animations" setting enabled to verify reduced-motion fallback