All PromptsAll Prompts
ui componentnav
Bubble Menu
Интерактивное меню-пузырь с плавными анимациями и эффектами при наведении. UI-компонент для навигации.
by Zhou JasonLive Preview
Prompt
# Bubble Menu
You are given a task to integrate an existing React component in the codebase
~~~/README.md
# BubbleMenu
A playful and interactive bubble-style navigation menu with floating pill animations and staggered reveals.
## Dependencies
- `gsap`: ^3.12.5
- `react`: ^18.2.0
- `lucide-react`: ^0.454.0
## Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `logo` | `ReactNode \| string` | - | The logo to display in the menu bar. |
| `items` | `MenuItem[]` | `DEFAULT_ITEMS` | Array of menu items with labels, links, and hover styles. |
| `onMenuClick` | `(open: boolean) => void` | - | Callback triggered when menu opens or closes. |
| `menuBg` | `string` | `"#fff"` | Background color for menu elements. |
| `menuContentColor` | `string` | `"#111"` | Text and icon color for menu elements. |
| `useFixedPosition` | `boolean` | `false` | Whether to use `fixed` or `absolute` positioning for the menu. |
| `animationDuration` | `number` | `0.5` | Duration of the entrance animation in seconds. |
| `staggerDelay` | `number` | `0.12` | Delay between each menu item animation. |
## Usage
```tsx
import { BubbleMenu } from '@/sd-components/44f3e52f-e3e9-47b9-b3be-d8dc5f6e29eb';
const items = [
{ label: 'home', href: '/', rotation: -5 },
{ label: 'work', href: '/work', rotation: 5, hoverStyles: { bgColor: '#000', textColor: '#fff' } }
];
function App() {
return (
<BubbleMenu
logo="Logo"
items={items}
/>
);
}
```
~~~
~~~/src/App.tsx
import { BubbleMenu } from './Component';
export default function App() {
const items = [
{
label: 'home',
href: '#',
ariaLabel: 'Home',
rotation: -8,
hoverStyles: { bgColor: '#3b82f6', textColor: '#ffffff' }
},
{
label: 'about',
href: '#',
ariaLabel: 'About',
rotation: 8,
hoverStyles: { bgColor: '#10b981', textColor: '#ffffff' }
},
{
label: 'projects',
href: '#',
ariaLabel: 'Projects',
rotation: 8,
hoverStyles: { bgColor: '#f59e0b', textColor: '#ffffff' }
},
{
label: 'blog',
href: '#',
ariaLabel: 'Blog',
rotation: 8,
hoverStyles: { bgColor: '#ef4444', textColor: '#ffffff' }
},
{
label: 'contact',
href: '#',
ariaLabel: 'Contact',
rotation: -8,
hoverStyles: { bgColor: '#8b5cf6', textColor: '#ffffff' }
}
];
return (
<div className="min-h-screen bg-[#F9F9F9] flex flex-col items-center justify-center p-20">
<h1 className="text-4xl font-medium mb-12 text-[#1A1A1B] tracking-tight">
Bubble Menu
</h1>
<div className="relative w-full max-w-4xl h-[600px] border border-black/5 rounded-[40px] bg-white shadow-[0_20px_40px_rgba(0,0,0,0.05)] overflow-hidden">
<BubbleMenu
logo={<span className="font-bold text-2xl tracking-tighter">RB</span>}
items={items}
menuAriaLabel="Toggle navigation"
menuBg="#ffffff"
menuContentColor="#111111"
useFixedPosition={false}
animationEase="back.out(1.5)"
animationDuration={0.5}
staggerDelay={0.12}
/>
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<p className="text-black/20 font-medium">Click the menu button to expand</p>
</div>
</div>
<button
onClick={() => window.location.reload()}
className="mt-12 px-6 py-2 rounded-full border border-black/10 hover:bg-black/5 transition-colors text-sm font-medium flex items-center gap-2"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M3 21v-5h5"/></svg>
Replay
</button>
</div>
);
}
~~~
~~~/package.json
{
"name": "bubble-menu-showcase",
"description": "Interactive bubble menu animation showcase",
"dependencies": {
"gsap": "^3.12.5",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"lucide-react": "^0.454.0"
}
}
~~~
~~~/src/Component.tsx
/**
* BubbleMenu component
* A playful and interactive navigation menu featuring floating "bubble" pills.
*
* Features:
* - GSAP-powered staggered animations
* - Responsive design (stacked on mobile, grid on desktop)
* - Customizable colors, rotations, and animation timings
* - Accessible ARIA attributes
*/
import type { CSSProperties, ReactNode } from 'react';
import { useEffect, useRef, useState } from 'react';
import { gsap } from 'gsap';
export type MenuItem = {
label: string;
href: string;
ariaLabel?: string;
rotation?: number;
hoverStyles?: {
bgColor?: string;
textColor?: string;
};
};
export type BubbleMenuProps = {
logo: ReactNode | string;
onMenuClick?: (open: boolean) => void;
className?: string;
style?: CSSProperties;
menuAriaLabel?: string;
menuBg?: string;
menuContentColor?: string;
useFixedPosition?: boolean;
items?: MenuItem[];
animationEase?: string;
animationDuration?: number;
staggerDelay?: number;
};
const DEFAULT_ITEMS: MenuItem[] = [
{
label: 'home',
href: '#',
ariaLabel: 'Home',
rotation: -8,
hoverStyles: { bgColor: '#3b82f6', textColor: '#ffffff' }
},
{
label: 'about',
href: '#',
ariaLabel: 'About',
rotation: 8,
hoverStyles: { bgColor: '#10b981', textColor: '#ffffff' }
},
{
label: 'projects',
href: '#',
ariaLabel: 'Documentation',
rotation: 8,
hoverStyles: { bgColor: '#f59e0b', textColor: '#ffffff' }
},
{
label: 'blog',
href: '#',
ariaLabel: 'Blog',
rotation: 8,
hoverStyles: { bgColor: '#ef4444', textColor: '#ffffff' }
},
{
label: 'contact',
href: '#',
ariaLabel: 'Contact',
rotation: -8,
hoverStyles: { bgColor: '#8b5cf6', textColor: '#ffffff' }
}
];
export function BubbleMenu({
logo,
onMenuClick,
className,
style,
menuAriaLabel = 'Toggle menu',
menuBg = '#fff',
menuContentColor = '#111',
useFixedPosition = false,
items,
animationEase = 'back.out(1.5)',
animationDuration = 0.5,
staggerDelay = 0.12
}: BubbleMenuProps) {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [showOverlay, setShowOverlay] = useState(false);
const overlayRef = useRef<HTMLDivElement>(null);
const bubblesRef = useRef<HTMLAnchorElement[]>([]);
const labelRefs = useRef<HTMLSpanElement[]>([]);
const menuItems = items?.length ? items : DEFAULT_ITEMS;
const containerClassName = [
'bubble-menu',
useFixedPosition ? 'fixed' : 'absolute',
'left-0 right-0 top-8',
'flex items-center justify-between',
'gap-4 px-8',
'pointer-events-none',
'z-[1001]',
className
]
.filter(Boolean)
.join(' ');
const handleToggle = () => {
const nextState = !isMenuOpen;
if (nextState) setShowOverlay(true);
setIsMenuOpen(nextState);
onMenuClick?.(nextState);
};
useEffect(() => {
const overlay = overlayRef.current;
const bubbles = bubblesRef.current.filter(Boolean);
const labels = labelRefs.current.filter(Boolean);
if (!overlay || !bubbles.length) return;
if (isMenuOpen) {
gsap.set(overlay, { display: 'flex' });
gsap.killTweensOf([...bubbles, ...labels]);
gsap.set(bubbles, { scale: 0, transformOrigin: '50% 50%' });
gsap.set(labels, { y: 24, autoAlpha: 0 });
bubbles.forEach((bubble, i) => {
const delay = i * staggerDelay + gsap.utils.random(-0.05, 0.05);
const tl = gsap.timeline({ delay });
tl.to(bubble, {
scale: 1,
duration: animationDuration,
ease: animationEase
});
if (labels[i]) {
tl.to(
labels[i],
{
y: 0,
autoAlpha: 1,
duration: animationDuration,
ease: 'power3.out'
},
'-=' + animationDuration * 0.9
);
}
});
} else if (showOverlay) {
gsap.killTweensOf([...bubbles, ...labels]);
gsap.to(labels, {
y: 24,
autoAlpha: 0,
duration: 0.2,
ease: 'power3.in'
});
gsap.to(bubbles, {
scale: 0,
duration: 0.2,
ease: 'power3.in',
onComplete: () => {
gsap.set(overlay, { display: 'none' });
setShowOverlay(false);
}
});
}
}, [isMenuOpen, showOverlay, animationEase, animationDuration, staggerDelay]);
useEffect(() => {
const handleResize = () => {
if (isMenuOpen) {
const bubbles = bubblesRef.current.filter(Boolean);
const isDesktop = window.innerWidth >= 900;
bubbles.forEach((bubble, i) => {
const item = menuItems[i];
if (bubble && item) {
const rotation = isDesktop ? (item.rotation ?? 0) : 0;
gsap.set(bubble, { rotation });
}
});
}
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [isMenuOpen, menuItems]);
return (
<>
<style>{`
.bubble-menu .menu-line {
transition: transform 0.3s ease, opacity 0.3s ease;
transform-origin: center;
}
.bubble-menu-items .pill-list .pill-col:nth-child(4):nth-last-child(2) {
margin-left: calc(100% / 6);
}
.bubble-menu-items .pill-list .pill-col:nth-child(4):last-child {
margin-left: calc(100% / 3);
}
@media (min-width: 900px) {
.bubble-menu-items .pill-link {
transform: rotate(var(--item-rot));
}
.bubble-menu-items .pill-link:hover {
transform: rotate(var(--item-rot)) scale(1.06);
background: var(--hover-bg) !important;
color: var(--hover-color) !important;
}
.bubble-menu-items .pill-link:active {
transform: rotate(var(--item-rot)) scale(.94);
}
}
@media (max-width: 899px) {
.bubble-menu-items {
padding-top: 120px;
align-items: flex-start;
}
.bubble-menu-items .pill-list {
row-gap: 16px;
}
.bubble-menu-items .pill-list .pill-col {
flex: 0 0 100% !important;
margin-left: 0 !important;
overflow: visible;
}
.bubble-menu-items .pill-link {
font-size: clamp(1.2rem, 3vw, 4rem);
padding: clamp(1rem, 2vw, 2rem) 0;
min-height: 80px !important;
}
.bubble-menu-items .pill-link:hover {
transform: scale(1.06);
background: var(--hover-bg);
color: var(--hover-color);
}
.bubble-menu-items .pill-link:active {
transform: scale(.94);
}
}
`}</style>
<nav className={containerClassName} style={style} aria-label="Main navigation">
<div
className={[
'bubble logo-bubble',
'inline-flex items-center justify-center',
'rounded-full',
'bg-white',
'shadow-[0_4px_16px_rgba(0,0,0,0.12)]',
'pointer-events-auto',
'h-12 md:h-14',
'px-4 md:px-8',
'gap-2',
'will-change-transform'
].join(' ')}
aria-label="Logo"
style={{
background: menuBg,
minHeight: '48px',
borderRadius: '9999px'
}}
>
<span
className={['logo-content', 'inline-flex items-center justify-center', 'w-[120px] h-full'].join(' ')}
style={
{
['--logo-max-height']: '60%',
['--logo-max-width']: '100%'
} as CSSProperties
}
>
{typeof logo === 'string' ? (
<img src={logo} alt="Logo" className="bubble-logo max-h-[60%] max-w-full object-contain block" />
) : (
logo
)}
</span>
</div>
<button
type="button"
className={[
'bubble toggle-bubble menu-btn',
isMenuOpen ? 'open' : '',
'inline-flex flex-col items-center justify-center',
'rounded-full',
'bg-white',
'shadow-[0_4px_16px_rgba(0,0,0,0.12)]',
'pointer-events-auto',
'w-12 h-12 md:w-14 md:h-14',
'border-0 cursor-pointer p-0',
'will-change-transform'
].join(' ')}
onClick={handleToggle}
aria-label={menuAriaLabel}
aria-pressed={isMenuOpen}
style={{ background: menuBg }}
>
<span
className="menu-line block mx-auto rounded-[2px]"
style={{
width: 26,
height: 2,
background: menuContentColor,
transform: isMenuOpen ? 'translateY(4px) rotate(45deg)' : 'none'
}}
/>
<span
className="menu-line short block mx-auto rounded-[2px]"
style={{
marginTop: '6px',
width: 26,
height: 2,
background: menuContentColor,
transform: isMenuOpen ? 'translateY(-4px) rotate(-45deg)' : 'none'
}}
/>
</button>
</nav>
{showOverlay && (
<div
ref={overlayRef}
className={[
'bubble-menu-items',
useFixedPosition ? 'fixed' : 'absolute',
'inset-0',
'flex items-center justify-center',
'pointer-events-none',
'z-[1000]'
].join(' ')}
aria-hidden={!isMenuOpen}
>
<ul
className={[
'pill-list',
'list-none m-0 px-6',
'w-full max-w-[1600px] mx-auto',
'flex flex-wrap',
'gap-x-0 gap-y-1',
'pointer-events-auto'
].join(' ')}
role="menu"
aria-label="Menu links"
>
{menuItems.map((item, idx) => (
<li
key={idx}
role="none"
className={[
'pill-col',
'flex justify-center items-stretch',
'[flex:0_0_calc(100%/3)]',
'box-border'
].join(' ')}
>
<a
role="menuitem"
href={item.href}
aria-label={item.ariaLabel || item.label}
className={[
'pill-link',
'w-full',
'rounded-[999px]',
'no-underline',
'bg-white',
'text-inherit',
'shadow-[0_4px_14px_rgba(0,0,0,0.10)]',
'flex items-center justify-center',
'relative',
'transition-[background,color] duration-300 ease-in-out',
'box-border',
'whitespace-nowrap overflow-hidden'
].join(' ')}
style={
{
['--item-rot']: `${item.rotation ?? 0}deg`,
['--pill-bg']: menuBg,
['--pill-color']: menuContentColor,
['--hover-bg']: item.hoverStyles?.bgColor || '#f3f4f6',
['--hover-color']: item.hoverStyles?.textColor || menuContentColor,
background: 'var(--pill-bg)',
color: 'var(--pill-color)',
minHeight: '160px',
padding: 'clamp(1.5rem, 3vw, 8rem) 0',
fontSize: 'clamp(1.5rem, 4vw, 4rem)',
fontWeight: 400,
lineHeight: 1,
willChange: 'transform',
} as CSSProperties
}
ref={el => {
if (el) bubblesRef.current[idx] = el;
}}
>
<span
className="pill-label inline-block"
style={{
willChange: 'transform, opacity',
height: '1.2em',
lineHeight: 1.2
}}
ref={el => {
if (el) labelRefs.current[idx] = el;
}}
>
{item.label}
</span>
</a>
</li>
))}
</ul>
</div>
)}
</>
);
}
export default BubbleMenu;
~~~
Implementation Guidelines
1. Analyze the component structure, styling, animation implementations
2. Review the component's arguments and state
3. Think through what is the best place to adopt this component/style into the design we are doing
4. Then adopt the component/design to our current system
Help me integrate this into my design