Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ interface IconButtonProps {
onMouseEnter?: () => void
style?: React.CSSProperties
'aria-label': string
isAutoHovered?: boolean
highlightFromParent?: boolean
}

export function IconButton({
Expand All @@ -17,19 +17,19 @@ export function IconButton({
onMouseEnter,
style,
'aria-label': ariaLabel,
isAutoHovered = false,
highlightFromParent = false,
}: IconButtonProps) {
const hoverHighlight = highlightFromParent
? ''
: 'hover:border-[#E5E5E5] hover:shadow-[0_2px_4px_0_rgba(0,0,0,0.08)]'

return (
<button
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 transition-all duration-300 ${hoverHighlight}`}
style={style}
>
{children}
Expand Down
120 changes: 102 additions & 18 deletions apps/sim/app/(landing)/components/hero/hero.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client'

import React from 'react'
import { motion } from 'framer-motion'
import { ArrowUp, CodeIcon } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { type Edge, type Node, Position } from 'reactflow'
Expand Down Expand Up @@ -150,12 +151,28 @@ export default function Hero() {
const [autoHoverIndex, setAutoHoverIndex] = React.useState(1)
const [isUserHovering, setIsUserHovering] = React.useState(false)
const [lastHoveredIndex, setLastHoveredIndex] = React.useState<number | null>(null)
const [selectedIconIndex, setSelectedIconIndex] = React.useState<number | null>(null)
const intervalRef = React.useRef<NodeJS.Timeout | null>(null)

const iconRowRef = React.useRef<HTMLDivElement | null>(null)
const buttonRefs = React.useRef<(HTMLDivElement | null)[]>([])
const [pillLayout, setPillLayout] = React.useState<{
left: number
top: number
width: number
height: number
} | null>(null)
const [layoutVersion, setLayoutVersion] = React.useState(0)

/**
* Handle service icon click to populate textarea with template
*/
const handleServiceClick = (service: keyof typeof SERVICE_TEMPLATES) => {
const handleServiceClick = (index: number, service: keyof typeof SERVICE_TEMPLATES) => {
setSelectedIconIndex(index)
if (intervalRef.current) {
clearInterval(intervalRef.current)
intervalRef.current = null
}
setTextValue(SERVICE_TEMPLATES[service])
}

Expand All @@ -177,6 +194,21 @@ export default function Hero() {
return () => window.removeEventListener('resize', updateVisibleIcons)
}, [])

React.useEffect(() => {
const maxIndex = visibleIconCount - 1
setAutoHoverIndex((i) => Math.min(i, maxIndex))
setLastHoveredIndex((idx) =>
idx !== null ? Math.min(idx, maxIndex) : null
)
setSelectedIconIndex((idx) =>
idx !== null ? Math.min(idx, maxIndex) : null
)
}, [visibleIconCount])
Copy link

Choose a reason for hiding this comment

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

Clamped selectedIconIndex mismatches textarea template content

Low Severity

When visibleIconCount shrinks (e.g., resizing from desktop to mobile), selectedIconIndex is clamped to maxIndex but textValue is left unchanged. This causes the sliding pill to highlight a different service icon than the one whose template is displayed in the textarea. For instance, clicking icon 10 (Google Sheets) then resizing to mobile clamps selectedIconIndex to 5 (Supabase), so the pill highlights Supabase while the textarea still shows the Google Sheets template.

Additional Locations (1)

Fix in Cursor Fix in Web


React.useEffect(() => {
if (textValue.trim().length === 0) setSelectedIconIndex(null)
}, [textValue])

/**
* Service icons array for easier indexing
*/
Expand Down Expand Up @@ -205,25 +237,20 @@ export default function Hero() {
* Auto-hover animation effect
*/
React.useEffect(() => {
// Start the interval when component mounts
const startInterval = () => {
intervalRef.current = setInterval(() => {
setAutoHoverIndex((prev) => (prev + 1) % visibleIconCount)
}, 2000)
}

// Only run interval when user is not hovering
if (!isUserHovering) {
startInterval()
}
if (selectedIconIndex === null && !isUserHovering) startInterval()

// Cleanup on unmount or when hovering state changes
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current)
}
}
}, [isUserHovering, visibleIconCount])
}, [selectedIconIndex, isUserHovering, visibleIconCount])

/**
* Handle mouse enter on icon container
Expand All @@ -246,6 +273,37 @@ export default function Hero() {
}
}

const activeIconIndex =
selectedIconIndex !== null
? selectedIconIndex
: isUserHovering && lastHoveredIndex !== null
? lastHoveredIndex
: autoHoverIndex
const pillTargetIndex = Math.max(
0,
Math.min(activeIconIndex, visibleIconCount - 1)
)

React.useLayoutEffect(() => {
const container = iconRowRef.current
const target = buttonRefs.current[pillTargetIndex]
if (!container || !target) return
const cr = container.getBoundingClientRect()
const tr = target.getBoundingClientRect()
setPillLayout({
left: tr.left - cr.left,
top: tr.top - cr.top,
width: tr.width,
height: tr.height,
})
}, [pillTargetIndex, layoutVersion, visibleIconCount])

React.useEffect(() => {
const onResize = () => setLayoutVersion((v) => v + 1)
window.addEventListener('resize', onResize)
return () => window.removeEventListener('resize', onResize)
}, [])

/**
* Handle form submission
*/
Expand Down Expand Up @@ -377,24 +435,50 @@ 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={iconRowRef}
className='relative flex items-center justify-center gap-[2px] pt-[18px] sm:pt-[32px]'
onMouseEnter={handleIconContainerMouseEnter}
onMouseLeave={handleIconContainerMouseLeave}
>
{/* Service integration buttons */}
{pillLayout !== null && (
<motion.div
className='pointer-events-none absolute rounded-xl border border-[#E5E5E5] shadow-[0_2px_4px_0_rgba(0,0,0,0.08)]'
initial={false}
animate={{
left: pillLayout.left,
top: pillLayout.top,
width: pillLayout.width,
height: pillLayout.height,
}}
transition={{
type: 'spring',
stiffness: 325,
damping: 33,
mass: 1.12,
}}
/>
)}
{serviceIcons.slice(0, visibleIconCount).map((service, index) => {
const Icon = service.icon
return (
<IconButton
<div
key={service.key}
aria-label={service.label}
onClick={() => handleServiceClick(service.key as keyof typeof SERVICE_TEMPLATES)}
onMouseEnter={() => setLastHoveredIndex(index)}
style={service.style}
isAutoHovered={!isUserHovering && index === autoHoverIndex}
ref={(el) => {
buttonRefs.current[index] = el
}}
>
<Icon className='h-5 w-5 sm:h-6 sm:w-6' />
</IconButton>
<IconButton
aria-label={service.label}
onClick={() =>
handleServiceClick(index, service.key as keyof typeof SERVICE_TEMPLATES)
}
onMouseEnter={() => setLastHoveredIndex(index)}
style={service.style}
highlightFromParent
>
<Icon className='h-5 w-5 sm:h-6 sm:w-6' />
</IconButton>
Comment on lines +470 to +480
Copy link
Contributor

Choose a reason for hiding this comment

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

IconButton applies border-[#E5E5E5] shadow-[0_2px_4px_0_rgba(0,0,0,0.08)] immediately when isAutoHovered=true (with a 300ms CSS transition). The sliding pill carries the same border/shadow. During the spring animation from icon A to icon B:

  1. Icon B's button immediately renders its own border (because isAutoHovered flipped to true)
  2. The pill is still mid-slide between A and B

This creates two visible bordered highlights simultaneously — one static at B (the button's own border) and one sliding (the pill) — which defeats the purpose of the smooth transition.

Since the pill is now the sole source of the highlight, consider suppressing the isAutoHovered border/shadow on IconButton:

Suggested change
<IconButton
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>
<IconButton
aria-label={service.label}
onClick={() => handleServiceClick(service.key as keyof typeof SERVICE_TEMPLATES)}
onMouseEnter={() => setLastHoveredIndex(index)}
style={service.style}
isAutoHovered={false}
>
<Icon className='h-5 w-5 sm:h-6 sm:w-6' />
</IconButton>

Alternatively, update IconButton to not apply the border/shadow when isAutoHovered=true so the pill is the only visual indicator.

</div>
)
})}
</div>
Expand Down