Skip to content

fix(landing): replace per-icon hover with smooth sliding pill highlight#3480

Closed
MaxwellCalkin wants to merge 1 commit intosimstudioai:mainfrom
MaxwellCalkin:fix/smooth-icon-hover-pill
Closed

fix(landing): replace per-icon hover with smooth sliding pill highlight#3480
MaxwellCalkin wants to merge 1 commit intosimstudioai:mainfrom
MaxwellCalkin:fix/smooth-icon-hover-pill

Conversation

@MaxwellCalkin
Copy link

Summary

Replaces the abrupt per-icon border/shadow hover effect on the landing hero icon row with a single highlight pill that smoothly translates between icons. The pill follows both the auto-hover cycle and manual cursor hover using CSS transition-all duration-300, creating a fluid, continuous highlight rather than a jarring jump.

Fixes #3468

Changes

  • icon-button.tsx: Convert to forwardRef to expose button DOM refs for position measurement. Remove the per-icon isAutoHovered prop and hover:border/hover:shadow classes — highlighting is now handled by the parent pill element.
  • hero.tsx: Add iconRefs and iconContainerRef for measuring each button's position via getBoundingClientRect. Compute the active index (user hover takes priority over auto-hover), then position an absolute pill element with translateX. Recalculates on window resize for responsive correctness.

How it works

  1. A single <div> pill sits at position: absolute inside the icon container
  2. On each active-index change, the pill's width, height, and transform: translateX(...) are updated
  3. transition-all duration-300 ease-in-out on the pill creates the smooth sliding motion
  4. When the user hovers over the icon row, the auto-cycle pauses and the pill tracks the cursor
  5. When the cursor leaves, the auto-cycle resumes from the next icon

Note

This PR was authored by an AI (Claude Opus 4.6, Anthropic). See https://github.com/anthropics/claude-code for details on the tool used.

Replace the abrupt per-icon border/shadow hover effect with a single
highlight pill that smoothly translates to the active icon. The pill
follows both the auto-hover cycle and manual cursor hover, using CSS
transitions for fluid movement between icons.

Changes:
- icon-button.tsx: Convert to forwardRef, remove per-icon hover/auto-hover
  border styling (now handled by parent pill element)
- hero.tsx: Add refs for icon buttons and container, compute pill position
  via getBoundingClientRect, render absolute-positioned pill with
  transition-all duration-300, recalculate on window resize

Fixes simstudioai#3468

---

> [!NOTE]
> This PR was authored by an AI (Claude Opus 4.6, Anthropic). See https://github.com/anthropics/claude-code
> for details on the tool used.

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

cursor bot commented Mar 9, 2026

PR Summary

Low Risk
Landing-page UI-only change; main risk is minor layout/positioning regressions from DOM measurement and the new resize listener.

Overview
Landing hero icon highlighting is reworked from per-button border/shadow hover states to a single absolutely positioned “pill” that smoothly animates between icons.

IconButton is converted to forwardRef and drops the isAutoHovered prop/styling so hero.tsx can measure button positions and drive the pill’s width/height/translateX, recalculating on hover changes and window resize.

Written by Cursor Bugbot for commit ec35f01. 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 3:23am

Request Review

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 9, 2026

Greptile Summary

This PR replaces individual per-icon border/shadow hover effects in the landing hero icon row with a single absolute-positioned "pill" element that smoothly slides between icons using CSS transforms, creating a fluid highlight that tracks both the auto-cycle and the user's cursor.

Key changes:

  • icon-button.tsx is converted to forwardRef so each button's DOM node can be measured; the now-unnecessary isAutoHovered prop and conditional border/shadow classes are removed.
  • hero.tsx introduces iconRefs and iconContainerRef, computes the active index (user hover takes priority over auto-cycle), and drives a single pill <div> via getBoundingClientRect-based translateX. A resize listener keeps the pill aligned after viewport changes.
  • There is a logic edge case on line 243: when the cursor enters the container padding area before any icon has been hovered, lastHoveredIndex is still null, making activeIndex null and causing the pill to briefly fade out. Changing the condition to isUserHovering && lastHoveredIndex !== null ? lastHoveredIndex : autoHoverIndex eliminates the flash.
  • The pill uses transition-all, which will also animate width and height on the first paint (from the 0 × 0 initial state). Scoping to transition-[transform,opacity] gives a cleaner entrance.
  • The hardcoded top-[18px] sm:top-[32px] on the pill duplicates the container's padding-top, making the two values silently coupled.

Confidence Score: 3/5

  • Safe to merge after addressing the pill-disappearance logic bug; style issues are cosmetic but worth resolving.
  • One concrete logic bug causes a visible flash on first interaction, and two style issues (over-broad transition, hardcoded top offset) reduce robustness. The core approach is sound and the icon-button refactor is clean.
  • apps/sim/app/(landing)/components/hero/hero.tsx — activeIndex computation and pill CSS classes

Important Files Changed

Filename Overview
apps/sim/app/(landing)/components/hero/hero.tsx Adds sliding pill highlight with getBoundingClientRect-based positioning and resize handling. Has a logic edge case where the pill vanishes on initial container entry before any icon is hovered, plus a style concern around transition-all animating unexpected properties on first render.
apps/sim/app/(landing)/components/hero/components/icon-button.tsx Clean conversion to forwardRef; removes the isAutoHovered prop and associated conditional styling. No issues found.

Sequence Diagram

sequenceDiagram
    participant User
    participant Container as Icon Container
    participant State as React State
    participant Pill as Pill <div>

    Note over State: autoHoverIndex cycles every 2s

    State->>Pill: activeIndex = autoHoverIndex → updatePillPosition()
    Pill-->>Pill: translateX to autoHoverIndex button

    User->>Container: onMouseEnter (container padding area)
    Container->>State: isUserHovering = true
    State->>Pill: activeIndex = lastHoveredIndex (null) → opacity 0
    Note over Pill: ⚠️ Brief flash: pill disappears

    User->>Container: onMouseEnter (icon button N)
    Container->>State: lastHoveredIndex = N
    State->>Pill: activeIndex = N → updatePillPosition()
    Pill-->>Pill: translateX to button N (opacity 1)

    User->>Container: onMouseLeave
    Container->>State: isUserHovering = false, autoHoverIndex = N+1
    State->>Pill: activeIndex = autoHoverIndex → updatePillPosition()
    Pill-->>Pill: slides to autoHoverIndex button
Loading

Last reviewed commit: ec35f01

/**
* Compute the active icon index and update the pill position
*/
const activeIndex = isUserHovering ? lastHoveredIndex : autoHoverIndex
Copy link
Contributor

Choose a reason for hiding this comment

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

Pill disappears on first container entry

When a user moves the cursor into the container padding area (the pt-[18px] / pt-[32px] zone above the icons) before hovering any individual icon, isUserHovering becomes true while lastHoveredIndex is still null. This makes activeIndex === null, which causes updatePillPosition to fade the pill to opacity: 0 — creating a visible flash where the pill vanishes before reappearing on the first icon hover.

The fix is to fall back to autoHoverIndex when the user hasn't yet hovered over a specific icon:

Suggested change
const activeIndex = isUserHovering ? lastHoveredIndex : autoHoverIndex
const activeIndex = isUserHovering && lastHoveredIndex !== null ? lastHoveredIndex : autoHoverIndex

>
{/* Sliding highlight pill */}
<div
className='pointer-events-none absolute top-[18px] left-0 rounded-xl border border-[#E5E5E5] shadow-[0_2px_4px_0_rgba(0,0,0,0.08)] transition-all duration-300 ease-in-out sm:top-[32px]'
Copy link
Contributor

Choose a reason for hiding this comment

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

transition-all animates width/height on initial render

The pill initialises with width: 0, height: 0 in state. Because transition-all duration-300 transitions every CSS property, the very first call to updatePillPosition will animate the pill growing from zero width/height to the button dimensions, causing a "scale-up" entrance rather than a clean fade-in slide. Consider scoping the transition to only the properties that should animate:

Suggested change
className='pointer-events-none absolute top-[18px] left-0 rounded-xl border border-[#E5E5E5] shadow-[0_2px_4px_0_rgba(0,0,0,0.08)] transition-all duration-300 ease-in-out sm:top-[32px]'
className='pointer-events-none absolute top-[18px] left-0 rounded-xl border border-[#E5E5E5] shadow-[0_2px_4px_0_rgba(0,0,0,0.08)] transition-[transform,opacity] duration-300 ease-in-out sm:top-[32px]'

If you also want the opacity fade on first appearance, transition-[transform,opacity] handles that without animating width/height.

>
{/* Sliding highlight pill */}
<div
className='pointer-events-none absolute top-[18px] left-0 rounded-xl border border-[#E5E5E5] shadow-[0_2px_4px_0_rgba(0,0,0,0.08)] transition-all duration-300 ease-in-out sm:top-[32px]'
Copy link
Contributor

Choose a reason for hiding this comment

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

Hardcoded top value duplicates container padding

The pill's top-[18px] sm:top-[32px] directly mirrors the container's pt-[18px] sm:pt-[32px]. These two values must always stay in sync — if the container padding is ever adjusted, the pill will be misaligned. Consider computing offsetY from getBoundingClientRect() in updatePillPosition (similar to how offsetX is already computed) so the vertical position is always derived from the actual button position rather than hardcoded.

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 2 potential issues.

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

transform: `translateX(${offsetX}px)`,
opacity: 1,
})
}, [activeIndex])
Copy link

Choose a reason for hiding this comment

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

Pill stays visible at stale position after icon count shrinks

Medium Severity

When visibleIconCount decreases on window resize (e.g., 13→6), autoHoverIndex is not clamped to the new range. If autoHoverIndex exceeds the new visibleIconCount, the corresponding icon ref is null (unmounted), so updatePillPosition exits early without updating — leaving the pill visible (opacity: 1) at its last stale position, potentially floating in empty space to the right of the now-narrower icon row. This persists for up to 2 seconds until the next interval tick wraps the index back in range.

Additional Locations (1)

Fix in Cursor Fix in Web


const updatePillPosition = React.useCallback(() => {
if (activeIndex == null) {
setPillStyle((prev) => ({ ...prev, opacity: 0 }))
Copy link

Choose a reason for hiding this comment

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

Pill disappears on first mouse entry into container

Medium Severity

On the very first user hover interaction, lastHoveredIndex is null. When the mouse enters the container (especially through the pt-[18px]/pt-[32px] padding zone above the buttons), isUserHovering becomes true and activeIndex resolves to lastHoveredIndex which is null. This triggers the activeIndex == null branch in updatePillPosition, fading the pill out to opacity: 0. The pill then reappears only after the user hovers over a specific icon button, causing a visible flash instead of the intended smooth sliding motion.

Additional Locations (1)

Fix in Cursor Fix in Web

@MaxwellCalkin
Copy link
Author

Closing in favor of #3481 which uses framer-motion's layoutId for a cleaner implementation of the same fix.

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