-
Notifications
You must be signed in to change notification settings - Fork 3.5k
fix: smooth sliding highlight pill for hero service icons #3481
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 1 commit
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,38 +1,45 @@ | ||
| 'use client' | ||
|
|
||
| import type React from 'react' | ||
| import { motion } from 'framer-motion' | ||
|
|
||
| interface IconButtonProps { | ||
| children: React.ReactNode | ||
| onClick?: () => void | ||
| onMouseEnter?: () => void | ||
| onMouseLeave?: () => void | ||
| style?: React.CSSProperties | ||
| 'aria-label': string | ||
| isAutoHovered?: boolean | ||
| isActive?: boolean | ||
| } | ||
|
|
||
| export function IconButton({ | ||
| children, | ||
| onClick, | ||
| onMouseEnter, | ||
| onMouseLeave, | ||
| style, | ||
| 'aria-label': ariaLabel, | ||
| isAutoHovered = false, | ||
| isActive = false, | ||
| }: IconButtonProps) { | ||
| 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)]' | ||
| }`} | ||
| onMouseLeave={onMouseLeave} | ||
| className='relative flex items-center justify-center rounded-xl p-2 outline-none' | ||
| style={style} | ||
| > | ||
| {children} | ||
| {isActive && ( | ||
| <motion.div | ||
| layoutId='icon-highlight-pill' | ||
| className='absolute inset-0 rounded-xl border border-[#E5E5E5] shadow-[0_2px_4px_0_rgba(0,0,0,0.08)]' | ||
| transition={{ type: 'spring', stiffness: 400, damping: 30 }} | ||
| /> | ||
| )} | ||
| <span className='relative z-[1]'>{children}</span> | ||
| </button> | ||
| ) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -149,7 +149,7 @@ 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 [hoveredIndex, setHoveredIndex] = React.useState<number | null>(null) | ||
| const intervalRef = React.useRef<NodeJS.Timeout | null>(null) | ||
|
|
||
| /** | ||
|
|
@@ -225,6 +225,12 @@ export default function Hero() { | |
| } | ||
| }, [isUserHovering, visibleIconCount]) | ||
|
|
||
| /** | ||
| * The active icon index used to position the shared highlight pill. | ||
| * When the user is hovering, use their hovered icon; otherwise use auto-hover. | ||
| */ | ||
| const activeIconIndex = isUserHovering && hoveredIndex !== null ? hoveredIndex : autoHoverIndex | ||
|
|
||
| /** | ||
| * Handle mouse enter on icon container | ||
| */ | ||
|
|
@@ -240,9 +246,10 @@ export default function Hero() { | |
| */ | ||
| 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) | ||
|
cursor[bot] marked this conversation as resolved.
Outdated
|
||
| } | ||
|
Comment on lines
247
to
254
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. Stale closure: In React 18 with automatic batching, The reliable fix is to mirror the state into a |
||
| } | ||
|
|
||
|
|
@@ -389,9 +396,9 @@ export default function Hero() { | |
| key={service.key} | ||
| aria-label={service.label} | ||
| onClick={() => handleServiceClick(service.key as keyof typeof SERVICE_TEMPLATES)} | ||
| onMouseEnter={() => setLastHoveredIndex(index)} | ||
| onMouseEnter={() => setHoveredIndex(index)} | ||
| style={service.style} | ||
| isAutoHovered={!isUserHovering && index === autoHoverIndex} | ||
| isActive={index === activeIconIndex} | ||
| > | ||
| <Icon className='h-5 w-5 sm:h-6 sm:w-6' /> | ||
| </IconButton> | ||
|
|
||
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.
Unused
onMouseLeaveproponMouseLeaveis declared inIconButtonPropsand forwarded to the<button>element, buthero.tsxnever passes this prop when rendering<IconButton>. The mouse-leave behaviour is handled entirely by the containerdiv'sonMouseLeave. This dead prop adds noise to the interface and may confuse future maintainers.Consider removing it unless there's a planned use-case, or add a brief comment explaining why it's reserved for future use.