Files
Bill Ballou 7a8330e205 Add CSS transitions and animations for smooth UI interactions
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.
2026-02-28 22:46:11 -05:00

94 lines
5.5 KiB
Markdown

## Context
The app uses React 19 with inline styles and no CSS framework. Current animations are limited to:
- A `skeleton-pulse` keyframe for loading placeholders in CourseSelection
- `transform` + `transition` on 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 `transition` and `@keyframes`
- Respect `prefers-reduced-motion` globally
- 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`:
```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-out` timing 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: hidden` on the collapsible wrapper to clip content during animation.