A pass over Tally's core loop — check-off, tab switches, the add-habit sheet, and the streak milestones — read through three motion practitioners' lenses, ordered by what users feel most and the order to fix it.
Tally has real motion personality — but it's uneven. The highest-frequency action, the check-off, is the most over-animated; the add-habit sheet that should glide just snaps shut. Tighten the frequent moments toward speed, finish the half-built transitions, then spend delight where it's earned: the streaks.
Three lenses, weighted for this context — a frequently-opened utility where most motion should get out of the way, and a little should be memorable. The read is what each would push on hardest today.
| Lens | Verdict | One-line read |
|---|---|---|
| Restraint & Speed Emil KowalskiSecondary | Concern | The check-off and tab switches are over-animated for actions this frequent — durations run long. |
| Production Polish Jakub KrehelPrimary | Problem | Several transitions are half-built: enters without exits, state changes that snap. Craft is uneven. |
| Experimentation & Delight Jhey TompkinsSelective | Strong | Restraint is mostly right. The open prize is making the streak milestones actually feel earned. |
Duration is a budget. A 320ms sheet is correct — a large surface earns the time. A 600ms tab switch on an action you fire dozens of times a session is the mistake. Each dot is one of Tally's animations, plotted where it runs today.
The two dots furthest right — tab switch (1) and check-off (2) — are the actions that fire most. Frequency and duration are inversely related: the more often it runs, the faster it should be. And the streak counter (5) has no transition at all.
Problem
routes/transition.tsx:14ui/Toast.tsx:31WhatThe bottom sheet animates up on open (320ms, good), but on dismiss it's removed from the tree instantly — no exit. The component renders conditionally with no AnimatePresence wrapper, so Framer Motion never gets to play the exit.
Why it mattersA surface that glides in and vanishes reads as broken — the eye expects symmetry. It's the single most common "half-built motion" tell, and it's on the app's primary create flow.
Recommended motionWrap the sheet in AnimatePresence and mirror the enter: slide down + fade over 300ms with the same ease-out-quint. The demo shows the enter; the exit is its reverse.
screens/Habits/AddSheet.tsx:48
WhatWhen a streak increments, the number is replaced in place — a hard swap. There's no transition on the value change, so the most rewarding number in the app updates with the least ceremony.
Why it mattersThe streak count is the payoff of the whole interaction. A snap makes a hard-won number feel like a re-render, not an achievement.
Recommended motionRoll the new value in: opacity + 10px translateY + a 5px blur that clears, 220ms. Subtle, but it tells the eye something changed and it's good.
components/StreakBadge.tsx:22
components/HabitCard.tsx:9The vocabulary is right; the sentences are unfinished. Pair every enter with an exit, give the streak its moment, and Tally crosses from "animated" to "polished."
Concern
components/HabitCard.tsx:18WhatThe four bottom tabs cross-slide the full panel width over 600ms with an ease-in-out. Tabs are the app's highest-frequency navigation; a 600ms slide means every switch holds the user behind an animation.
Why it mattersEmil's rule: the more often an action fires, the less it should animate. At this duration the motion stops being feedback and becomes a toll. ease-in-out also adds a slow start, compounding the lag.
Recommended motionDrop to a 180ms opacity crossfade with a 7px slide, ease-out. Better still: consider no slide at all — a fast crossfade is plenty of orientation for a tab.
navigation/TabView.tsx:63
WhatTicking a habit animates the checkmark from scale(0) with a bouncy spring (~450ms to settle). It's the app's core, most-repeated gesture, and it's the showiest animation in the product.
Why it mattersBounce on a high-frequency confirm gets tiring fast — the overshoot draws attention to motion the user has already mentally completed. Starting from scale(0) exaggerates the distance and the time.
Recommended motionScale 0.9 → 1 with opacity, 200ms ease-out, no overshoot. Confident and done before the finger lifts.
components/HabitCheck.tsx:27
Speed up everything the user touches constantly. The tab switch and the check-off should feel instant; their current durations are the difference between an app that feels fast and one that feels fussy.
Strong
WhatCrossing a 7-, 30-, or 100-day streak looks identical to any other day — the number just increments. The one moment in the app that has genuinely earned a flourish gets none.
Why it mattersThis is where delight pays for itself. A milestone is rare, emotionally loaded, and shareable — exactly the place to spend motion the rest of the app withholds. Skipping it leaves the payoff flat.
Recommended motionOn a milestone only: a badge that scales 0.8 → 1 (260ms ease-out) with a short, one-shot sparkle burst. Fires once on the event — not a looping pulse.
components/StreakBadge.tsx:40
screens/Today.tsx:51Don't add more motion — add it in one right place. The streak milestone is the moment worth engineering; everywhere else, keeping your hands off the controls is the sophisticated move.
Ordered by what users feel: critical (degrades the core loop on every use) → important (real friction or a missed payoff) → opportunity (could enhance).
| Issue | File | Fix |
|---|---|---|
| Tab switch runs 600ms on the highest-frequency action | TabView.tsx:63 | 180ms opacity crossfade + 7px slide, ease-out |
| Add-habit sheet has no exit — snaps shut | AddSheet.tsx:48 | Wrap in AnimatePresence; mirror the 300ms enter on exit |
| Issue | File | Fix |
|---|---|---|
| Check-off pops from scale(0) with a bounce | HabitCheck.tsx:27 | scale 0.9→1 + opacity, 200ms ease-out, no overshoot |
| Streak counter swaps with no transition | StreakBadge.tsx:22 | 220ms opacity + translateY + blur roll-in |
| Milestone streaks have no celebration moment | StreakBadge.tsx:40 | One-shot badge scale-in + sparkle, milestones only |
| Enhancement | Where | Impact |
|---|---|---|
| Habit-card elevation invisible on dark theme | HabitCard.tsx:9 | 1px border carries elevation on both themes |
| Today-list could stagger in on first paint | Today.tsx:51 | 30ms stagger, opacity + 6px rise — one orchestrated load |
Jakub Krehel — Production Polish
Tally's gaps are craft gaps, not taste gaps: enters without exits, state changes that snap. That's Jakub's territory — finishing what's been started — so his lens drove the ordering. Emil set the durations; Jhey marked the one place to spend.