3D Earth with Real-Time ISS Tracking

Build a live ISS tracker on a 3D Earth using orbital mechanics.

Introduction

30 Second Summary

The International Space Station circles Earth every 90 minutes, traveling at 28,000 km/h. Right now, you could look up its exact position using the same orbital math that mission control uses.

In this project, you will build a real-time 3D visualization that tracks the ISS orbiting a photorealistic Earth globe in your browser. You will use actual orbital mechanics to compute its position every second, powered by live satellite data.

What You'll Build

Picture rotating a glowing 3D Earth with your mouse, watching a red dot trace the ISS path overhead, while a telemetry panel ticks off its altitude, velocity, and coordinates in real-time.

By the end of this project, you'll have:

  • A photorealistic 3D Earth you can rotate and zoom with your mouse, wrapped in a NASA Blue Marble texture with an atmospheric glow and starfield background.
  • A live ISS marker that visibly moves across the globe every second, powered by SGP4 orbit propagation using real TLE data from CelesTrak.
  • A glowing orbit trail showing the ISS path around Earth, plus a telemetry panel displaying latitude, longitude, altitude, and velocity updating in real-time.
  • Secret Mission: Calculate the real sun position to add day/night lighting, so the Earth shows an accurate daytime and nighttime split that shifts as you watch.

Are there any prerequisites?

Familiarity with React and TypeScript is helpful. No Three.js or 3D graphics experience required.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters to you.

Set Up the Project

Before we can render a 3D Earth or track any satellites, we need a React project that bundles and serves our code in the browser.

In this step, we'll scaffold a new project using Vite with the React TypeScript template. We'll also install the libraries for 3D rendering and orbital mechanics calculations.

In this step, get ready to:
  • Scaffold a React TypeScript project with Vite.
  • Install Three.js, React Three Fiber, and satellite.js.
  • Start the dev server and confirm everything runs.
Scaffold the Vite project

Vite is a modern build tool that bundles your React code and serves it to the browser with hot reloading. The React TypeScript template gives us a working starter app out of the box.

  • In VS Code, open a new terminal by pressing Ctrl+` (macOS and Windows).
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project by running this command:
npm create vite@latest orbitx -- --template react-ts

What will Vite ask you?

Vite may ask whether to use the Vite 8 beta. Select No to stick with the stable release.

It may also ask whether to install dependencies and start now. Select No here too. We'll install dependencies manually in the next substep so you can see exactly what gets added.

  • Move into the new project folder by running:
cd orbitx
  • Confirm the project was created by running:
ls

You should see files like package.json, tsconfig.json, index.html, and a src folder listed in the output.

Don't see these files?

Make sure you ran cd orbitx after the scaffold command. If the orbitx folder does not exist, re-run the scaffold command and check for error messages in the output.

Help me troubleshoot my Vite scaffold

Install 3D rendering and orbital mechanics dependencies

Our project needs five additional packages. Three.js is the 3D rendering engine. React Three Fiber wraps Three.js in React components. Drei provides pre-built helpers like camera controls. satellite.js handles the orbital mechanics math.

  • Install all five packages by running this command:
npm install three @react-three/fiber @react-three/drei satellite.js @types/three

What are these packages?

  • three is the core 3D rendering library for the web.
  • @react-three/fiber lets you build Three.js scenes using React components and hooks.
  • @react-three/drei provides ready-made helpers like orbit controls and star backgrounds.
  • satellite.js implements the SGP4 algorithm for calculating satellite positions from orbital data.
  • @types/three adds TypeScript type definitions for Three.js.

Once the install finishes, you should see a message showing the number of packages added (something like "added 50 packages").

Seeing permission or network errors?

If you see EACCES permission errors, try running the command with administrator privileges. If you see network timeout errors, check your internet connection and try again.

Help me fix my npm install error

Start the dev server

With all dependencies installed, we can boot up the development server. This compiles your TypeScript, bundles everything, and serves it on a local URL with hot reloading.

  • Install the base project dependencies and start the server by running these commands:
npm install
npm run dev

Why run npm install again?

The previous npm install added our extra packages but Vite's own template dependencies may not have been installed yet (since we declined the auto-install prompt). This ensures everything in package.json is present before we start the server.

Before you look at the browser, what do you expect to see at http://localhost:5173? Think about what a brand-new Vite React template looks like.

  • Open your browser and navigate to http://localhost:5173.

You should see the default Vite + React starter page with the Vite and React logos, a counter button, and some welcome text.

The dev server takes over your terminal

The npm run dev process keeps running to watch for file changes. When you need to run other commands later, open a second terminal in VS Code with Ctrl+Shift+`.

Page not loading?

If the browser shows "This site can't be reached", check your terminal for errors. Make sure npm run dev is still running and shows a Local: URL in the output.

If port 5173 is already in use, Vite will pick the next available port and show it in the terminal output. Use that URL instead.

Help me get my Vite dev server running

Your project is scaffolded and the dev server is running. Next up, you'll replace that starter page with a photorealistic 3D Earth globe.

Render a 3D Earth Globe

Your React project is scaffolded and the dev server is running. Now it is time to give this satellite tracker its most essential visual element: the Earth itself.

Getting a textured sphere with camera controls working proves the entire 3D pipeline is functional. It also gives you something visually exciting to build on for the rest of the project.

In this step, get ready to:
  • Create an Earth component with a NASA Blue Marble texture.
  • Set up a 3D scene with lighting and interactive camera controls.
  • Style the page for a full-screen space environment.
Create the Earth component

In React Three Fiber, 3D objects are described as JSX components. The Earth is a sphere with a photorealistic texture mapped onto it.

  • Create a new folder called components inside the src folder of your orbitx project.
  • Create a new file called Earth.tsx inside src/components/ and paste the following code:
import { useLoader } from '@react-three/fiber';
import { TextureLoader } from 'three';

// NASA Blue Marble texture hosted on a public CDN
const TEXTURE_URL =
  'https://unpkg.com/three-globe/example/img/earth-blue-marble.jpg';

export function Earth() {
  // Load the Earth texture asynchronously
  const texture = useLoader(TextureLoader, TEXTURE_URL);

  return (
    <mesh>
      <sphereGeometry args={[1, 64, 64]} />
      <meshStandardMaterial map={texture} />
    </mesh>
  );
}

What does this code do?

useLoader(TextureLoader, TEXTURE_URL) fetches the NASA Blue Marble image and converts it into a texture that Three.js can paint onto a 3D surface.

The <mesh> is a 3D object that combines geometry (a sphere with radius 1 and 64 segments for smoothness) with a material (the texture-mapped standard material that responds to lighting).

This component renders once the main scene imports it. You will see the Earth appear after the next chunk.

Set up the 3D scene

A 3D scene needs three things beyond the object itself: a camera to view from, lights to illuminate the surface, and controls so the viewer can interact.

  • Open src/App.tsx in VS Code.
  • Select all the existing code and replace it entirely with the following:
import { Suspense } from 'react';
import { Canvas } from '@react-three/fiber';
import { OrbitControls } from '@react-three/drei';
import { Earth } from './components/Earth';
import './App.css';

export default function App() {
  return (
    <div className="app">
      {/* Canvas creates the WebGL rendering context */}
      <Canvas camera={{ position: [0, 0, 3], fov: 45 }}>
        <ambientLight intensity={0.3} />
        <directionalLight position={[5, 3, 5]} intensity={1.5} />
        {/* Suspense shows nothing while the texture loads */}
        <Suspense fallback={null}>
          <Earth />
        </Suspense>
        <OrbitControls enablePan={false} minDistance={1.5} maxDistance={6} />
      </Canvas>
    </div>
  );
}

What does this code do?

  • Canvas creates a WebGL rendering context. The camera starts at position [0, 0, 3], looking at the origin where the Earth sits.
  • ambientLight provides soft base illumination so the dark side of Earth is not completely black.
  • directionalLight simulates sunlight from a specific direction, creating realistic shading on the sphere.
  • OrbitControls lets the user drag to rotate and scroll to zoom. Panning is disabled to keep Earth centered.

Before you check the browser, what do you think you will see? The texture is loaded from a CDN, so consider whether the page might briefly appear empty.

  • Save src/components/Earth.tsx and src/App.tsx.
  • Switch to your browser at http://localhost:5173. After a brief loading moment, you should see a textured Earth globe. Try dragging to rotate it and scrolling to zoom.

Don't see the Earth?

  • Check your browser console for errors. A common issue is a typo in the import path. Make sure ./components/Earth matches the folder and file name exactly (case-sensitive).
  • If you see a blank white or grey screen, the Canvas may have zero height. The next substep adds the CSS that gives it full-screen dimensions.
  • If the sphere appears but is completely dark (no texture), your network may be blocking the CDN. Check that you can reach unpkg.com in a new browser tab.

Help me debug why my Earth globe is not rendering.

✔️ Awesome, I've got everything!

Great. Double check you have saved both files before continuing.

ⓧ I'd like to double check the full code

Here is src/components/Earth.tsx:

import { useLoader } from '@react-three/fiber';
import { TextureLoader } from 'three';

const TEXTURE_URL =
  'https://unpkg.com/three-globe/example/img/earth-blue-marble.jpg';

export function Earth() {
  const texture = useLoader(TextureLoader, TEXTURE_URL);

  return (
    <mesh>
      <sphereGeometry args={[1, 64, 64]} />
      <meshStandardMaterial map={texture} />
    </mesh>
  );
}

And here is src/App.tsx:

import { Suspense } from 'react';
import { Canvas } from '@react-three/fiber';
import { OrbitControls } from '@react-three/drei';
import { Earth } from './components/Earth';
import './App.css';

export default function App() {
  return (
    <div className="app">
      <Canvas camera={{ position: [0, 0, 3], fov: 45 }}>
        <ambientLight intensity={0.3} />
        <directionalLight position={[5, 3, 5]} intensity={1.5} />
        <Suspense fallback={null}>
          <Earth />
        </Suspense>
        <OrbitControls enablePan={false} minDistance={1.5} maxDistance={6} />
      </Canvas>
    </div>
  );
}
Style the scene for full-screen space

The default Vite starter CSS adds margins, padding, and background colours that interfere with the 3D canvas. You need a clean, full-screen black canvas so the Earth floats in space.

  • Open src/index.css and replace all its contents with the following:
/* Reset all browser defaults */
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

/* Make the page fill the entire viewport with a black background */
html, body, #root {
  width: 100%;
  height: 100%;
  overflow: hidden;
  background: #000;
}

What does this do?

The global reset removes default browser margins so the canvas fills edge-to-edge. Setting overflow: hidden prevents scrollbars from appearing. The black background ensures any gap around the canvas blends into the space scene.

  • Open src/App.css and replace all its contents with the following:
/* Main app container fills the full viewport */
.app {
  width: 100%;
  height: 100%;
  position: relative;
}

/* Loading indicator centered on screen */
.loading {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  color: #00ff88;
  font-family: 'Courier New', monospace;
  font-size: 16px;
  letter-spacing: 2px;
}

/* Error message banner at top of screen */
.error {
  position: absolute;
  top: 20px;
  left: 50%;
  transform: translateX(-50%);
  color: #ff4444;
  font-family: 'Courier New', monospace;
  font-size: 14px;
  background: rgba(255, 0, 0, 0.1);
  padding: 10px 20px;
  border-radius: 8px;
  border: 1px solid rgba(255, 68, 68, 0.3);
}

Why add loading and error styles now?

The .app class gives the Canvas its full-screen dimensions. The .loading and .error classes are not used yet, but you will need them in the next step when you add data fetching. Setting them up now saves a context switch later.

  • Save both src/index.css and src/App.css.
  • Switch to your browser. The page should now be completely black with the Earth globe filling the viewport. No leftover Vite purple or white background.

Still seeing a white background or the old Vite page?

  • Make sure you replaced the entire contents of src/index.css. If any of the original Vite CSS remains, it can override your black background.
  • Confirm that src/App.tsx imports ./App.css (not the old stylesheet). The import line should read import './App.css'.
  • Try a hard refresh (Cmd+Shift+R on macOS or Ctrl+Shift+R on Windows) to clear cached styles.

Help me fix my CSS so the Earth renders on a full black background.

✔️ Awesome, I've got everything!

Great. Make sure both CSS files are saved before moving on.

ⓧ I'd like to double check the full code

Here is src/index.css:

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

html, body, #root {
  width: 100%;
  height: 100%;
  overflow: hidden;
  background: #000;
}

And here is src/App.css:

.app {
  width: 100%;
  height: 100%;
  position: relative;
}

.loading {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  color: #00ff88;
  font-family: 'Courier New', monospace;
  font-size: 16px;
  letter-spacing: 2px;
}

.error {
  position: absolute;
  top: 20px;
  left: 50%;
  transform: translateX(-50%);
  color: #ff4444;
  font-family: 'Courier New', monospace;
  font-size: 14px;
  background: rgba(255, 0, 0, 0.1);
  padding: 10px 20px;
  border-radius: 8px;
  border: 1px solid rgba(255, 68, 68, 0.3);
}

Take a moment to interact with your globe. Drag to spin the Earth. Scroll to zoom in close enough to see coastlines, then zoom back out. This is your 3D pipeline working end-to-end.

You have a photorealistic 3D Earth that responds to mouse input. Next up, you will fetch real orbital data and make the ISS appear on this globe in real-time.

Fetch ISS Orbital Data and Propagate Position

Your 3D Earth looks stunning, but right now it is just a spinning screensaver. To turn it into a satellite tracker, you need real orbital data and the math to compute where the ISS is at any given second.

In this step, you will fetch live Two-Line Element (TLE) data from CelesTrak and use the SGP4 propagation algorithm to compute the ISS position in real-time.

In this step, get ready to:
  • Create a satellite utility module that fetches TLE data and propagates orbital positions.
  • Connect the utility to your App so the ISS position logs to the console every second.
Create the satellite utility module

The ISS orbit is described by a TLE, a compact two-line format that encodes the satellite's orbital parameters. CelesTrak publishes up-to-date TLEs for every tracked object in space.

  • Create a new folder src/utils in your project.
  • Inside that folder, create a new file called satellite.ts and paste the following code:
import * as satellite from 'satellite.js';

const CELESTRAK_ISS_URL =
  'https://celestrak.org/NORAD/elements/gp.php?CATNR=25544&FORMAT=TLE';
const EARTH_RADIUS_KM = 6371;

// Shape of the position data we'll compute
export interface SatellitePosition {
  latitude: number;
  longitude: number;
  altitude: number;
  velocity: number;
}

// Fetch the latest TLE from CelesTrak's GP API
export async function fetchTLE(): Promise<{ line1: string; line2: string }> {
  const response = await fetch(CELESTRAK_ISS_URL);
  if (!response.ok) {
    throw new Error(`CelesTrak responded with status ${response.status}`);
  }
  const text = await response.text();
  const lines = text.trim().split('\n');
  return {
    line1: lines[1].trim(),
    line2: lines[2].trim(),
  };
}

What does this code do?

The CelesTrak URL targets NORAD catalog number 25544, which is the ISS. The response comes back as three lines: the satellite name, then two lines of orbital data.

The fetchTLE function grabs those two data lines and returns them. These lines encode everything needed to predict the ISS orbit: inclination, eccentricity, mean motion, and more.

  • Below the fetchTLE function, add the SGP4 propagation function:
// Propagate the ISS position at a given time using SGP4
export function getISSPosition(
  line1: string,
  line2: string,
  date: Date = new Date()
): SatellitePosition | null {
  const satrec = satellite.twoline2satrec(line1, line2);
  const result = satellite.propagate(satrec, date);
  if (result === null) return null;
  const gmst = satellite.gstime(date);
  const positionGd = satellite.eciToGeodetic(result.position, gmst);
  const velocity = Math.sqrt(
    result.velocity.x ** 2 + result.velocity.y ** 2 + result.velocity.z ** 2
  );
  return {
    latitude: satellite.degreesLat(positionGd.latitude),
    longitude: satellite.degreesLong(positionGd.longitude),
    altitude: positionGd.height,
    velocity,
  };
}

What is SGP4 propagation?

SGP4 is the algorithm that takes a TLE and a timestamp, then predicts exactly where the satellite is at that moment. It accounts for atmospheric drag, gravitational perturbations, and Earth's oblateness.

The function converts the raw result through three coordinate systems: ECI (Earth-Centered Inertial) to geodetic (latitude/longitude/altitude) to the final values you can read. The velocity is computed from the 3D velocity vector's magnitude.

  • Below getISSPosition, add two helper functions that you will use in the next step to draw the orbit trail:
// Compute points along the orbit for the trail visualization
export function getOrbitTrail(
  line1: string,
  line2: string,
  minutesBefore: number = 45,
  minutesAfter: number = 45,
  stepMinutes: number = 1
): [number, number, number][] {
  const points: [number, number, number][] = [];
  const now = Date.now();
  for (let m = -minutesBefore; m <= minutesAfter; m += stepMinutes) {
    const date = new Date(now + m * 60 * 1000);
    const pos = getISSPosition(line1, line2, date);
    if (pos) {
      points.push(latLngToVector3(pos.latitude, pos.longitude, pos.altitude));
    }
  }
  return points;
}
  • Finally, add the coordinate transform function at the bottom of the file:
// Convert lat/lng/altitude to Three.js XYZ coordinates
export function latLngToVector3(
  lat: number,
  lng: number,
  altitude: number
): [number, number, number] {
  const radius = 1 + altitude / EARTH_RADIUS_KM;
  const phi = (90 - lat) * (Math.PI / 180);
  const theta = (lng + 180) * (Math.PI / 180);
  const x = -(radius * Math.sin(phi) * Math.cos(theta));
  const y = radius * Math.cos(phi);
  const z = radius * Math.sin(phi) * Math.sin(theta);
  return [x, y, z];
}

Why do we need a coordinate transform?

Latitude and longitude are angles on a sphere, but Three.js needs XYZ Cartesian coordinates. This function uses standard spherical-to-Cartesian math to place a point at the correct 3D position above the globe surface.

  • Save src/utils/satellite.ts.
  • Check the terminal running your dev server. You should see no new compile errors.

Seeing a compile error in the terminal?

Make sure your file is saved at exactly src/utils/satellite.ts (not src/satellite.ts or utils/satellite.ts).

Check that the import * as satellite from 'satellite.js' line is at the very top. If you see a module-not-found error, confirm you ran the install command from Step 1.

Help me debug a compile error in satellite.ts

Connect the utility to the App

The satellite module is ready. Now you need to call it from your React app so the ISS position updates every second and logs to the console.

  • Open src/App.tsx and replace its entire contents. Start by pasting this top section:
import { Suspense, useState, useEffect, useRef } from 'react';
import { Canvas } from '@react-three/fiber';
import { OrbitControls } from '@react-three/drei';
import { Earth } from './components/Earth';
import { fetchTLE, getISSPosition, type SatellitePosition } from './utils/satellite';
import './App.css';

export default function App() {
  // Track the ISS position, loading state, and errors
  const [issPosition, setIssPosition] = useState<SatellitePosition | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
  const tleRef = useRef<{ line1: string; line2: string } | null>(null);

  useEffect(() => {
    let mounted = true;
    async function init() {
      try {
        const tle = await fetchTLE();
        if (!mounted) return;
        tleRef.current = tle;
        const position = getISSPosition(tle.line1, tle.line2);
        if (position) {
          setIssPosition(position);
          console.log(position);
        }
        setLoading(false);
      } catch {
        if (mounted) setError('Failed to fetch ISS data from CelesTrak');
        setLoading(false);
      }
    }
    init();
  • Immediately below, paste this code to complete the file:
    const interval = setInterval(() => {
      if (tleRef.current) {
        const position = getISSPosition(tleRef.current.line1, tleRef.current.line2);
        if (position) {
          setIssPosition(position);
          console.log(position);
        }
      }
    }, 1000);
    return () => { mounted = false; clearInterval(interval); };
  }, []);

  return (
    <div className="app">
      <Canvas camera={{ position: [0, 0, 3], fov: 45 }}>
        <ambientLight intensity={0.3} />
        <directionalLight position={[5, 3, 5]} intensity={1.5} />
        <Suspense fallback={null}>
          <Earth />
        </Suspense>
        <OrbitControls enablePan={false} minDistance={1.5} maxDistance={6} />
      </Canvas>
      {loading && <div className="loading">Loading ISS data...</div>}
      {error && <div className="error">{error}</div>}
    </div>
  );
}

What does this code do?

  • On mount, fetchTLE() grabs the latest ISS orbital data from CelesTrak and stores it in a ref so it survives re-renders.
  • A setInterval calls getISSPosition every 1000ms, propagating the current position from the stored TLE.
  • Each position is logged to the console with console.log(position) so you can confirm real data is flowing.
  • The cleanup function clears the interval and sets mounted = false to prevent state updates on an unmounted component.
  • Save src/App.tsx.

Before you check the console, what do you expect to see logged there? Think about what shape the position object has.

  • Open your browser at http://localhost:5173.
  • Open DevTools by pressing Cmd+Option+J (macOS) or Ctrl+Shift+J (Windows).
  • Look at the Console tab.

You should see an object logging every second with four properties: latitude, longitude, altitude (in km), and velocity (in km/s). The values change slightly each second as the ISS moves.

Not seeing any logs in the console?

  • If you see a green "Loading ISS data..." overlay that never disappears, your network may be blocking the CelesTrak request. Check the Network tab in DevTools for a failed request to celestrak.org.
  • If you see a red error banner, the fetch failed. A VPN or corporate firewall may block the request. Try disabling your VPN temporarily.
  • If the page is blank with errors in the console, check that your src/App.tsx import path matches exactly: ./utils/satellite.

Help me debug why ISS data is not logging to my console

✔️ Awesome, I've got everything!

Great. Double check you have saved both files.

ⓧ I'd like to double check the full code

For src/utils/satellite.ts, scroll up and confirm these sections are present in order:

  • The import * as satellite line, two constants, and the SatellitePosition interface.
  • The fetchTLE function that calls CelesTrak and returns line1 and line2.
  • The getISSPosition function using twoline2satrec, propagate, and eciToGeodetic.
  • The getOrbitTrail function that loops over time steps.
  • The latLngToVector3 function that converts to XYZ.

For src/App.tsx, confirm these sections are present in order:

  • Imports from react, @react-three/fiber, @react-three/drei, your Earth component, and your satellite utility.
  • Three useState hooks (issPosition, loading, error) and one useRef (tleRef).
  • A useEffect that calls init() on mount and sets up a 1-second interval with console.log(position).
  • The JSX return with the Canvas, Earth, OrbitControls, and conditional loading/error overlays.

You now have live orbital data streaming into your app every second. Next up, you will take those coordinates and plot the ISS directly on the globe with a glowing orbit trail.

Plot the ISS on the Globe with an Orbit Trail

You have live ISS coordinates printing in the console every second. The data is there, but there is no visual connection to the globe.

The problem is that geographic coordinates (latitude and longitude) are angles on a sphere. They do not map directly to the XYZ Cartesian space that Three.js uses for positioning objects. Your satellite.ts utility already includes a latLngToVector3 function that bridges this gap. In this step, you will use it to place a live ISS marker and orbit trail directly on the globe.

In this step, get ready to:
  • Understand how the coordinate transform converts lat/lng to 3D positions.
  • Create an ISS marker component that pulses red at the correct geographic location.
  • Create an orbit trail component that draws the ISS path around Earth.
Understand the coordinate transform

Before building the visual components, take a moment to understand how latLngToVector3 in your src/utils/satellite.ts converts geographic coordinates into 3D positions.

How does the coordinate transform work?

Latitude and longitude describe a point as angles on a sphere. Three.js positions objects using [x, y, z] Cartesian coordinates. The latLngToVector3 function uses standard spherical-to-Cartesian math to bridge the two systems.

  • It calculates a radius by scaling the ISS altitude relative to Earth's radius (6371 km). An altitude of ~400 km places the marker just above the globe surface.
  • It converts latitude to phi (polar angle from the top) and longitude to theta (rotation around the Y axis).
  • It outputs [x, y, z] using the formulas for spherical coordinates, adjusted for Three.js Y-up convention.

The getOrbitTrail function calls latLngToVector3 for 90 time steps (45 minutes before and after now) to produce an array of 3D points tracing one full orbit.

Create the ISS marker and orbit trail components

Now you will create two new React components. The ISS marker is a small red sphere that pulses to draw attention. The orbit trail is a green line tracing the orbital path.

  • Create a new file at src/components/ISSMarker.tsx and paste the following code:
import { useRef } from 'react';
import { useFrame } from '@react-three/fiber';
import * as THREE from 'three';
import { latLngToVector3 } from '../utils/satellite';

interface ISSMarkerProps {
  latitude: number;
  longitude: number;
  altitude: number;
}

export function ISSMarker({ latitude, longitude, altitude }: ISSMarkerProps) {
  const meshRef = useRef<THREE.Mesh>(null);
  const glowRef = useRef<THREE.Mesh>(null);
  // Convert lat/lng to 3D position using the coordinate transform
  const position = latLngToVector3(latitude, longitude, altitude);

What does this code do?

  • The ISSMarkerProps interface defines the three values the component needs: latitude, longitude, and altitude.
  • Two refs (meshRef and glowRef) give direct access to the 3D meshes for animation.
  • The latLngToVector3 call converts the geographic coordinates into [x, y, z] so Three.js knows where to place the marker.
  • Add the rest of the component directly below what you just pasted:
  useFrame(({ clock }) => {
    if (glowRef.current) {
      // Pulse the glow sphere using a sine wave
      const scale = 1 + Math.sin(clock.getElapsedTime() * 3) * 0.3;
      glowRef.current.scale.setScalar(scale);
    }
  });

  return (
    <group position={position}>
      <mesh ref={meshRef}>
        <sphereGeometry args={[0.02, 16, 16]} />
        <meshBasicMaterial color="#ff4444" />
      </mesh>
      <mesh ref={glowRef}>
        <sphereGeometry args={[0.035, 16, 16]} />
        <meshBasicMaterial color="#ff4444" transparent opacity={0.3} />
      </mesh>
    </group>
  );
}

What does this code do?

  • useFrame runs every animation frame (60 times per second). It scales the outer glow sphere up and down using a sine wave, creating a pulsing effect.
  • The JSX returns a <group> positioned at the ISS coordinates, containing two spheres: a solid red core and a larger semi-transparent glow around it.
  • Save src/components/ISSMarker.tsx.

Next, create the orbit trail component.

  • Create a new file at src/components/OrbitTrail.tsx and paste the following code:
import { Line } from '@react-three/drei';

interface OrbitTrailProps {
  points: [number, number, number][];
}

export function OrbitTrail({ points }: OrbitTrailProps) {
  // Only render if we have at least 2 points to draw a line
  if (points.length < 2) return null;
  return <Line points={points} color="#00ff88" lineWidth={1.5} />;
}

What does this code do?

  • The Line component from drei draws a smooth line through an array of 3D points. Each point comes from getOrbitTrail, which already computed the ISS path in 3D coordinates.
  • The guard clause (points.length < 2) prevents rendering errors if the data has not loaded yet.
  • Save src/components/OrbitTrail.tsx.
  • Confirm both files exist by running:
ls src/components/

You should see Earth.tsx, ISSMarker.tsx, and OrbitTrail.tsx listed.

Don't see all three files?

  • Make sure you created the files inside src/components/, not in the project root or src/ directly.
  • Check that file names match exactly, including the capital letters: ISSMarker.tsx and OrbitTrail.tsx.

Still having trouble? Help me find where my component files ended up.

Wire everything into the scene

The components exist but the scene does not know about them yet. You need to import them into App.tsx, compute the orbit trail on load, and render both components inside the Canvas.

  • In src/App.tsx, find the imports at the top of the file. Find these lines:
import { OrbitControls } from '@react-three/drei';
import { Earth } from './components/Earth';
import { fetchTLE, getISSPosition, type SatellitePosition } from './utils/satellite';
  • Replace those three lines with the following five lines to add the new component imports and getOrbitTrail:
import { OrbitControls } from '@react-three/drei';
import { Earth } from './components/Earth';
import { ISSMarker } from './components/ISSMarker';
import { OrbitTrail } from './components/OrbitTrail';
import { fetchTLE, getISSPosition, getOrbitTrail, type SatellitePosition } from './utils/satellite';
  • Next, find the state declarations. Find this line:
  const [issPosition, setIssPosition] = useState<SatellitePosition | null>(null);
  • Add a new state variable directly below it to store the orbit trail points:
  const [orbitPoints, setOrbitPoints] = useState<[number, number, number][]>([]);
  • Now update the init function inside useEffect. Find the existing try block:
      try {
        const tle = await fetchTLE();
        if (!mounted) return;
        tleRef.current = tle;
        const position = getISSPosition(tle.line1, tle.line2);
        if (position) {
          setIssPosition(position);
          console.log(position);
        }
        setLoading(false);
      }
  • Replace that try block with this version, which removes the console.log and computes the orbit trail:
      try {
        const tle = await fetchTLE();
        if (!mounted) return;
        tleRef.current = tle;
        const position = getISSPosition(tle.line1, tle.line2);
        if (position) setIssPosition(position);
        // Compute the full orbit trail on initial load
        const trail = getOrbitTrail(tle.line1, tle.line2);
        setOrbitPoints(trail);
        setLoading(false);
      }
  • In the same useEffect, find the interval callback:
    const interval = setInterval(() => {
      if (tleRef.current) {
        const position = getISSPosition(tleRef.current.line1, tleRef.current.line2);
        if (position) {
          setIssPosition(position);
          console.log(position);
        }
      }
    }, 1000);
  • Replace it with this cleaner version that removes the console.log:
    const interval = setInterval(() => {
      if (tleRef.current) {
        const position = getISSPosition(tleRef.current.line1, tleRef.current.line2);
        if (position) setIssPosition(position);
      }
    }, 1000);
  • Finally, add the ISS marker and orbit trail to the JSX. Find the closing </Suspense> tag inside the Canvas and add these lines directly after it:
        {issPosition && (
          <ISSMarker
            latitude={issPosition.latitude}
            longitude={issPosition.longitude}
            altitude={issPosition.altitude}
          />
        )}
        {orbitPoints.length > 1 && <OrbitTrail points={orbitPoints} />}

What do these JSX additions do?

  • The ISSMarker only renders when issPosition is not null (data has loaded). It receives the live latitude, longitude, and altitude that update every second.
  • The OrbitTrail renders once orbitPoints has at least 2 points. It draws the precomputed orbital path as a green line around Earth.
  • Save src/App.tsx.

Before you look at the browser, predict what you expect to see. Will the ISS marker appear over land or over ocean? Will the orbit trail be a straight line or a curve?

Check your browser at http://localhost:5173. You should see a red pulsing dot on the globe surface at the ISS's current position. A green curved line arcs around the Earth showing 90 minutes of the orbital path. Drag to rotate the globe and confirm the trail wraps around it.

Don't see the red marker or green trail?

  • Open the browser console (F12) and check for import errors. A typo in './components/ISSMarker' or './components/OrbitTrail' causes a module-not-found error.
  • If you see the globe but no marker, check that you removed the curly braces around the if (position) block in the interval. The old version had { setIssPosition(position); console.log(position); } which should now be just setIssPosition(position);.
  • If the marker appears but the trail does not, confirm you added getOrbitTrail to the import from './utils/satellite' and that setOrbitPoints(trail) is called inside the init function.

Still stuck? Help me debug why my ISS marker or orbit trail is not appearing on the globe.

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files: ISSMarker.tsx, OrbitTrail.tsx, and App.tsx.

ⓧ I'd like to double check the full code

Scroll up and confirm each piece is in place, in this order:

  • src/components/ISSMarker.tsx contains the imports, interface, function with useFrame animation, and the two-sphere JSX group (part 1 + part 2 pasted together).
  • src/components/OrbitTrail.tsx contains the Line import from drei, the interface, and the OrbitTrail function with the guard clause.
  • src/App.tsx has five component imports (including ISSMarker and OrbitTrail), the orbitPoints state, the updated init function with getOrbitTrail, and the two new JSX elements after </Suspense>.

Your ISS now has a visible presence on the globe, and the orbit trail shows exactly where it is heading. Next up, you will add an atmospheric glow, a starfield, and a live telemetry panel to turn this into a polished, portfolio-quality visualization.

Add Atmosphere, Starfield, and Telemetry Panel

The ISS is tracking correctly across the globe, but the scene still looks unfinished. A real Earth photo from space shows a thin blue haze at the edges, stars stretching into the distance, and instrument panels displaying live data.

In this step, you will add three finishing touches that transform your visualization from a tech demo into a polished, portfolio-quality piece.

In this step, get ready to:
  • Add a glowing atmosphere effect around the Earth using a custom shader.
  • Fill the background with stars for depth.
  • Build a live telemetry panel showing ISS position data.
Add the atmosphere glow effect

When you look at photos of Earth from space, you see a thin blue glow at the planet's edge. This is caused by sunlight scattering through the atmosphere at grazing angles.

We simulate this with a technique called a Fresnel shader. The idea is simple: pixels at the edge of the sphere (where the surface normal is perpendicular to your view direction) get brighter, while pixels facing you directly stay transparent.

  • Create a new file src/components/Atmosphere.tsx and add the following atmosphere component:
import * as THREE from 'three';

const vertexShader = `
  varying vec3 vNormal;
  void main() {
    vNormal = normalize(normalMatrix * normal);
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
  }
`;

const fragmentShader = `
  varying vec3 vNormal;
  void main() {
    float intensity = pow(0.65 - dot(vNormal, vec3(0.0, 0.0, 1.0)), 2.0);
    gl_FragColor = vec4(0.3, 0.6, 1.0, 1.0) * intensity;
  }
`;

export function Atmosphere() {
  return (
    <mesh scale={[1.15, 1.15, 1.15]}>
      <sphereGeometry args={[1, 64, 64]} />
      <shaderMaterial
        vertexShader={vertexShader}
        fragmentShader={fragmentShader}
        blending={THREE.AdditiveBlending}
        side={THREE.BackSide}
        transparent
      />
    </mesh>
  );
}

What does this shader do?

The vertex shader passes each pixel's surface normal to the fragment shader. The fragment shader then computes how "edge-on" each pixel is by taking the dot product of the normal with the camera's view direction vec3(0.0, 0.0, 1.0).

Pixels facing away from the camera (at the sphere's rim) get a high intensity value, creating the blue glow. Pixels facing directly toward the camera get zero intensity, staying invisible.

The sphere is scaled to 1.15 (slightly larger than Earth) and rendered with THREE.BackSide so only the inside faces are visible. THREE.AdditiveBlending makes the glow add to whatever is behind it rather than covering it.

Now wire the atmosphere into your scene.

  • In src/App.tsx, add this import below the existing OrbitTrail import:
import { Atmosphere } from './components/Atmosphere';
  • Inside the Canvas JSX, find the closing </Suspense> tag and add <Atmosphere /> directly below it:
        <Atmosphere />
  • Save both files.
  • Check your browser. You should see a soft blue glow hugging the edges of the Earth.

Don't see the blue glow?

  • Make sure the Atmosphere import path matches the filename exactly (case-sensitive).
  • Confirm <Atmosphere /> is inside the <Canvas> block, not outside it.
  • help me debug why my Atmosphere component isn't rendering a blue glow

✔️ Awesome, I've got everything!

Great. Make sure you have saved src/components/Atmosphere.tsx and src/App.tsx.

ⓧ I'd like to double check the full code

Here is the complete Atmosphere.tsx file:

import * as THREE from 'three';

const vertexShader = `
  varying vec3 vNormal;
  void main() {
    vNormal = normalize(normalMatrix * normal);
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
  }
`;

const fragmentShader = `
  varying vec3 vNormal;
  void main() {
    float intensity = pow(0.65 - dot(vNormal, vec3(0.0, 0.0, 1.0)), 2.0);
    gl_FragColor = vec4(0.3, 0.6, 1.0, 1.0) * intensity;
  }
`;

export function Atmosphere() {
  return (
    <mesh scale={[1.15, 1.15, 1.15]}>
      <sphereGeometry args={[1, 64, 64]} />
      <shaderMaterial
        vertexShader={vertexShader}
        fragmentShader={fragmentShader}
        blending={THREE.AdditiveBlending}
        side={THREE.BackSide}
        transparent
      />
    </mesh>
  );
}
Add the starfield

A black void behind the Earth feels flat. Adding a starfield gives the scene depth and makes it feel like you are looking out a spacecraft window.

  • In src/App.tsx, find the drei import at the top of the file:
import { OrbitControls } from '@react-three/drei';
  • Add Stars to the import so it reads:
import { OrbitControls, Stars } from '@react-three/drei';
  • Inside the Canvas JSX, add the Stars component just above the <OrbitControls line:
        <Stars
          radius={100}
          depth={50}
          count={5000}
          factor={4}
          fade
          speed={1}
        />

What do these Stars props do?

  • radius places stars on a sphere 100 units out, far behind the Earth.
  • count controls how many star points are generated.
  • fade makes distant stars dimmer, adding a sense of depth.
  • speed adds a subtle twinkle animation.
  • Save src/App.tsx.
  • Check your browser. Thousands of tiny white dots now fill the background behind the Earth.

Stars not visible?

  • Make sure Stars is imported from @react-three/drei in the same import line as OrbitControls.
  • Confirm the <Stars ... /> component is inside the <Canvas> block.
  • help me figure out why Stars from drei isn't showing in my scene
Build the telemetry panel

The ISS position data is updating every second in state, but the viewer has no way to see the numbers. A heads-up display showing latitude, longitude, altitude, and velocity completes the mission-control feel.

  • Create a new file src/components/InfoPanel.tsx and add the following component:
import type { SatellitePosition } from '../utils/satellite';

interface InfoPanelProps {
  position: SatellitePosition | null;
}

export function InfoPanel({ position }: InfoPanelProps) {
  if (!position) return null;

  return (
    <div className="info-panel">
      <h3>ISS Live Tracking</h3>
      <div className="info-grid">
        <span>Latitude</span>
        <span>{position.latitude.toFixed(4)}&deg;</span>
        <span>Longitude</span>
        <span>{position.longitude.toFixed(4)}&deg;</span>
        <span>Altitude</span>
        <span>{position.altitude.toFixed(1)} km</span>
        <span>Velocity</span>
        <span>{position.velocity.toFixed(2)} km/s</span>
      </div>
    </div>
  );
}

What does InfoPanel do?

This is a regular React component (not a 3D object). It renders an HTML overlay on top of the Canvas. When position is null (data still loading), it renders nothing.

The .toFixed() calls format the numbers to a consistent decimal length so the panel does not jitter as values change.

The panel needs CSS to position it over the canvas and give it the dark glassmorphic look.

  • Open src/App.css and add the following styles at the bottom of the file, after the existing .error block:
.info-panel {
  position: absolute;
  bottom: 20px;
  left: 20px;
  background: rgba(0, 0, 0, 0.85);
  border: 1px solid rgba(0, 255, 136, 0.3);
  border-radius: 12px;
  padding: 20px;
  color: white;
  font-family: 'Courier New', monospace;
  min-width: 260px;
  backdrop-filter: blur(10px);
}

.info-panel h3 {
  margin-bottom: 12px;
  color: #00ff88;
  font-size: 12px;
  text-transform: uppercase;
  letter-spacing: 3px;
}
  • Still in src/App.css, add the grid styles directly below the code you just pasted:
.info-grid {
  display: grid;
  grid-template-columns: auto auto;
  gap: 8px 16px;
  font-size: 13px;
}

.info-grid span:nth-child(odd) {
  color: #888;
}

.info-grid span:nth-child(even) {
  color: #fff;
  font-weight: bold;
}

How does the glassmorphic style work?

backdrop-filter: blur(10px) blurs whatever is behind the panel, creating a frosted-glass effect. Combined with the semi-transparent black background and the green border, it gives the panel a sleek mission-control aesthetic.

Finally, connect the InfoPanel to the scene.

  • In src/App.tsx, add this import below the Atmosphere import:
import { InfoPanel } from './components/InfoPanel';
  • Inside the return statement, find the closing {error && <div className="error"> line and its closing }. Add the InfoPanel component below it, just before the closing </div> of the app container:
      <InfoPanel position={issPosition} />
  • Save all three files: src/components/InfoPanel.tsx, src/App.css, and src/App.tsx.

Before you check the browser, think about what you expect to see. What should appear in the bottom-left corner?

  • Check your browser now.

You should see a dark panel in the bottom-left corner with a green "ISS LIVE TRACKING" header. Below it, four rows show Latitude, Longitude, Altitude, and Velocity. The numbers update every second as the ISS moves.

Panel not showing or unstyled?

  • Make sure <InfoPanel position={issPosition} /> is OUTSIDE the <Canvas> block but inside the .app div. HTML components cannot render inside a R3F Canvas.
  • Check that src/App.css has both .info-panel and .info-grid class definitions.
  • Confirm the className in InfoPanel.tsx is info-panel (with a hyphen, not camelCase).
  • help me debug why my InfoPanel component is not appearing over the 3D canvas

✔️ Awesome, I've got everything!

Great. Confirm all files are saved and your browser shows the atmosphere glow, starfield, and telemetry panel together.

ⓧ I'd like to double check the full code

Your src/App.tsx has grown across this step. Scroll through it and confirm these pieces are in place, in order:

  • Stars added to the @react-three/drei import.
  • Atmosphere and InfoPanel imported from their component files.
  • <Atmosphere /> placed after </Suspense>.
  • <Stars ... /> placed before <OrbitControls.
  • <InfoPanel position={issPosition} /> placed after the error div, outside the Canvas.

Here is the complete InfoPanel.tsx for reference:

import type { SatellitePosition } from '../utils/satellite';

interface InfoPanelProps {
  position: SatellitePosition | null;
}

export function InfoPanel({ position }: InfoPanelProps) {
  if (!position) return null;

  return (
    <div className="info-panel">
      <h3>ISS Live Tracking</h3>
      <div className="info-grid">
        <span>Latitude</span>
        <span>{position.latitude.toFixed(4)}&deg;</span>
        <span>Longitude</span>
        <span>{position.longitude.toFixed(4)}&deg;</span>
        <span>Altitude</span>
        <span>{position.altitude.toFixed(1)} km</span>
        <span>Velocity</span>
        <span>{position.velocity.toFixed(2)} km/s</span>
      </div>
    </div>
  );
}

Your visualization is complete. You have a photorealistic Earth with an atmospheric glow, a star-filled background, a live ISS marker tracing its orbit, and a telemetry panel showing real-time data. Next up, the Secret Mission challenges you to add realistic day/night lighting using the actual sun position.

Secret mission

Day/Night Lighting with Sun Position

Real satellite trackers show whether the ISS is in sunlight or Earth's shadow. Can you calculate the sun's actual position and use it to light only the daytime hemisphere of Earth in real-time?

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • The Vite dev server running at http://localhost:5173.
  • The orbitx project folder on your Desktop.

Keep everything running

No action needed. Choose this if you are still actively building or want to keep experimenting with the visualization.

  • Your dev server stays running at http://localhost:5173.
  • The orbitx folder and all its files remain on your Desktop.

Pause - I'll come back to this later

Stop the running dev server to free up your terminal. Your project files stay on disk so you can pick up where you left off.

  • Switch to the terminal running the dev server.
  • Press Ctrl+C to stop the Vite server.
  • When you return, navigate back to the orbitx folder and run npm run dev to start again.

Delete - I don't want to use this again

Remove all project files and stop the running process. This completely cleans up everything created during this project.

  • Switch to the terminal running the dev server and press Ctrl+C to stop it.
  • Delete the project folder by running:

macOS

cd ~/Desktop
rm -rf orbitx

Windows

cd ~/Desktop
Remove-Item -Recurse -Force orbitx
  • Confirm the folder is gone by running ls on your Desktop. The orbitx folder should no longer appear.

That's a Wrap!

That's a Wrap!

Nice work. You just built a real-time satellite tracking visualization that shows the International Space Station orbiting a photorealistic 3D Earth in your browser.

You've learned how to:

  • Built a full 3D Earth visualization with React Three Fiber and Three.js, including a custom atmosphere shader, starfield, and interactive camera controls.
  • Implemented real-time ISS tracking using SGP4 orbit propagation with live TLE data from CelesTrak, updating the position every second.
  • Learned the full coordinate transform pipeline that converts ECI satellite data to geodetic coordinates to Three.js Cartesian 3D space.
  • Secret Mission: Calculated the real sun position to create accurate day/night lighting with a live sunlight indicator on the telemetry panel.

Ready to quiz yourself?