Skip to content

feat: add MarqueeText component with hover bounce-scroll for overflowing text#13168

Merged
EurFelux merged 5 commits intomainfrom
feat/marquee-text
Mar 3, 2026
Merged

feat: add MarqueeText component with hover bounce-scroll for overflowing text#13168
EurFelux merged 5 commits intomainfrom
feat/marquee-text

Conversation

@EurFelux
Copy link
Copy Markdown
Collaborator

@EurFelux EurFelux commented Mar 3, 2026

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 MarqueeText component 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:

  • Hover-triggered (not always-on) to avoid visual noise and distraction
  • Bounce (back-and-forth) animation instead of infinite loop so users can see both the beginning and end of the text naturally
  • Uses Tailwind CSS @theme inline custom animation (scroll-bounce) with CSS custom properties for dynamic scroll distance, keeping the component free of styled-components dependency

The following alternatives were considered:

  • CSS text-overflow: ellipsis (existing Ellipsis component) — hides content permanently
  • Infinite loop marquee (existing marquee animation) — requires duplicating DOM nodes and doesn't suit hover interaction
  • JavaScript-driven scroll with requestAnimationFrame — more complex, CSS animation is sufficient here

Breaking changes

None

Special notes for your reviewer

  • The scroll-bounce keyframe is added to tailwind.css @theme inline block, making animate-scroll-bounce available as a Tailwind utility class globally
  • ResizeObserver is used to dynamically detect overflow, so the component responds correctly to container resizing
  • Animation duration is calculated from overflow distance and speed (px/s) to keep scroll velocity consistent regardless of text length

Checklist

  • PR: The PR description is expressive enough and will help future contributors
  • Code: Write code that humans can understand and Keep it simple
  • Refactor: You have left the code cleaner than you found it (Boy Scout Rule)
  • Upgrade: Impact of this change on upgrade flows was considered and addressed if required
  • Documentation: A user-guide update was considered and is present (link) or not required.
  • Self-review: I have reviewed my own code before requesting review from others

Release note

NONE

@EurFelux EurFelux requested review from DeJeune and GeorgeDong32 March 3, 2026 08:51
Copy link
Copy Markdown
Collaborator

@DeJeune DeJeune left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-transform is always on, creating unnecessary GPU compositing layers even when idle. Should be conditional on shouldAnimate.

Minor

  • Animation snaps back instantly on mouse leave if mid-scroll — a smooth return would feel more polished.
  • overflowAmount not 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={
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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"
)}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed! will-change: transform is now set via JS only when the Web Animation is active, and cleared in the cleanup function.

Comment on lines +44 to +48
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
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 * 3

And 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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +53 to +55
className={cn('overflow-hidden whitespace-nowrap', className)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}>
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +30 to +33
setOverflowAmount(content.scrollWidth - container.clientWidth)
}
}
}, [])
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Collaborator Author

@EurFelux EurFelux Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@EurFelux EurFelux requested a review from DeJeune March 3, 2026 10:06
Copy link
Copy Markdown
Collaborator

@DeJeune DeJeune left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great refactor — all previous feedback addressed cleanly:

  • Fixed pause timing with fixed pauseDuration and dynamic keyframe offsets
  • will-change now conditional on animation lifecycle
  • Smooth 0.3s ease-out return on mouse leave
  • Stale overflowAmount properly 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 👍

Copy link
Copy Markdown
Collaborator

@GeorgeDong32 GeorgeDong32 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Summary

Reviewed 5 files with 128 additions and 2 deletions. All CI checks pass.

Findings

Accessibility (1)

  • Missing prefers-reduced-motion support 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 memo for 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
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@EurFelux EurFelux merged commit 7fd2342 into main Mar 3, 2026
23 checks passed
@EurFelux EurFelux deleted the feat/marquee-text branch March 3, 2026 15:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants