Skip to content

fix: smooth sliding highlight pill for hero service icons#3481

Open
MaxwellCalkin wants to merge 2 commits intosimstudioai:mainfrom
MaxwellCalkin:fix/smooth-icon-hover-pill-v2
Open

fix: smooth sliding highlight pill for hero service icons#3481
MaxwellCalkin wants to merge 2 commits intosimstudioai:mainfrom
MaxwellCalkin:fix/smooth-icon-hover-pill-v2

Conversation

@MaxwellCalkin
Copy link

Summary

Fixes #3468

The service integration icons on the landing page hero use per-icon hover states, causing the border/shadow highlight to jump abruptly between icons. This replaces that with a single shared highlight pill that slides smoothly between icons using framer-motion's layoutId animation.

Changes

  • icon-button.tsx: Replace CSS border/shadow toggle with a motion.div pill using layoutId='icon-highlight-pill' for shared layout animation. Spring physics (stiffness: 400, damping: 30) produce a snappy but smooth slide.
  • hero.tsx: Consolidate lastHoveredIndex into hoveredIndex and compute a single activeIconIndex (manual hover takes priority over auto-cycle). On mouse leave, the auto-cycle resumes from the next icon after the last hovered one.

Behavior

  • Auto-cycle: A single pill moves through icons every 2 seconds with smooth spring animation
  • User hover: The pill instantly follows the cursor to the hovered icon
  • Mouse leave: Auto-cycle resumes from the icon after the last hovered one

This PR was authored by an AI (Claude Opus 4.6, Anthropic). See https://www.maxcalkin.com/ai for transparency details.

Replace per-icon hover state (abrupt border/shadow jump) with a single
shared highlight pill that slides smoothly between icons using
framer-motion layoutId animation. The pill follows the cursor on hover
and continues the auto-cycle on mouse leave.

Fixes simstudioai#3468

> This PR was authored by an AI (Claude Opus 4.6, Anthropic). See
> https://www.maxcalkin.com/ai for transparency details.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@cursor
Copy link

cursor bot commented Mar 9, 2026

PR Summary

Low Risk
Low risk, limited to landing-page UI state/animation; main risk is minor visual/perf regressions from the new framer-motion layout animation.

Overview
Updates the landing hero service icon row to use a single shared highlight pill that slides smoothly between icons instead of toggling per-button border/shadow states.

IconButton now accepts isActive and renders a framer-motion motion.div with a shared layoutId for the highlight, while hero.tsx consolidates hover tracking and computes an activeIconIndex (user hover overrides auto-cycle, and auto-cycle resumes from the next icon on mouse leave).

Written by Cursor Bugbot for commit 2b3a3f7. This will update automatically on new commits. Configure here.

@vercel
Copy link

vercel bot commented Mar 9, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Skipped Skipped Mar 9, 2026 6:08am

Request Review

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 9, 2026

Greptile Summary

This PR replaces the per-icon CSS hover state (border/shadow toggled individually) on the hero service icons with a single framer-motion shared-layout pill (layoutId='icon-highlight-pill') that slides smoothly between icons during both the auto-cycle and manual hover. It's a clean UX improvement that fits the landing page's existing animation style.

Key changes:

  • icon-button.tsx: Removes the CSS conditional border/shadow in favour of a conditionally-rendered motion.div with layoutId and spring physics. An onMouseLeave prop was also added to the interface but is never passed from the parent.
  • hero.tsx: Renames lastHoveredIndexhoveredIndex, derives a single activeIconIndex (user hover takes priority over auto-cycle), and resumes auto-cycling from the icon after the last one hovered on container mouse-leave.

Issues found:

  • There is a stale-closure bug in handleIconContainerMouseLeave: because React 18 auto-batches state updates across separate browser events, the hoveredIndex read inside that handler may reflect the value from the previous render rather than the one set by a rapid onMouseEnter, causing the auto-cycle to resume from the wrong icon. Tracking the hovered index in a useRef alongside state is the standard fix.
  • The onMouseLeave prop added to IconButtonProps and forwarded to <button> is never supplied by hero.tsx, making it dead code.

Confidence Score: 3/5

  • Safe to merge for most interactions, but a stale-closure edge case can cause the auto-cycle to resume from the wrong icon after a fast hover-and-leave.
  • The visual change is well-contained and the animation approach is correct. The stale-closure bug in handleIconContainerMouseLeave is real (reproducible in React 18 with automatic batching) but only affects the resume position of the auto-cycle after a very fast hover, making it a low-severity UX glitch rather than a crash or data issue. The unused onMouseLeave prop is harmless but adds unnecessary interface noise.
  • apps/sim/app/(landing)/components/hero/hero.tsx — stale closure in handleIconContainerMouseLeave

Important Files Changed

Filename Overview
apps/sim/app/(landing)/components/hero/components/icon-button.tsx Replaces per-icon CSS border/shadow toggle with a conditionally-rendered motion.div using layoutId='icon-highlight-pill' for a shared-layout spring animation; adds an unused onMouseLeave prop to the interface.
apps/sim/app/(landing)/components/hero/hero.tsx Consolidates lastHoveredIndex into hoveredIndex and derives activeIconIndex; contains a stale-closure bug in handleIconContainerMouseLeave where hoveredIndex may be read before React processes the preceding setHoveredIndex call.

Sequence Diagram

sequenceDiagram
    participant User
    participant ContainerDiv
    participant IconButton
    participant HeroState

    Note over HeroState: autoHoverIndex advances every 2 s (isUserHovering = false)

    User->>ContainerDiv: mouseenter
    ContainerDiv->>HeroState: setIsUserHovering(true) + clearInterval()

    User->>IconButton: mouseenter (index N)
    IconButton->>HeroState: setHoveredIndex(N)
    Note over HeroState: activeIconIndex = N (user hover wins)

    User->>IconButton: mouseenter (index M)
    IconButton->>HeroState: setHoveredIndex(M)
    Note over HeroState: activeIconIndex = M

    User->>ContainerDiv: mouseleave
    ContainerDiv->>HeroState: setIsUserHovering(false), setHoveredIndex(null)
    ContainerDiv->>HeroState: setAutoHoverIndex((M+1) % visibleIconCount)  [may be stale]
    Note over HeroState: Auto-cycle resumes from icon M+1
Loading

Last reviewed commit: bcd2aab

Comment on lines 247 to 253
const handleIconContainerMouseLeave = () => {
setIsUserHovering(false)
setHoveredIndex(null)
// Start from the next icon after the last hovered one
if (lastHoveredIndex !== null) {
setAutoHoverIndex((lastHoveredIndex + 1) % visibleIconCount)
if (hoveredIndex !== null) {
setAutoHoverIndex((hoveredIndex + 1) % visibleIconCount)
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Stale closure: hoveredIndex may be read before its setHoveredIndex update is applied

In React 18 with automatic batching, setHoveredIndex(index) (called in the icon's onMouseEnter) and the container's onMouseLeave handler can be triggered by two separate, rapid browser events. If the user hovers an icon and immediately leaves the container before React has re-rendered, handleIconContainerMouseLeave will execute with the closure's stale hoveredIndex value (the one from the last render, which is still null or a previous index). The auto-cycle will then resume from the wrong icon.

The reliable fix is to mirror the state into a useRef so the handler always reads the latest value:

const hoveredIndexRef = React.useRef<number | null>(null)

// in the onMouseEnter passed to each IconButton:
onMouseEnter={() => {
  setHoveredIndex(index)
  hoveredIndexRef.current = index
}}

// in handleIconContainerMouseLeave:
const handleIconContainerMouseLeave = () => {
  setIsUserHovering(false)
  setHoveredIndex(null)
  if (hoveredIndexRef.current !== null) {
    setAutoHoverIndex((hoveredIndexRef.current + 1) % visibleIconCount)
  }
  hoveredIndexRef.current = null
}

children: React.ReactNode
onClick?: () => void
onMouseEnter?: () => void
onMouseLeave?: () => void
Copy link
Contributor

Choose a reason for hiding this comment

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

Unused onMouseLeave prop

onMouseLeave is declared in IconButtonProps and forwarded to the <button> element, but hero.tsx never passes this prop when rendering <IconButton>. The mouse-leave behaviour is handled entirely by the container div's onMouseLeave. This dead prop adds noise to the interface and may confuse future maintainers.

Suggested change
onMouseLeave?: () => void
onMouseLeave?: () => void

Consider removing it unless there's a planned use-case, or add a brief comment explaining why it's reserved for future use.

Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

- Save hoveredIndex to local variable before clearing to avoid
  confusing stale closure read (Cursor Bugbot + Greptile feedback)
- Remove unused onMouseLeave prop from IconButtonProps since
  mouse-leave is handled by the container div

AI Disclosure: This commit was authored by Claude Opus 4.6 (Anthropic),
an AI agent operated by Maxwell Calkin (@MaxwellCalkin).
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.

[UX] - Staggered hover feels abrupt on landing page

1 participant