Animate course set pin/unpin with cross-fade content swap, credit bar width changes, status badge color transitions, expand/collapse panels (CreditLegend, AllocationBreakdown), mode toggle switching, and ModeComparison banner fade. Specialization rows flash on credit/status changes. Threshold markers animate position. All animations respect prefers-reduced-motion.
5.5 KiB
Context
The app uses React 19 with inline styles and no CSS framework. Current animations are limited to:
- A
skeleton-pulsekeyframe for loading placeholders in CourseSelection transform+transitionon mobile banners (MobileStatusBanner, MobileCourseBanner)- dnd-kit's built-in drag transform/transition on sortable items
All other state changes (course pin/unpin, credit bar updates, expand/collapse, status badge changes, mode toggle) are instant. The app has no animation library dependency.
Goals / Non-Goals
Goals:
- Smooth transitions for all interactive state changes so users can follow what changed
- Keep bundle size unchanged — CSS-only approach using
transitionand@keyframes - Respect
prefers-reduced-motionglobally - Maintain existing inline-style pattern (no migration to CSS modules or Tailwind)
Non-Goals:
- Page/route transition animations (app is single-page, no routing)
- Drag-and-drop animation improvements (dnd-kit handles this already)
- Loading skeleton animation changes (already animated)
- Mobile banner slide animations (already implemented)
- Scroll animations or parallax effects
Decisions
1. CSS transitions on inline styles, no animation library
Choice: Add transition properties to existing inline styles.
Rationale: The app already uses inline styles everywhere. Adding framer-motion or react-spring would introduce a runtime dependency (15-40 KB) for effects that CSS transition handles natively. CSS transitions are GPU-accelerated and zero-JS-overhead.
Alternative considered: framer-motion AnimatePresence for mount/unmount animations. Rejected because the bundle cost isn't justified — we can approximate enter/exit with max-height or opacity transitions on always-mounted wrappers.
2. Expand/collapse via max-height + overflow, not conditional rendering
Choice: For CreditLegend and AllocationBreakdown, keep elements mounted but toggle between max-height: 0; overflow: hidden and max-height: <value> with a transition.
Rationale: CSS cannot transition elements that are conditionally rendered ({open && <div>...}</div>). To animate open/close, the element must remain in the DOM. Using max-height with a generous upper bound (e.g., 500px) provides a smooth slide without needing to measure actual content height.
Alternative considered: requestAnimationFrame + scrollHeight measurement for precise height animation. Rejected — adds JS complexity for marginal visual improvement. The max-height approach is standard and sufficient.
3. Global prefers-reduced-motion reset in index.css
Choice: Add a single CSS rule in index.css:
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
Rationale: A global rule is simpler and more reliable than adding motion checks to each component. This is the recommended pattern from MDN. Using 0.01ms instead of 0s ensures transition events still fire.
4. Credit bar transitions via CSS transition: width
Choice: Add transition: width 300ms ease-out to the CreditBar's inner <div> elements.
Rationale: The bars already use percentage-based widths. Adding a transition property is a one-line change that produces a smooth fill/drain animation as credit allocations change.
5. Status badge color transitions
Choice: Add transition: background 200ms, color 200ms to the status badge <span> in SortableItem.
Rationale: When a specialization's status changes (e.g., achievable → achieved), the badge colors will cross-fade instead of snapping. Since the badge is always rendered (just with different colors), a simple transition property works.
6. Mode toggle sliding indicator
Choice: Add transition: background 150ms, box-shadow 150ms to both toggle buttons.
Rationale: The toggle already swaps background and boxShadow between active/inactive. Adding transitions creates a smooth switch effect with no structural changes.
7. ModeComparison banner enter/exit
Choice: Always render the banner div but use opacity + max-height transitions to animate in/out, with the conditional logic controlling style values rather than mount/unmount.
Rationale: Same pattern as expand/collapse. The banner either shows or hides based on whether the two modes differ. Transitioning opacity provides a fade effect.
8. Course set pin/unpin transitions
Choice: Add transition: border-color 200ms, background 200ms to the ElectiveSet container div.
Rationale: When a course is pinned, the set's border changes from dashed gray to solid blue and background shifts. Transitioning these properties creates a visual confirmation of the selection.
Risks / Trade-offs
-
max-height overshoot: Using a fixed max-height (e.g., 500px) means the transition timing won't perfectly match content height. Short content will appear to "accelerate" at the end. → Mitigation: Use
ease-outtiming and a reasonable max-height close to typical content size. -
Inline style transitions are slightly verbose: Each component gets
transition: ...added to its style object. → Mitigation: This is consistent with the existing pattern. Extracting shared transition strings into constants keeps it DRY. -
Content flash on expand/collapse refactor: Changing from conditional render to always-mounted-but-hidden may cause brief layout shifts during the transition. → Mitigation: Use
overflow: hiddenon the collapsible wrapper to clip content during animation.