-
Notifications
You must be signed in to change notification settings - Fork 3.4k
feat(landing): smooth sliding pill for hero service icons #3469
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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' | ||||||||||||||||||||||||||||||||||||||
|
|
@@ -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]) | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
|
|
@@ -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]) | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| React.useEffect(() => { | ||||||||||||||||||||||||||||||||||||||
| if (textValue.trim().length === 0) setSelectedIconIndex(null) | ||||||||||||||||||||||||||||||||||||||
| }, [textValue]) | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||
| * Service icons array for easier indexing | ||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||
|
|
@@ -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 | ||||||||||||||||||||||||||||||||||||||
|
|
@@ -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 | ||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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
Suggested change
Alternatively, update |
||||||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||
| })} | ||||||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
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
visibleIconCountshrinks (e.g., resizing from desktop to mobile),selectedIconIndexis clamped tomaxIndexbuttextValueis 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 clampsselectedIconIndexto 5 (Supabase), so the pill highlights Supabase while the textarea still shows the Google Sheets template.Additional Locations (1)
apps/sim/app/(landing)/components/hero/hero.tsx#L275-L285