Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 8 additions & 15 deletions apps/sim/app/(landing)/components/hero/components/icon-button.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use client'

import { forwardRef } from 'react'
import type React from 'react'

interface IconButtonProps {
Expand All @@ -8,31 +9,23 @@ interface IconButtonProps {
onMouseEnter?: () => void
style?: React.CSSProperties
'aria-label': string
isAutoHovered?: boolean
}

export function IconButton({
children,
onClick,
onMouseEnter,
style,
'aria-label': ariaLabel,
isAutoHovered = false,
}: IconButtonProps) {
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(function IconButton(
{ children, onClick, onMouseEnter, style, 'aria-label': ariaLabel },
ref
) {
return (
<button
ref={ref}
type='button'
aria-label={ariaLabel}
onClick={onClick}
onMouseEnter={onMouseEnter}
className={`flex items-center justify-center rounded-xl border p-2 outline-none transition-all duration-300 ${
isAutoHovered
? 'border-[#E5E5E5] shadow-[0_2px_4px_0_rgba(0,0,0,0.08)]'
: 'border-transparent hover:border-[#E5E5E5] hover:shadow-[0_2px_4px_0_rgba(0,0,0,0.08)]'
}`}
className='flex items-center justify-center rounded-xl border border-transparent p-2 outline-none'
style={style}
>
{children}
</button>
)
}
})
59 changes: 57 additions & 2 deletions apps/sim/app/(landing)/components/hero/hero.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,18 @@ export default function Hero() {
const [lastHoveredIndex, setLastHoveredIndex] = React.useState<number | null>(null)
const intervalRef = React.useRef<NodeJS.Timeout | null>(null)

/**
* Refs for smooth sliding pill highlight
*/
const iconContainerRef = React.useRef<HTMLDivElement>(null)
const iconRefs = React.useRef<(HTMLButtonElement | null)[]>([])
const [pillStyle, setPillStyle] = React.useState<React.CSSProperties>({
opacity: 0,
width: 0,
height: 0,
transform: 'translateX(0px)',
})

/**
* Handle service icon click to populate textarea with template
*/
Expand Down Expand Up @@ -225,6 +237,40 @@ export default function Hero() {
}
}, [isUserHovering, visibleIconCount])

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


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

return
}

const button = iconRefs.current[activeIndex]
const container = iconContainerRef.current
if (!button || !container) return

const containerRect = container.getBoundingClientRect()
const buttonRect = button.getBoundingClientRect()
const offsetX = buttonRect.left - containerRect.left

setPillStyle({
width: buttonRect.width,
height: buttonRect.height,
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


React.useEffect(() => {
updatePillPosition()

window.addEventListener('resize', updatePillPosition)
return () => window.removeEventListener('resize', updatePillPosition)
}, [updatePillPosition])

/**
* Handle mouse enter on icon container
*/
Expand Down Expand Up @@ -377,21 +423,30 @@ export default function Hero() {
Build and deploy AI agent workflows
</p>
<div
className='flex items-center justify-center gap-[2px] pt-[18px] sm:pt-[32px]'
ref={iconContainerRef}
className='relative flex items-center justify-center gap-[2px] pt-[18px] sm:pt-[32px]'
onMouseEnter={handleIconContainerMouseEnter}
onMouseLeave={handleIconContainerMouseLeave}
>
{/* 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.

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.

style={pillStyle}
aria-hidden='true'
/>
{/* Service integration buttons */}
{serviceIcons.slice(0, visibleIconCount).map((service, index) => {
const Icon = service.icon
return (
<IconButton
key={service.key}
ref={(el) => {
iconRefs.current[index] = el
}}
aria-label={service.label}
onClick={() => handleServiceClick(service.key as keyof typeof SERVICE_TEMPLATES)}
onMouseEnter={() => setLastHoveredIndex(index)}
style={service.style}
isAutoHovered={!isUserHovering && index === autoHoverIndex}
>
<Icon className='h-5 w-5 sm:h-6 sm:w-6' />
</IconButton>
Expand Down