feat: add MarqueeText component with hover bounce-scroll for overflowing text#13168
feat: add MarqueeText component with hover bounce-scroll for overflowing text#13168
Conversation
DeJeune
left a comment
There was a problem hiding this comment.
Nice, clean component — good use of ResizeObserver, proper memo wrapping, and a sensible CSS-only animation approach. A few suggestions:
Significant
- Animation pause duration scales linearly with text length. For long titles (300px overflow), total idle time reaches ~30s, which feels broken. Consider fixed pause durations with only scroll time varying.
Perf
will-change-transformis always on, creating unnecessary GPU compositing layers even when idle. Should be conditional onshouldAnimate.
Minor
- Animation snaps back instantly on mouse leave if mid-scroll — a smooth return would feel more polished.
overflowAmountnot reset when overflow resolves (harmless but unclear).
CI is green. Overall a solid addition — the timing formula is the main thing worth revisiting.
| <div | ||
| ref={contentRef} | ||
| className={cn('inline-block whitespace-nowrap will-change-transform', shouldAnimate && 'animate-scroll-bounce')} | ||
| style={ |
There was a problem hiding this comment.
Perf: will-change-transform is applied unconditionally to the inner div, even when the text is not overflowing or the user is not hovering. This promotes the element to its own compositing layer at all times, which wastes GPU memory.
Consider making it conditional, similar to how the animation class is applied:
className={cn(
"inline-block whitespace-nowrap",
shouldAnimate && "will-change-transform animate-scroll-bounce"
)}There was a problem hiding this comment.
Fixed! will-change: transform is now set via JS only when the Web Animation is active, and cleared in the cleanup function.
| const shouldAnimate = isOverflowing && isHovered | ||
| // Scroll phases (15%→35% and 65%→85%) each occupy 20% of total duration | ||
| // So each scroll direction takes 0.2 * totalDuration seconds | ||
| // We want: overflowAmount / speed = 0.2 * totalDuration | ||
| const animationDuration = overflowAmount / speed / 0.2 |
There was a problem hiding this comment.
Significant: The animation duration formula overflowAmount / speed / 0.2 means the pause phases scale linearly with overflow distance. For example, with 300px overflow at 30px/s:
- Total duration = 50s
- Pause phases (60% of total) = 30 seconds of idle time
Users hovering over long titles would wait through very long pauses. Consider using a fixed pause duration plus variable scroll time instead:
const scrollTime = overflowAmount / speed
const pauseTime = 1.5 // fixed seconds per pause
const animationDuration = scrollTime * 2 + pauseTime * 3And then adjust the keyframe percentages dynamically (via CSS custom properties), or switch to a simpler approach with fixed keyframes and a minimum/maximum duration clamp.
There was a problem hiding this comment.
Good catch! Switched to Web Animations API with computed keyframe offsets — pause durations are now fixed (pauseDuration prop, default 0.8s) and only scroll time varies with overflow distance. The old CSS @keyframes scroll-bounce has been removed.
| className={cn('overflow-hidden whitespace-nowrap', className)} | ||
| onMouseEnter={() => setIsHovered(true)} | ||
| onMouseLeave={() => setIsHovered(false)}> |
There was a problem hiding this comment.
Minor: When the user moves the mouse away, shouldAnimate becomes false, which immediately removes the animate-scroll-bounce class. If the text was mid-scroll, it snaps back to position 0 instantly, which can feel jarring.
Consider adding a CSS transition on transform as a fallback, or listening for animationiteration to wait for a natural reset point before stopping.
There was a problem hiding this comment.
Fixed! On mouse leave, the cleanup now captures the current computed transform via getComputedStyle, cancels the animation, and applies a 0.3s ease-out CSS transition back to origin — no more jarring snap.
| setOverflowAmount(content.scrollWidth - container.clientWidth) | ||
| } | ||
| } | ||
| }, []) |
There was a problem hiding this comment.
Nit: When overflow is false, overflowAmount retains its previous stale value. This is harmless since shouldAnimate gates on isOverflowing, but for clarity you could reset it:
setIsOverflowing(overflow)
setOverflowAmount(overflow ? content.scrollWidth - container.clientWidth : 0)There was a problem hiding this comment.
Note
This comment was translated by Claude.
Fixed — `setOverflowAmount` now always runs unconditionally:
```tsx
setOverflowAmount(overflow ? content.scrollWidth - container.clientWidth : 0)
```
Original Content
Fixed — `setOverflowAmount` now always runs unconditionally:
```tsx
setOverflowAmount(overflow ? content.scrollWidth - container.clientWidth : 0)
```
Replace custom CSS keyframes with Web Animations API for better control and smoother transitions. Add pause duration parameter and smooth reset on animation cancellation.
DeJeune
left a comment
There was a problem hiding this comment.
Great refactor — all previous feedback addressed cleanly:
- Fixed pause timing with fixed
pauseDurationand dynamic keyframe offsets will-changenow conditional on animation lifecycle- Smooth 0.3s ease-out return on mouse leave
- Stale
overflowAmountproperly reset
The Web Animations API approach is a solid upgrade over the CSS class toggle — gives full dynamic control without polluting the global stylesheet.
Nit: tailwind.css has a stray blank line addition that could be reverted, but not blocking.
LGTM 👍
GeorgeDong32
left a comment
There was a problem hiding this comment.
Code Review Summary
Reviewed 5 files with 128 additions and 2 deletions. All CI checks pass.
Findings
Accessibility (1)
- Missing
prefers-reduced-motionsupport for motion-sensitive users
Bug Fix (1)
- Parameter boundary validation missing - speed <= 0 causes Infinity
Testing (1)
- MarqueeText component lacks unit tests
Documentation (1)
- PR description mentions unused CSS keyframes that don't match implementation
Positives
- Clean component structure with proper TypeScript typing
- Good use of
memofor performance optimization - Web Animations API provides better control than CSS animations
- Smooth animation cancellation with transition effect
- Responsive to container resize via ResizeObserver
Overall: Good implementation with a few improvements needed before merging.
| const el = contentRef.current | ||
| if (!shouldAnimate || !el || overflowAmount <= 0) return | ||
|
|
||
| const scrollTime = overflowAmount / speed |
There was a problem hiding this comment.
Bug: When speed <= 0, this will cause scrollTime = Infinity, resulting in broken animation.
// Line 45 - add boundary validation
const safeSpeed = Math.max(speed, 1) // minimum value protection
const scrollTime = overflowAmount / safeSpeed
What this PR does
Before this PR:
Long text in constrained containers (e.g., citation tooltip titles) is either truncated with ellipsis or overflows, making it impossible for users to read the full content.
After this PR:
A new
MarqueeTextcomponent provides a hover-triggered bounce-scroll effect for overflowing text. It is applied to citation tooltip titles as the first usage.2026-03-03.16-46-56.mov
Why we need it and why it was done in this way
The following tradeoffs were made:
@theme inlinecustom animation (scroll-bounce) with CSS custom properties for dynamic scroll distance, keeping the component free of styled-components dependencyThe following alternatives were considered:
text-overflow: ellipsis(existingEllipsiscomponent) — hides content permanentlymarqueeanimation) — requires duplicating DOM nodes and doesn't suit hover interactionrequestAnimationFrame— more complex, CSS animation is sufficient hereBreaking changes
None
Special notes for your reviewer
scroll-bouncekeyframe is added totailwind.css@theme inlineblock, makinganimate-scroll-bounceavailable as a Tailwind utility class globallyResizeObserveris used to dynamically detect overflow, so the component responds correctly to container resizingpx/s) to keep scroll velocity consistent regardless of text lengthChecklist
Release note