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.
94 lines
5.5 KiB
Markdown
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.
|