Featured Globe
Interactive 3D globe with scroll-triggered text animations, sparkle effects, and smooth drag rotation.
EUNARY UI
Installation
Install dependencies
npm install motion clsx tailwind-merge cobe react-spring @tsparticles/react @tsparticles/engine @tsparticles/slim
Add util file
import { ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
Copy the source code
'use client';
import { useEffect, useRef } from 'react';
import createGlobe from 'cobe';
import { useSpring } from 'react-spring';
import { Sparkles } from './sparkles';
import {
motion,
useScroll,
useTransform,
useMotionTemplate,
} from 'motion/react';
import { cn } from '@/lib/utils';
interface props {
globeClassName?: string;
containerClassName?: string;
textClassName?: string;
text?: string;
textSize?: 'xl' | 'lg' | 'md' | 'sm';
globeSize?: number;
globeRotateDirection?: 'left' | 'right';
}
export const FeaturedGlobe = ({
globeClassName,
containerClassName,
textClassName,
text = 'EUNARY UI',
textSize = 'md',
globeSize = 500,
globeRotateDirection = 'right',
}: props) => {
const containerRef = useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({
target: containerRef,
offset: ['start end', 'end center'],
});
const textY = useTransform(
scrollYProgress,
[0, 0.25, 0.5, 0.75],
[300, 100, 0, -100]
);
const textOpacity = useTransform(
scrollYProgress,
[0, 0.25, 0.5, 0.75],
[0, 0, 0.5, 1]
);
const textScale = useTransform(
scrollYProgress,
[0, 0.25, 0.5, 0.75],
[0.8, 0.85, 0.9, 1]
);
const textBlur = useTransform(
scrollYProgress,
[0, 0.2, 0.4, 0.6],
[9, 6, 3, 0]
);
const getTextSizeClasses = () => {
switch (textSize) {
case 'sm':
return 'text-4xl sm:text-5xl md:text-6xl lg:text-7xl';
case 'md':
return 'text-5xl sm:text-6xl md:text-7xl lg:text-8xl';
case 'lg':
return 'text-6xl sm:text-7xl md:text-8xl lg:text-9xl';
case 'xl':
default:
return 'text-6xl sm:text-7xl md:text-8xl lg:text-9xl xl:text-[10rem]';
}
};
const textSizeClasses = getTextSizeClasses();
return (
<div className="relative h-full w-full">
<div
ref={containerRef}
className={cn(
'sticky flex min-h-[30rem] w-full items-center justify-center overflow-hidden',
containerClassName
)}
>
{/* Globe Component */}
<div className="absolute -bottom-48 z-20 flex aspect-[1] items-center justify-center">
<Globe
globeSize={globeSize}
globeClassName={globeClassName}
globeRotateDirection={globeRotateDirection}
/>
</div>
{/* Animated Text */}
<motion.div
style={{
y: textY,
opacity: textOpacity,
scale: textScale,
filter: useMotionTemplate`blur(${textBlur}px)`,
}}
className="absolute z-10 flex items-center justify-center text-nowrap"
>
<div
className={cn(
'bg-gradient-to-b from-white to-gray-600 bg-clip-text text-center font-bold text-transparent',
textSizeClasses,
textClassName
)}
>
{text}
</div>
</motion.div>
{/* Background Sparkles */}
<Sparkles
background="transparent"
minSize={0.4}
maxSize={1}
particleDensity={50}
className="z-0 h-full w-full"
particleColor="#ffffff"
speed={2}
/>
</div>
</div>
);
};
const Globe = ({
globeClassName,
globeSize,
globeRotateDirection,
}: {
globeClassName?: string;
globeSize: number;
globeRotateDirection?: 'left' | 'right';
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const pointerInteracting = useRef<number | null>(null);
const pointerInteractionMovement = useRef(0);
const [{ r }, api] = useSpring(() => ({
r: 0,
config: {
mass: 1,
tension: 280,
friction: 40,
precision: 0.001,
},
}));
useEffect(() => {
let phi = 0;
const width: number = globeSize;
if (!canvasRef.current) return;
const globe = createGlobe(canvasRef.current, {
devicePixelRatio: 2,
width: width * 2,
height: width * 2,
phi: 0,
theta: 0.3,
dark: 1,
diffuse: 3,
mapSamples: 16000,
mapBrightness: 1.2,
baseColor: [1, 1, 1],
markerColor: [40 / 255, 100 / 255, 215 / 255],
glowColor: [255 / 255, 255 / 255, 255 / 255],
markers: [
{
location: [28.61402, 77.22955], // Delhi
size: 0.1,
},
{
location: [40.75833, -73.99167], // New York
size: 0.2,
},
{
location: [22.575, 88.325], // Kolkata
size: 0.05,
},
{
location: [25.18333, 55.26667], // Dubai
size: 0.05,
},
],
onRender: (state) => {
// This prevents rotation while dragging
if (!pointerInteracting.current) {
// Called on every animation frame.
// `state` will be an empty object, return updated params.
globeRotateDirection === 'left'
? (phi -= 0.005)
: (phi += 0.005);
}
state.phi = phi + r.get();
state.width = width * 2;
state.height = width * 2;
},
});
setTimeout(
() => canvasRef.current && (canvasRef.current.style.opacity = '1')
);
return () => {
globe.destroy();
};
}, []);
return (
<div
className={cn('rounded-full', globeClassName)}
style={{
width: 500,
height: 500,
maxWidth: 600,
aspectRatio: 1,
margin: 'auto',
position: 'relative',
}}
>
<canvas
ref={canvasRef}
onPointerDown={(e) => {
pointerInteracting.current =
e.clientX - pointerInteractionMovement.current;
canvasRef.current &&
(canvasRef.current.style.cursor = 'grabbing');
}}
onPointerUp={() => {
pointerInteracting.current = null;
canvasRef.current &&
(canvasRef.current.style.cursor = 'grab');
}}
onPointerOut={() => {
pointerInteracting.current = null;
canvasRef.current &&
(canvasRef.current.style.cursor = 'grab');
}}
onMouseMove={(e) => {
if (pointerInteracting.current !== null) {
const delta = e.clientX - pointerInteracting.current;
pointerInteractionMovement.current = delta;
api.start({
r: delta / 200,
});
}
}}
onTouchMove={(e) => {
if (pointerInteracting.current !== null && e.touches[0]) {
const delta =
e.touches[0].clientX - pointerInteracting.current;
pointerInteractionMovement.current = delta;
api.start({
r: delta / 100,
});
}
}}
style={{
width: '100%',
height: '100%',
cursor: 'grab',
contain: 'layout paint size',
opacity: 0,
transition: 'opacity 1s ease',
}}
/>
</div>
);
};
'use client';
import { useEffect, useId, useState } from 'react';
import Particles, { initParticlesEngine } from '@tsparticles/react';
import { type Container, type ISourceOptions } from '@tsparticles/engine';
import { loadSlim } from '@tsparticles/slim';
import { cn } from '@/lib/utils';
import { motion, useAnimation } from 'motion/react';
type ParticlesProps = {
className?: string;
background?: string;
particleSize?: number;
minSize?: number;
maxSize?: number;
speed?: number;
particleColor?: string;
particleDensity?: number;
id?: any;
};
export const Sparkles = ({
className,
background,
minSize,
maxSize,
speed,
particleColor,
particleDensity,
id,
}: ParticlesProps) => {
const [init, setInit] = useState(false);
const generatedId = useId();
useEffect(() => {
initParticlesEngine(async (engine) => {
await loadSlim(engine);
}).then(() => {
setInit(true);
});
}, []);
const fadeInControls = useAnimation();
const particlesLoaded = async (container?: Container): Promise<void> => {
container &&
fadeInControls.start({
opacity: 1,
transition: { duration: 1 },
});
};
const options: ISourceOptions = {
background: {
color: {
value: background || '#0d47a1',
},
},
fullScreen: {
enable: false,
zIndex: 1,
},
particles: {
color: {
value: particleColor || '#ffffff',
},
move: {
angle: {
offset: 0,
value: 90,
},
center: {
x: 50,
y: 50,
mode: 'percent',
radius: 0,
},
enable: true,
random: false,
size: false,
speed: {
min: 0.1,
max: 1,
},
},
number: {
density: {
enable: true,
width: 400,
height: 400,
},
limit: {
mode: 'delete',
value: 0,
},
value: particleDensity || 120,
},
opacity: {
value: {
min: 0.1,
max: 1,
},
animation: {
enable: true,
speed: speed || 4,
sync: false,
mode: 'auto',
startValue: 'random',
destroy: 'none',
},
},
shape: {
close: true,
fill: true,
options: {},
type: 'circle',
},
size: {
value: {
min: minSize || 1,
max: maxSize || 3,
},
},
},
};
if (init) {
return (
<motion.div
animate={fadeInControls}
className={cn('opacity-0', className)}
>
<Particles
className="h-full w-full"
id={id || generatedId}
particlesLoaded={particlesLoaded}
options={options}
/>
</motion.div>
);
}
return <></>;
};
Props
Use the following props to customize the featured globe.
Prop | Type | Description |
---|---|---|
globeClassName | string | CSS class applied to the globe container |
containerClassName | string | CSS class applied to the main container wrapper |
textClassName | string | CSS class applied to the animated text element |
text | string | Text content displayed over the globe |
textSize | "xl" | "lg" | "md" | "sm" | Size of the text overlay |
globeSize | number | Size of the globe in pixels |
globeRotateDirection | "left" | "right" | Direction of the globe's automatic rotation |
Explore more components with Eunary
Discover and experiment with a variety of components to craft a stunning and seamless experience for your product.