Installation
Add this component to your project using the CLI:
terminal
npx vui-registry-cli-v1 add ios-liquid-icon-buttonSource Code
ios-liquid-icon-button.tsx
'use client'
import React, { useMemo, useState } from 'react'
import { MessageCircle, Camera, LayoutGrid, Plus } from 'lucide-react'
type IconAction = { icon: React.ReactNode; label: string }
export default function OrbitalActionHub({
actions = [
{ icon: <MessageCircle size={24} strokeWidth={2} />, label: 'Messages' },
{ icon: <Camera size={24} strokeWidth={2} />, label: 'Camera' },
{ icon: <LayoutGrid size={24} strokeWidth={2} />, label: 'Apps' },
],
size = 90,
}) {
const [isOpen, setIsOpen] = useState(false)
const filterId = useMemo(() => `liquid-goo-${Math.random().toString(36).slice(2)}`, [])
const TRAVEL_DISTANCE = size * 1.6
const ANGLE_SPREAD = 52
const lerpCurve = 'cubic-bezier(0.23, 1, 0.32, 1)'
return (
<div className="relative flex items-center justify-center h-[550px] w-full select-none overflow-hidden">
{/* 1. ANIMATION SYSTEM */}
<style>{`
@keyframes slowSweep {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes softPulse {
0%, 100% { transform: translate(var(--tw-translate-x), var(--tw-translate-y)) scale(1); opacity: 0.6; }
50% { transform: translate(var(--tw-translate-x), var(--tw-translate-y)) scale(1.02); opacity: 0.3; }
}
.animate-slow-sweep {
animation: slowSweep var(--speed) linear infinite;
}
.animate-soft-pulse {
animation: softPulse 4s ease-in-out infinite;
}
`}</style>
{/* 2. DOTTED ORBITS (1px) & BEAMS (0.3px) */}
<div
className="absolute transition-all duration-[1200ms] ease-out pointer-events-none"
style={{
transform: `scale(${isOpen ? 1.15 : 0.9})`,
opacity: isOpen ? 0.5 : 0,
}}
>
<svg width="460" height="460" viewBox="0 0 400 400" fill="none">
<defs>
{/* Ultra-sharp gradient for the 0.3px beam */}
<linearGradient id="needle-beam" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="currentColor" stopOpacity="0" />
<stop offset="95%" stopColor="currentColor" stopOpacity="1" />
<stop offset="100%" stopColor="currentColor" stopOpacity="0" />
</linearGradient>
</defs>
{[190, 140, 85].map((radius, i) => (
<g key={radius} className="dark:text-white text-black">
{/* 1px Dotted Orbit Circumference */}
<circle
cx="200"
cy="200"
r={radius}
stroke="currentColor"
strokeWidth="1"
strokeDasharray="1 6"
opacity="0.15"
/>
{/* 0.3px Precision Beam */}
<g
className="animate-slow-sweep"
style={
{
transformOrigin: '200px 200px',
'--speed': `${18 + i * 10}s`,
} as any
}
>
<circle
cx="200"
cy="200"
r={radius}
stroke="url(#needle-beam)"
strokeWidth="0.3"
strokeDasharray="50 350"
strokeLinecap="round"
/>
</g>
</g>
))}
</svg>
</div>
{/* 3. LIQUID FILTER */}
<svg className="absolute w-0 h-0 invisible" aria-hidden="true">
<defs>
<filter id={filterId}>
<feGaussianBlur in="SourceGraphic" stdDeviation="11" result="blur" />
<feColorMatrix
in="blur"
mode="matrix"
values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 24 -12"
result="goo"
/>
<feComposite in="SourceGraphic" in2="goo" operator="atop" />
</filter>
</defs>
</svg>
{/* 4. LIQUID BLOB LAYER */}
<div
style={{ filter: `url(#${filterId})` }}
className="absolute inset-0 flex items-center justify-center pointer-events-none"
>
{actions.map((_, i) => {
const angle = (i - (actions.length - 1) / 2) * ANGLE_SPREAD
const rad = (angle * Math.PI) / 180
const x = Math.sin(rad) * (isOpen ? TRAVEL_DISTANCE : 0)
const y = -Math.cos(rad) * (isOpen ? TRAVEL_DISTANCE : 0)
return (
<div
key={`blob-${i}`}
className="absolute bg-card border border-border rounded-full shadow-sm"
style={{
width: size - 12,
height: size - 12,
transform: `translate(${x}px, ${y}px) scale(${isOpen ? 1 : 0.4})`,
transition: `transform 0.8s ${lerpCurve}, opacity 0.6s ease-out`,
opacity: isOpen ? 1 : 0,
}}
/>
)
})}
<div
className="bg-card border border-border rounded-full"
style={{
width: size,
height: size,
transform: isOpen ? 'scale(0.94)' : 'scale(1)',
transition: `transform 0.8s ${lerpCurve}`,
}}
/>
</div>
{/* 5. INTERACTIVE CONTENT & PULSING LABELS */}
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
{actions.map((action, i) => {
const angle = (i - (actions.length - 1) / 2) * ANGLE_SPREAD
const rad = (angle * Math.PI) / 180
const x = Math.sin(rad) * (isOpen ? TRAVEL_DISTANCE : 0)
const y = -Math.cos(rad) * (isOpen ? TRAVEL_DISTANCE : 0)
const lx = Math.sin(rad) * (isOpen ? TRAVEL_DISTANCE + 85 : 0)
const ly = -Math.cos(rad) * (isOpen ? TRAVEL_DISTANCE + 85 : 0)
return (
<React.Fragment key={`ui-${i}`}>
<button
className="absolute flex items-center justify-center pointer-events-auto text-foreground hover:scale-110 active:scale-95 transition-all"
style={{
width: size - 12,
height: size - 12,
transform: `translate(${x}px, ${y}px) scale(${isOpen ? 1 : 0})`,
opacity: isOpen ? 1 : 0,
transition: `transform 0.8s ${lerpCurve} ${isOpen ? i * 0.08 : 0}s, opacity 0.4s ease-out`,
}}
>
{action.icon}
</button>
<span
className={`absolute text-[9px] font-black tracking-[0.5em] uppercase text-muted-foreground ${isOpen ? 'animate-soft-pulse' : ''}`}
style={
{
'--tw-translate-x': `${lx}px`,
'--tw-translate-y': `${ly}px`,
transform: `translate(${lx}px, ${ly}px)`,
opacity: isOpen ? 0.5 : 0,
transition: `opacity 1.2s ${lerpCurve} ${isOpen ? 0.4 + i * 0.08 : 0}s`,
animationDelay: `${i * 0.6}s`,
} as any
}
>
{action.label}
</span>
</React.Fragment>
)
})}
</div>
{/* 6. MASTER TOGGLE */}
<button
onClick={() => setIsOpen(!isOpen)}
className="relative z-[100] bg-card border border-border rounded-full flex items-center justify-center active:scale-90 shadow-2xl transition-all duration-500"
style={{ width: size, height: size }}
>
<div
className="transition-transform duration-[800ms]"
style={{
transform: isOpen ? 'rotate(135deg)' : 'rotate(0deg)',
transitionTimingFunction: lerpCurve,
}}
>
<Plus size={size * 0.35} strokeWidth={2} className="text-foreground" />
</div>
<div className="absolute inset-0 rounded-full bg-gradient-to-tr from-transparent via-white/5 to-white/10 pointer-events-none" />
</button>
</div>
)
}
export { OrbitalActionHub as LiquidGlassActionHub }
export { OrbitalActionHub as IOSLiquidMenu }
export { OrbitalActionHub as IOSLiquidIconButton }

