Make it stand out.
Whatever it is, the way you tell your story online can make all the difference.
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from "react"
import { addPropertyControls, ControlType, useIsStaticRenderer } from "framer"
import { useInView } from "framer-motion"
// User request: Create a new Framer code component named Global Journey Globe. It must render a self-contained interactive 3D-style globe suitable for a dark minimal portfolio section: a subtle rotating wireframe/landmass-like sphere on a transparent canvas, with animated glowing white curved flight arcs connecting Paris to Barcelona and Lisbon to London. Motion should be slow and ambient by default; users should be able to drag to rotate the globe. No labels, controls, pop-ups, icons, buttons, or tooltips. Keep it accessible with an aria label. Expose controls for width/height if feasible, motionSpeed defaulting slow, globeColor, arcColor, and background transparency. Avoid external runtime dependencies and remote data/API calls; ensure it renders reliably inside Framer and is responsive.
interface MyComponentProps {
width: number
height: number
motionSpeed: number
globeColor: string
arcColor: string
backgroundTransparency: number
ariaLabel: string
style?: React.CSSProperties
}
type GeoPoint = { lat: number; lon: number }
type Point3 = { x: number; y: number; z: number }
type Point2 = { x: number; y: number; z: number; visible: boolean }
const FALLBACK_SIZE = 420
const CITIES = {
paris: { lat: 48.8566, lon: 2.3522 },
barcelona: { lat: 41.3874, lon: 2.1686 },
lisbon: { lat: 38.7223, lon: -9.1393 },
london: { lat: 51.5072, lon: -0.1276 },
}
const CONTINENT_STROKES: GeoPoint[][] = [
[
{ lat: 36, lon: -9 },
{ lat: 44, lon: -5 },
{ lat: 50, lon: 2 },
{ lat: 54, lon: 8 },
{ lat: 58, lon: 18 },
{ lat: 55, lon: 27 },
{ lat: 48, lon: 34 },
{ lat: 42, lon: 27 },
{ lat: 38, lon: 15 },
{ lat: 36, lon: -2 },
{ lat: 36, lon: -9 },
],
[
{ lat: 15, lon: -18 },
{ lat: 4, lon: -8 },
{ lat: -5, lon: 10 },
{ lat: -20, lon: 22 },
{ lat: -34, lon: 19 },
{ lat: -30, lon: 8 },
{ lat: -13, lon: -4 },
{ lat: 0, lon: -12 },
{ lat: 15, lon: -18 },
],
]
/**
* @framerSupportedLayoutWidth fixed
* @framerSupportedLayoutHeight fixed
*/
export default function GlobalJourneyGlobe(props: MyComponentProps) {
const {
width,
height,
motionSpeed,
globeColor,
arcColor,
backgroundTransparency,
ariaLabel,
style,
} = props
const isStatic = useIsStaticRenderer()
const wrapperRef = useRef(null)
const inView = useInView(wrapperRef, { amount: 0.2 })
const canvasRef = useRef(null)
const requestRef = useRef(null)
const yawRef = useRef(-0.35)
const pitchRef = useRef(0.2)
const dragRef = useRef(false)
const lastPointerRef = useRef({ x: 0, y: 0 })
const sphereLines = useMemo(() => {
const meridians: GeoPoint[][] = []
const parallels: GeoPoint[][] = []
for (let lon = -180; lon < 180; lon += 20) {
const line: GeoPoint[] = []
for (let lat = -80; lat <= 80; lat += 4) line.push({ lat, lon })
meridians.push(line)
}
for (let lat = -70; lat <= 70; lat += 14) {
const line: GeoPoint[] = []
for (let lon = -180; lon <= 180; lon += 4) line.push({ lat, lon })
parallels.push(line)
}
return [...meridians, ...parallels]
}, [])
const geoTo3D = useCallback((lat: number, lon: number, radius = 1): Point3 => {
const latRad = (lat * Math.PI) / 180
const lonRad = (lon * Math.PI) / 180
return {
x: radius * Math.cos(latRad) * Math.sin(lonRad),
y: radius * Math.sin(latRad),
z: radius * Math.cos(latRad) * Math.cos(lonRad),
}
}, [])
const rotatePoint = useCallback((point: Point3, yaw: number, pitch: number): Point3 => {
const cy = Math.cos(yaw)
const sy = Math.sin(yaw)
const cp = Math.cos(pitch)
const sp = Math.sin(pitch)
const x1 = point.x * cy - point.z * sy
const z1 = point.x * sy + point.z * cy
const y1 = point.y
return {
x: x1,
y: y1 * cp - z1 * sp,
z: y1 * sp + z1 * cp,
}
}, [])
const project = useCallback(
(point: Point3, radiusPx: number, centerX: number, centerY: number): Point2 => {
const perspective = 2.6
const scale = perspective / (perspective - point.z)
return {
x: centerX + point.x * radiusPx * scale,
y: centerY - point.y * radiusPx * scale,
z: point.z,
visible: point.z > -0.25,
}
},
[]
)
const makeArcPoints = useCallback(
(from: GeoPoint, to: GeoPoint, segments = 80): Point3[] => {
const points: Point3[] = []
for (let i = 0; i <= segments; i++) {
const t = i / segments
const lat = from.lat + (to.lat - from.lat) * t
const lon = from.lon + (to.lon - from.lon) * t
const elevation = 1 + Math.sin(Math.PI * t) * 0.24
points.push(geoTo3D(lat, lon, elevation))
}
return points
},
[geoTo3D]
)
const arcs = useMemo(
() => [
makeArcPoints(CITIES.paris, CITIES.barcelona),
makeArcPoints(CITIES.lisbon, CITIES.london),
],
[makeArcPoints]
)
const drawGlobe = useCallback(
(timeMs: number, allowAnimation = true) => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext("2d")
if (!ctx) return
const dpr = typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1
const measuredWidth = wrapperRef.current?.clientWidth ?? 0
const measuredHeight = wrapperRef.current?.clientHeight ?? 0
const w = Math.max(1, measuredWidth || width || FALLBACK_SIZE)
const h = Math.max(1, measuredHeight || height || FALLBACK_SIZE)
if (canvas.width !== Math.floor(w * dpr) || canvas.height !== Math.floor(h * dpr)) {
canvas.width = Math.floor(w * dpr)
canvas.height = Math.floor(h * dpr)
canvas.style.width = `${w}px`
canvas.style.height = `${h}px`
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
const alpha = Math.max(0, Math.min(1, 1 - backgroundTransparency))
ctx.clearRect(0, 0, w, h)
if (alpha > 0) {
ctx.fillStyle = `rgba(0, 0, 0, ${alpha})`
ctx.fillRect(0, 0, w, h)
}
const t = timeMs * 0.001
if (!dragRef.current && !isStatic && allowAnimation) {
yawRef.current += 0.0016 * motionSpeed
pitchRef.current = 0.2 + Math.sin(t * 0.2) * 0.05
}
const radius = Math.min(w, h) * 0.34
const cx = w / 2
const cy = h / 2
ctx.beginPath()
ctx.arc(cx, cy, radius, 0, Math.PI * 2)
ctx.strokeStyle = globeColor
ctx.globalAlpha = 0.2
ctx.lineWidth = 1
ctx.stroke()
ctx.globalAlpha = 1
sphereLines.forEach((line) => {
let started = false
ctx.beginPath()
line.forEach((geo) => {
const p = project(
rotatePoint(geoTo3D(geo.lat, geo.lon), yawRef.current, pitchRef.current),
radius,
cx,
cy
)
if (p.visible) {
if (!started) {
ctx.moveTo(p.x, p.y)
started = true
} else {
ctx.lineTo(p.x, p.y)
}
} else if (started) {
started = false
}
})
ctx.strokeStyle = globeColor
ctx.globalAlpha = 0.11
ctx.lineWidth = 1
ctx.stroke()
})
CONTINENT_STROKES.forEach((shape) => {
ctx.beginPath()
shape.forEach((geo, idx) => {
const p = project(
rotatePoint(geoTo3D(geo.lat, geo.lon, 0.99), yawRef.current, pitchRef.current),
radius,
cx,
cy
)
if (idx === 0) ctx.moveTo(p.x, p.y)
else ctx.lineTo(p.x, p.y)
})
ctx.closePath()
ctx.fillStyle = globeColor
ctx.globalAlpha = 0.06
ctx.fill()
ctx.strokeStyle = globeColor
ctx.globalAlpha = 0.2
ctx.lineWidth = 1
ctx.stroke()
})
const pulse = 0.5 + 0.5 * Math.sin(t * 1.2)
arcs.forEach((arc, arcIndex) => {
const reveal = (t * 0.07 * motionSpeed + arcIndex * 0.25) % 1
const revealCount = Math.max(8, Math.floor(arc.length * reveal))
ctx.beginPath()
for (let i = 0; i < revealCount; i++) {
const p = project(
rotatePoint(arc[i], yawRef.current, pitchRef.current),
radius,
cx,
cy
)
if (!p.visible && i !== 0) continue
if (i === 0) ctx.moveTo(p.x, p.y)
else ctx.lineTo(p.x, p.y)
}
ctx.strokeStyle = arcColor
ctx.lineWidth = 5
ctx.globalAlpha = 0.1 + pulse * 0.08
ctx.stroke()
ctx.strokeStyle = arcColor
ctx.lineWidth = 1.8
ctx.globalAlpha = 0.82
ctx.stroke()
})
ctx.globalAlpha = 1
},
[
arcColor,
arcs,
backgroundTransparency,
geoTo3D,
globeColor,
height,
isStatic,
motionSpeed,
project,
rotatePoint,
sphereLines,
width,
]
)
useEffect(() => {
if (typeof window === "undefined") return
drawGlobe(0, false)
}, [drawGlobe])
useLayoutEffect(() => {
if (typeof window === "undefined") return
let tries = 0
let rafId = 0
const drawWhenReady = () => {
drawGlobe(0, false)
const measuredWidth = wrapperRef.current?.clientWidth ?? 0
const measuredHeight = wrapperRef.current?.clientHeight ?? 0
tries += 1
if ((measuredWidth < 2 || measuredHeight < 2) && tries < 12) {
rafId = window.requestAnimationFrame(drawWhenReady)
}
}
rafId = window.requestAnimationFrame(drawWhenReady)
let resizeObserver: ResizeObserver | null = null
if (typeof ResizeObserver !== "undefined" && wrapperRef.current) {
resizeObserver = new ResizeObserver(() => {
drawGlobe(0, false)
})
resizeObserver.observe(wrapperRef.current)
}
return () => {
window.cancelAnimationFrame(rafId)
resizeObserver?.disconnect()
}
}, [drawGlobe])
useEffect(() => {
if (typeof window === "undefined") return
if (isStatic || !inView) return
const loop = (time: number) => {
drawGlobe(time)
requestRef.current = window.requestAnimationFrame(loop)
}
requestRef.current = window.requestAnimationFrame(loop)
return () => {
if (requestRef.current !== null) {
window.cancelAnimationFrame(requestRef.current)
}
}
}, [drawGlobe, inView, isStatic])
const onPointerDown = useCallback((event: React.PointerEvent) => {
dragRef.current = true
lastPointerRef.current = { x: event.clientX, y: event.clientY }
}, [])
const onPointerMove = useCallback(
(event: React.PointerEvent) => {
if (!dragRef.current) return
const dx = event.clientX - lastPointerRef.current.x
const dy = event.clientY - lastPointerRef.current.y
lastPointerRef.current = { x: event.clientX, y: event.clientY }
yawRef.current += dx * 0.006
pitchRef.current = Math.max(-1.1, Math.min(1.1, pitchRef.current + dy * 0.004))
drawGlobe(performance.now())
},
[drawGlobe]
)
const onPointerUp = useCallback(() => {
dragRef.current = false
}, [])
const resolvedWidth = width > 0 ? width : FALLBACK_SIZE
const resolvedHeight = height > 0 ? height : FALLBACK_SIZE
return (
)
}
addPropertyControls(GlobalJourneyGlobe, {
width: {
type: ControlType.Number,
title: "Width",
defaultValue: 420,
min: 240,
max: 1000,
step: 1,
unit: "px",
},
height: {
type: ControlType.Number,
title: "Height",
defaultValue: 420,
min: 240,
max: 1000,
step: 1,
unit: "px",
},
motionSpeed: {
type: ControlType.Number,
title: "Motion",
defaultValue: 0.35,
min: 0.1,
max: 2,
step: 0.05,
},
globeColor: {
type: ControlType.Color,
title: "Globe",
defaultValue: "#FFFFFF",
},
arcColor: {
type: ControlType.Color,
title: "Arcs",
defaultValue: "#FFFFFF",
},
backgroundTransparency: {
type: ControlType.Number,
title: "Bg Alpha",
defaultValue: 1,
min: 0,
max: 1,
step: 0.01,
},
ariaLabel: {
type: ControlType.String,
title: "Aria",
defaultValue: "Interactive rotating globe with flight paths",
},
})
Make it stand out.
It all begins with an idea. Maybe you want to launch a business. Maybe you want to turn a hobby into something more. Or maybe you have a creative project to share with the world. Whatever it is, the way you tell your story online can make all the difference.
Make it stand out.
It all begins with an idea. Maybe you want to launch a business. Maybe you want to turn a hobby into something more. Or maybe you have a creative project to share with the world. Whatever it is, the way you tell your story online can make all the difference.
Make it stand out.
It all begins with an idea. Maybe you want to launch a business. Maybe you want to turn a hobby into something more. Or maybe you have a creative project to share with the world. Whatever it is, the way you tell your story online can make all the difference.
“It all begins with an idea. Maybe you want to launch a business. Maybe you want to turn a hobby into something more. Or maybe you have a creative project to share with the world. Whatever it is, the way you tell your story online can make all the difference.”
— Squarespace